import React, { useState, useEffect, useLayoutEffect, useMemo, useCallback, memo, createContext, useContext, useRef } from 'react'; import { ProgressSpinner } from 'primereact/progressspinner'; import { authFetch } from '@/utils/authFetch'; // ============================================================================ // Discord Message Type Constants // https://discord.com/developers/docs/resources/channel#message-object-message-types const MESSAGE_TYPE_DEFAULT = 0; const MESSAGE_TYPE_REPLY = 19; const MESSAGE_TYPE_CHAT_INPUT_COMMAND = 20; const MESSAGE_TYPE_CONTEXT_MENU_COMMAND = 23; const MESSAGE_TYPE_POLL_RESULT = 46; // Image modal context for child components to trigger modal const ImageModalContext = createContext(null); // Trusted domains that can be embedded directly without server-side proxy const TRUSTED_DOMAINS = new Set([ 'youtube.com', 'www.youtube.com', 'youtu.be', 'instagram.com', 'www.instagram.com', 'twitter.com', 'x.com', 'www.twitter.com', 'twitch.tv', 'www.twitch.tv', 'clips.twitch.tv', 'spotify.com', 'open.spotify.com', 'soundcloud.com', 'www.soundcloud.com', 'vimeo.com', 'www.vimeo.com', 'imgur.com', 'i.imgur.com', 'giphy.com', 'media.giphy.com', 'tenor.com', 'media.tenor.com', 'gfycat.com', 'reddit.com', 'www.reddit.com', 'v.redd.it', 'i.redd.it', 'github.com', 'gist.github.com', 'raw.githubusercontent.com', 'avatars.githubusercontent.com', 'user-images.githubusercontent.com', 'camo.githubusercontent.com', 'opengraph.githubassets.com', 'codepen.io', 'codesandbox.io', 'streamable.com', 'medal.tv', 'discord.com', 'cdn.discordapp.com', 'media.discordapp.net', // Common image CDNs 'picsum.photos', 'images.unsplash.com', 'unsplash.com', 'pbs.twimg.com', 'abs.twimg.com', 'img.youtube.com', 'i.ytimg.com', 'w3schools.com', 'www.w3schools.com', // for demo video ]); // Image extensions const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i; // Video extensions const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|avi|mkv|m4v)(\?.*)?$/i; // Audio extensions const AUDIO_EXTENSIONS = /\.(mp3|wav|ogg|flac|m4a|aac)(\?.*)?$/i; /** * Check if URL is from a trusted domain */ function isTrustedDomain(url) { try { const parsed = new URL(url); return TRUSTED_DOMAINS.has(parsed.hostname); } catch { return false; } } /** * Check if a URL is already a proxy URL (from our server) */ function isProxyUrl(url) { if (!url) return false; return url.startsWith('/api/image-proxy'); } /** * Extract YouTube video ID */ function getYouTubeId(url) { try { const parsed = new URL(url); if (parsed.hostname === 'youtu.be') { return parsed.pathname.slice(1); } if (parsed.hostname.includes('youtube.com')) { return parsed.searchParams.get('v') || parsed.pathname.split('/').pop(); } } catch { return null; } return null; } /** * Format file size */ function formatFileSize(bytes) { if (!bytes) return ''; const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`; } /** * Format Discord timestamp */ function formatTimestamp(timestamp, format = 'full') { const date = new Date(timestamp); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); const isYesterday = new Date(now - 86400000).toDateString() === date.toDateString(); const time = date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true }); if (format === 'time') return time; if (isToday) return `Today at ${time}`; if (isYesterday) return `Yesterday at ${time}`; return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined }) + ` at ${time}`; } /** * Format date for divider */ function formatDateDivider(timestamp) { const date = new Date(timestamp); return date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); } /** * Decode HTML entities safely. Uses the DOM if available (client-side), otherwise * falls back to a robust regex for SSR environments. */ function decodeHtmlEntities(str) { if (!str) return str; try { if (typeof document !== 'undefined') { const tx = document.createElement('textarea'); tx.innerHTML = str; return tx.value; } } catch (e) { // fall through to fallback } return str.replace(/&(#(?:x[0-9a-fA-F]+|\d+)|[a-zA-Z]+);/g, (m, e) => { if (e[0] === '#') return e[1] === 'x' ? String.fromCharCode(parseInt(e.slice(2), 16)) : String.fromCharCode(parseInt(e.slice(1), 10)); const map = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', rsquo: '’', lsquo: '‘', hellip: '…', rdquo: '”', ldquo: '“' }; return map[e] || m; }); } /** * Parse archived messages from #dds-archive channel * These messages were re-sent by a bot with this format: * * **Topic/Category** * **YYYY-MM-DD HH:MM:SS.ssssss+00:00 (UTC) * username**: message content * * Note: The ** wraps from the timestamp line through the username * Some messages are nested (archive of an archive) and need recursive parsing * * Returns { originalAuthor, originalTimestamp, originalContent, topic } or null if not parseable */ function parseArchivedMessage(content, depth = 0) { if (!content || depth > 3) return null; // Prevent infinite recursion // The format is: // **Topic** // **Timestamp (UTC) // username**: content // Username cannot contain * or : characters, and ends with **: // This prevents matching nested archive content const boldPattern = /^\*\*(.+?)\*\*\s*\n\*\*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s*\(UTC\)\s*\n([^*:]+)\*\*:\s*([\s\S]*)$/; const boldMatch = content.match(boldPattern); if (boldMatch) { const topic = boldMatch[1].trim(); const timestampStr = boldMatch[2].trim(); let originalAuthor = boldMatch[3].trim(); let originalContent = boldMatch[4].trim(); // Parse the timestamp let originalTimestamp; try { const tsString = timestampStr.replace(' ', 'T'); originalTimestamp = new Date(tsString); if (isNaN(originalTimestamp.getTime())) { originalTimestamp = new Date(tsString + 'Z'); } } catch { return null; } if (isNaN(originalTimestamp.getTime())) { return null; } // Check if the content is itself another archive format (nested archive) // Pattern: **Topic**\n**username**: content (simpler format without timestamp) const nestedPattern = /^\*\*(.+?)\*\*\s*\n\*\*([^*:]+)\*\*:\s*([\s\S]*)$/; const nestedMatch = originalContent.match(nestedPattern); if (nestedMatch) { // This is a nested archive - use the inner author and content originalAuthor = nestedMatch[2].trim(); originalContent = nestedMatch[3].trim(); } // Also try recursive parsing for deeply nested archives const recursiveParsed = parseArchivedMessage(originalContent, depth + 1); if (recursiveParsed) { return { originalAuthor: recursiveParsed.originalAuthor, originalTimestamp: recursiveParsed.originalTimestamp || originalTimestamp.toISOString(), originalContent: recursiveParsed.originalContent, topic: recursiveParsed.topic || topic }; } return { originalAuthor, originalTimestamp: originalTimestamp.toISOString(), originalContent, topic }; } // Try simpler format: **Topic**\n**username**: content (no timestamp) const simplePattern = /^\*\*(.+?)\*\*\s*\n\*\*([^*:]+)\*\*:\s*([\s\S]*)$/; const simpleMatch = content.match(simplePattern); if (simpleMatch) { const topic = simpleMatch[1].trim(); const originalAuthor = simpleMatch[2].trim(); const originalContent = simpleMatch[3].trim(); return { originalAuthor, originalTimestamp: null, // No timestamp in this format originalContent, topic }; } // Fallback: try without bold markers (plain text format) const lines = content.split('\n'); if (lines.length < 3) return null; const topic = lines[0].replace(/^\*\*|\*\*$/g, '').trim(); const timestampLine = lines[1].replace(/^\*\*/, '').trim(); // Parse timestamp - format: "2024-11-08 18:41:34.031000+00:00 (UTC)" const timestampMatch = timestampLine.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s*(?:\(UTC\))?$/); if (!timestampMatch) return null; // Get the rest as author**: message or author: message const messagePart = lines.slice(2).join('\n').trim(); // Parse "author**: message" or "author: message" - author cannot contain * or : const authorMatch = messagePart.match(/^([^*:]+)\*?\*?:\s*([\s\S]*)$/); if (!authorMatch) return null; const originalAuthor = authorMatch[1].trim(); const originalContent = authorMatch[2].trim(); // Parse the timestamp let originalTimestamp; try { const tsString = timestampMatch[1].replace(' ', 'T'); originalTimestamp = new Date(tsString); if (isNaN(originalTimestamp.getTime())) { originalTimestamp = new Date(timestampMatch[1].replace(' ', 'T') + 'Z'); } } catch { return null; } if (isNaN(originalTimestamp.getTime())) { return null; } return { originalAuthor, originalTimestamp: originalTimestamp.toISOString(), originalContent, topic }; } /** * Hardcoded user data for archived messages * Since these users may not be mentioned in current messages, we store their data directly * This includes avatar URLs, display names, and colors from the database */ const ARCHIVE_USERS = { // kriegerin -> cyberkriegerin (user_id: 992437729927376996) 'kriegerin': { id: '992437729927376996', username: 'cyberkriegerin', displayName: 'kriegerin', avatar: 'https://cdn.discordapp.com/avatars/992437729927376996/3c4030cf3a210db4a180eab76e559ea2.png?size=1024', color: null, }, // codey/Chris -> gizmo_a (user_id: 1172340700663255091) 'codey': { id: '1172340700663255091', username: 'gizmo_a', displayName: 'Chris', avatar: 'https://cdn.discordapp.com/avatars/1172340700663255091/05b2a61faeba2363943a175df4ecb701.png?size=1024', color: null, }, 'Chris': { id: '1172340700663255091', username: 'gizmo_a', displayName: 'Chris', avatar: 'https://cdn.discordapp.com/avatars/1172340700663255091/05b2a61faeba2363943a175df4ecb701.png?size=1024', color: null, }, // Havoc bot (user_id: 1175471063438737519) 'Havoc': { id: '1175471063438737519', username: 'Havoc', displayName: 'Havoc', avatar: 'https://cdn.discordapp.com/avatars/1175471063438737519/5e70b92d710a8584d27ca76220f93d67.png?size=1024', color: null, bot: true, }, // Deleted User / slip (user_id: 456226577798135808) 'Deleted User': { id: '456226577798135808', username: 'slip', displayName: 'poopboy', avatar: 'https://codey.lol/images/456226577798135808.png', color: null, }, }; /** * Look up a real user from an archived username * First checks hardcoded ARCHIVE_USERS, then falls back to usersMap * If members data is provided, looks up color from there * Returns user data if found, otherwise returns a basic object with just the username */ function resolveArchivedUser(archivedUsername, usersMap, members) { // First check hardcoded archive users if (ARCHIVE_USERS[archivedUsername]) { const archivedUser = ARCHIVE_USERS[archivedUsername]; // Look up color from members data if available let color = archivedUser.color; if (!color && members?.groups && archivedUser.id) { for (const group of members.groups) { const member = group.members?.find(m => m.id === archivedUser.id); if (member?.color) { color = member.color; break; } } } return { ...archivedUser, color, isArchiveResolved: true, }; } // Fall back to usersMap lookup by username match if (usersMap) { for (const [userId, userData] of Object.entries(usersMap)) { if (userData.username === archivedUsername || userData.displayName === archivedUsername) { // Look up color from members data if not in usersMap let color = userData.color; if (!color && members?.groups) { for (const group of members.groups) { const member = group.members?.find(m => m.id === userId); if (member?.color) { color = member.color; break; } } } return { ...userData, id: userId, color, isArchiveResolved: true, }; } } } // Return basic user object if not found return { username: archivedUsername, displayName: archivedUsername, id: `archive-${archivedUsername}`, isArchiveResolved: false, }; } /** * Parse Discord markdown-like formatting * @param {string} text - The text to parse * @param {Object} options - Options for parsing * @param {Map} options.channelMap - Map of channel IDs to channel objects * @param {Object} options.usersMap - Map of user IDs to user objects { displayName, username, color } * @param {Object} options.rolesMap - Map of role IDs to role objects { name, color } * @param {Object} options.emojiCache - Map of emoji IDs to cached emoji objects { url, animated } * @param {Function} options.onChannelClick - Callback when channel is clicked */ function parseDiscordMarkdown(text, options = {}) { if (!text) return ''; try { // Normalize HTML entities that sometimes make it into messages/embed fields // We decode before we escape so strings like "A & B" become "A & B" // and avoid double-encoding when we later run an escape pass. const { channelMap = new Map(), usersMap = {}, rolesMap = new Map(), emojiCache = {}, onChannelClick } = options; // Normalize entities then escape HTML to avoid XSS while ensuring // already-encoded entities don't become double-encoded in the UI. const normalized = decodeHtmlEntities(text); // Escape HTML first let parsed = normalized .replace(/&/g, '&') .replace(//g, '>'); // Code blocks (``` ```) - add data-lenis-prevent for independent scrolling // Must be processed first to prevent other formatting inside code // Don't trim - preserve whitespace for ASCII art parsed = parsed.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => { // Only trim trailing newline, preserve all other whitespace const trimmedCode = code.replace(/\n$/, ''); return `
${trimmedCode}
`; }); // Inline code (`) - must be early to prevent formatting inside code parsed = parsed.replace(/`([^`]+)`/g, '$1'); // Blockquotes (> at start of line) - process before newline conversion // Group consecutive > lines into a single blockquote parsed = parsed.replace(/(^|\n)((?:> .+(?:\n|$))+)/gm, (_, before, block) => { const content = block .split('\n') .map(line => line.replace(/^> /, '')) .join('\n'); return `${before}
${content}
`; }); // Headings (# ## ###) - must be at start of line // Process before other inline formatting parsed = parsed.replace(/(^|\n)### (.+?)(?=\n|$)/gm, (_, before, content) => { return `${before}${content}`; }); parsed = parsed.replace(/(^|\n)## (.+?)(?=\n|$)/gm, (_, before, content) => { return `${before}${content}`; }); parsed = parsed.replace(/(^|\n)# (.+?)(?=\n|$)/gm, (_, before, content) => { return `${before}${content}`; }); // Subtext/small text (-# at start of line) - process before newline conversion parsed = parsed.replace(/(^|\n)-# (.+?)(?=\n|$)/gm, (_, before, content) => { return `${before}${content}`; }); // Unordered lists (- or * at start of line, but not ---) parsed = parsed.replace(/(^|\n)[-*] (.+?)(?=\n|$)/gm, (_, before, content) => { return `${before}• ${content}`; }); // Ordered lists (1. 2. etc at start of line) parsed = parsed.replace(/(^|\n)(\d+)\. (.+?)(?=\n|$)/gm, (_, before, num, content) => { return `${before}${num}. ${content}`; }); // Bold + Italic + Underline combinations (most specific first) // ___***text***___ or ***___text___*** parsed = parsed.replace(/(\*\*\*|___)(\*\*\*|___)([^*_]+)\2\1/g, '$3'); // Bold + Italic (***text***) parsed = parsed.replace(/\*\*\*([^*]+)\*\*\*/g, '$1'); // Bold + Underline (__**text**__ or **__text__**) parsed = parsed.replace(/__\*\*([^*_]+)\*\*__/g, '$1'); parsed = parsed.replace(/\*\*__([^*_]+)__\*\*/g, '$1'); // Italic + Underline (__*text*__ or *__text__* or ___text___) parsed = parsed.replace(/__\*([^*_]+)\*__/g, '$1'); parsed = parsed.replace(/\*__([^*_]+)__\*/g, '$1'); parsed = parsed.replace(/___([^_]+)___/g, '$1'); // Bold (**) parsed = parsed.replace(/\*\*([^*]+)\*\*/g, '$1'); // Underline (__) - must come before italic _ handling parsed = parsed.replace(/__([^_]+)__/g, '$1'); // Italic (* or _) parsed = parsed.replace(/\*([^*]+)\*/g, '$1'); parsed = parsed.replace(/\b_([^_]+)_\b/g, '$1'); // Strikethrough (~~) parsed = parsed.replace(/~~([^~]+)~~/g, '$1'); // Spoiler (||) parsed = parsed.replace(/\|\|([^|]+)\|\|/g, '$1'); // Discord Timestamps () parsed = parsed.replace(/<t:(\d+)(?::([tTdDfFR]))?>/g, (_, timestamp, format) => { const date = new Date(parseInt(timestamp) * 1000); let formatted; switch (format) { case 't': // Short time (9:30 PM) formatted = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); break; case 'T': // Long time (9:30:00 PM) formatted = date.toLocaleTimeString(); break; case 'd': // Short date (11/28/2024) formatted = date.toLocaleDateString(); break; case 'D': // Long date (November 28, 2024) formatted = date.toLocaleDateString([], { dateStyle: 'long' }); break; case 'f': // Short date/time (November 28, 2024 9:30 PM) default: formatted = date.toLocaleString([], { dateStyle: 'long', timeStyle: 'short' }); break; case 'F': // Long date/time (Thursday, November 28, 2024 9:30 PM) formatted = date.toLocaleString([], { dateStyle: 'full', timeStyle: 'short' }); break; case 'R': // Relative (2 hours ago) const now = Date.now(); const diff = now - date.getTime(); const seconds = Math.floor(Math.abs(diff) / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); const months = Math.floor(days / 30); const years = Math.floor(days / 365); if (diff < 0) { // Future if (years > 0) formatted = `in ${years} year${years > 1 ? 's' : ''}`; else if (months > 0) formatted = `in ${months} month${months > 1 ? 's' : ''}`; else if (days > 0) formatted = `in ${days} day${days > 1 ? 's' : ''}`; else if (hours > 0) formatted = `in ${hours} hour${hours > 1 ? 's' : ''}`; else if (minutes > 0) formatted = `in ${minutes} minute${minutes > 1 ? 's' : ''}`; else formatted = `in ${seconds} second${seconds > 1 ? 's' : ''}`; } else { // Past if (years > 0) formatted = `${years} year${years > 1 ? 's' : ''} ago`; else if (months > 0) formatted = `${months} month${months > 1 ? 's' : ''} ago`; else if (days > 0) formatted = `${days} day${days > 1 ? 's' : ''} ago`; else if (hours > 0) formatted = `${hours} hour${hours > 1 ? 's' : ''} ago`; else if (minutes > 0) formatted = `${minutes} minute${minutes > 1 ? 's' : ''} ago`; else formatted = `${seconds} second${seconds > 1 ? 's' : ''} ago`; } break; } return `${formatted}`; }); // User mentions (<@123456789>) parsed = parsed.replace(/<@!?(\d+)>/g, (_, userId) => { const user = usersMap[userId]; const displayName = user?.displayName || user?.username || 'User'; const colorStyle = user?.color ? ` style="color: ${user.color}"` : ''; return `@${displayName}`; }); // @everyone and @here mentions parsed = parsed.replace(/@(everyone|here)/g, '@$1'); // Channel mentions (<#123456789>) parsed = parsed.replace(/<#(\d+)>/g, (_, channelId) => { const channel = channelMap.get(channelId); const channelName = channel?.name || 'channel'; if (channel) { return `#${channelName}`; } return `#${channelName}`; }); // Role mentions (<@&123456789>) - robust lookup to avoid errors when rolesMap is missing or malformed parsed = parsed.replace(/<@&(\d+)>/g, (_, roleId) => { try { let role = null; if (rolesMap) { if (typeof rolesMap.get === 'function') { role = rolesMap.get(roleId); } else if (rolesMap[roleId]) { role = rolesMap[roleId]; } else if (rolesMap[String(roleId)]) { role = rolesMap[String(roleId)]; } } const roleName = role?.name || 'role'; const roleColor = role?.color || null; const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : ''; return `@${roleName}`; } catch (err) { // Defensive: log for telemetry/debug and return safe fallback try { console.error('parseDiscordMarkdown: role mention parse failed', { roleId, err, rolesMapType: rolesMap && typeof rolesMap }); } catch (e) { /* ignore logging errors */ } return `@role`; } }); // Slash command mentions () parsed = parsed.replace(/<\/([^:]+):(\d+)>/g, '/$1'); // Custom emoji (<:name:123456789> or ) // Use cached emoji URL if available, otherwise fall back to Discord CDN parsed = parsed.replace(/<(a)?:(\w+):(\d+)>/g, (_, animated, name, id) => { const cached = emojiCache[id]; const url = cached?.url || `https://cdn.discordapp.com/emojis/${id}.${animated ? 'gif' : 'png'}`; return `:${name}:`; }); // Unicode emoji (keep as-is, they render natively) // Masked/Markdown links [text](url) or [text](url "title") - process before bare URL detection parsed = parsed.replace( /\[([^\]]+)\]\((https?:\/\/[^\s)"]+)(?:\s+"([^"]+)")?\)/g, (_, text, url, title) => { const titleAttr = title ? ` title="${title}"` : ''; return `${text}`; } ); // Links (URLs) - use negative lookbehind to skip URLs in HTML attributes (src=", href=") // Match URLs that are NOT preceded by =" or =' parsed = parsed.replace( /(?"']+)/g, '$1' ); // Newlines parsed = parsed.replace(/\n/g, '
'); // Unescape Discord markdown escape sequences (\\_ \\* \\~ \\` \\| \\\\) // Must be done after all markdown processing parsed = parsed.replace(/\\([_*~`|\\])/g, '$1'); return parsed; } catch (err) { try { console.error('parseDiscordMarkdown failed', err); } catch (e) { /* ignore logging errors */ } // Fallback: return a safely-escaped version of the input to avoid crashing the UI const safe = String(text) .replace(/&/g, '&') .replace(//g, '>'); return safe; } } /** * Extract URLs from text */ function extractUrls(text) { if (!text) return []; const urlRegex = /(https?:\/\/[^\s<]+)/g; const matches = text.match(urlRegex); return matches || []; } // ============================================================================ // Link Preview Component // ============================================================================ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoad }) { const [preview, setPreview] = useState(cachedPreview || null); const [loading, setLoading] = useState(!cachedPreview); const [error, setError] = useState(false); const [imageError, setImageError] = useState(false); useEffect(() => { if (cachedPreview) { setPreview(cachedPreview); setLoading(false); return; } // For direct media URLs, only allow trusted domains if (IMAGE_EXTENSIONS.test(url)) { if (isTrustedDomain(url)) { const previewData = { url, type: 'image', image: url, trusted: true }; setPreview(previewData); setLoading(false); onPreviewLoad?.(url, previewData); } else { // Fetch through server to get metadata without exposing user IP fetchPreviewFromServer(); } return; } if (VIDEO_EXTENSIONS.test(url)) { if (isTrustedDomain(url)) { const previewData = { url, type: 'video', video: url, trusted: true }; setPreview(previewData); setLoading(false); onPreviewLoad?.(url, previewData); } else { // Don't show untrusted video embeds - too risky setError(true); setLoading(false); } return; } // Check for YouTube - trusted domain, can embed directly const ytId = getYouTubeId(url); if (ytId) { const previewData = { url, type: 'youtube', videoId: ytId, title: 'YouTube Video', siteName: 'YouTube', themeColor: '#FF0000', trusted: true, }; setPreview(previewData); setLoading(false); onPreviewLoad?.(url, previewData); return; } // All other URLs: fetch preview from server fetchPreviewFromServer(); async function fetchPreviewFromServer() { try { const response = await authFetch(`/api/link-preview?url=${encodeURIComponent(url)}`); if (!response.ok) throw new Error('Failed to fetch'); const data = await response.json(); if (data.error) throw new Error(data.error); setPreview(data); onPreviewLoad?.(url, data); } catch (err) { console.warn('Failed to fetch link preview:', url, err); setError(true); } finally { setLoading(false); } } }, [url, cachedPreview, onPreviewLoad]); if (loading) { return (
); } if (error || !preview) { return null; // Don't show anything for failed previews } // YouTube embed - use click-to-play thumbnail if (preview.type === 'youtube' && preview.videoId) { const thumbnailUrl = `https://img.youtube.com/vi/${preview.videoId}/maxresdefault.jpg`; const watchUrl = `https://www.youtube.com/watch?v=${preview.videoId}`; return (
YouTube
{preview.title && ( {decodeHtmlEntities(preview.title)} )}
{decodeHtmlEntities(preview.title)
); } // Direct video - only show if trusted if (preview.type === 'video' || preview.video) { const videoUrl = preview.video || url; if (!isTrustedDomain(videoUrl)) { return null; // Don't embed untrusted videos } return (
); } // Direct image - server already returns safe (proxied) URLs if (preview.type === 'image') { const imageUrl = preview.image || url; return (
Linked image e.target.style.display = 'none'} />
); } // Standard link preview - server already returns safe (proxied) image URLs const hasLargeImage = preview.image && !preview.thumbnail; return (
{preview.siteName && (
{decodeHtmlEntities(preview.siteName)}
)} {preview.title && ( {decodeHtmlEntities(preview.title)} )} {preview.description && (
{(() => { const d = decodeHtmlEntities(preview.description); return d.length > 300 ? d.slice(0, 300) + '...' : d; })()}
)}
{preview.image && !hasLargeImage && !imageError && ( setImageError(true)} /> )}
{preview.image && hasLargeImage && !imageError && ( setImageError(true)} /> )}
); }); // ============================================================================ // Attachment Component // ============================================================================ const Attachment = memo(function Attachment({ attachment }) { const { filename, url, size, content_type, width, height } = attachment; const openImageModal = useContext(ImageModalContext); const isImage = content_type?.startsWith('image/') || IMAGE_EXTENSIONS.test(filename || url); const isVideo = content_type?.startsWith('video/') || VIDEO_EXTENSIONS.test(filename || url) || (url && url.includes('/api/discord/cached-video')); const isAudio = content_type?.startsWith('audio/') || AUDIO_EXTENSIONS.test(filename || url); if (isImage) { return (
{filename openImageModal?.({ url, alt: filename || 'Image' })} style={{ cursor: 'pointer', ...(width && height ? { maxWidth: Math.min(width, 400), aspectRatio: `${width} / ${height}` } : {}) }} />
); } if (isVideo) { return (
); } if (isAudio) { return (
{filename || 'Audio file'} {size &&
{formatFileSize(size)}
}
); } // Generic file return (
{filename || 'File'} {size &&
{formatFileSize(size)}
}
); }); // ============================================================================ // Message Component // ============================================================================ const DiscordMessage = memo(function DiscordMessage({ message, isFirstInGroup, showTimestamp, previewCache, onPreviewLoad, channelMap, usersMap, emojiCache, members, onChannelSelect, channelName, onReactionClick, onPollVoterClick, onContextMenu, isSearchResult, onJumpToMessage }) { const { id, author, content, timestamp, attachments = [], embeds = [], stickers = [], reactions = [], poll = null, referenced_message, type } = message; // Check if this is an archived message from #dds-archive const isArchiveChannel = channelName === 'dds-archive'; const archivedMessage = useMemo(() => { if (!isArchiveChannel) return null; return parseArchivedMessage(content); }, [isArchiveChannel, content]); // Use original data if this is a parsed archive message // Try to resolve the archived username to a real user const displayAuthor = useMemo(() => { if (!archivedMessage) return author; return resolveArchivedUser(archivedMessage.originalAuthor, usersMap, members); }, [archivedMessage, author, usersMap, members]); const displayContent = archivedMessage ? archivedMessage.originalContent : content; const displayTimestamp = archivedMessage ? (archivedMessage.originalTimestamp || timestamp) : timestamp; // Extract URLs from content for link previews const urls = useMemo(() => extractUrls(displayContent), [displayContent]); // Filter URLs that don't already have embeds // For YouTube, compare video IDs since URLs can vary (youtu.be vs youtube.com, with/without playlist) const urlsToPreview = useMemo(() => { const embedUrls = new Set(embeds?.map(e => e.url).filter(Boolean)); // Extract YouTube video IDs from embeds const embedYouTubeIds = new Set(); embeds?.forEach(e => { if (e.url) { const ytId = getYouTubeId(e.url); if (ytId) embedYouTubeIds.add(ytId); } // Also check video URL for YouTube embeds if (e.video?.url) { const videoYtId = getYouTubeId(e.video.url); if (videoYtId) embedYouTubeIds.add(videoYtId); } }); // Exclude URLs that match any attachment's originalUrl const attachmentOriginalUrls = new Set(attachments?.map(a => a.originalUrl).filter(Boolean)); return urls.filter(url => { // Skip if exact URL match if (embedUrls.has(url)) return false; // For YouTube URLs, skip if video ID already embedded const ytId = getYouTubeId(url); if (ytId && embedYouTubeIds.has(ytId)) return false; // Skip if URL matches any attachment originalUrl if (attachmentOriginalUrls.has(url)) return false; return true; }); }, [urls, embeds, attachments]); // Build rolesMap from members data for role mention parsing (defensive) const rolesMap = useMemo(() => { const map = new Map(); if (Array.isArray(members?.roles)) { members.roles.forEach(role => map.set(role?.id, role)); } return map; }, [members?.roles]); const parsedContent = useMemo(() => parseDiscordMarkdown(displayContent, { channelMap, usersMap, rolesMap, emojiCache }), [displayContent, channelMap, usersMap, rolesMap, emojiCache]); // Handle channel link clicks const handleContentClick = useCallback((e) => { const channelLink = e.target.closest('.discord-channel-link'); if (channelLink) { e.preventDefault(); const channelId = channelLink.dataset.channelId; if (channelId && onChannelSelect) { const channel = channelMap?.get(channelId); if (channel) { onChannelSelect(channel); } } } }, [channelMap, onChannelSelect]); // For archived messages, use the resolved user's avatar if available // Avatar might be a full URL or just a hash - handle both cases const avatarUrl = useMemo(() => { const avatarSource = displayAuthor?.avatar || displayAuthor?.avatarUrl; if (!avatarSource) return null; if (avatarSource.startsWith('http')) return avatarSource; return `https://cdn.discordapp.com/avatars/${displayAuthor.id}/${avatarSource}.png?size=80`; }, [displayAuthor]); const getInitial = (name) => (name || 'U')[0].toUpperCase(); // System messages (join, boost, etc.) // Type 0 = default, 19 = reply, 20 = chat input command, 23 = context menu command // Types 20 and 23 are app/bot command messages and should render normally if ( type && type !== MESSAGE_TYPE_DEFAULT && type !== MESSAGE_TYPE_REPLY && type !== MESSAGE_TYPE_CHAT_INPUT_COMMAND && type !== MESSAGE_TYPE_CONTEXT_MENU_COMMAND ) { // Special handling for poll result system messages (type 46) if (type === MESSAGE_TYPE_POLL_RESULT) { // Find the poll_result embed const pollResultEmbed = embeds?.find(e => e.type === 'poll_result'); let pollFields = {}; if (pollResultEmbed && pollResultEmbed.fields) { for (const field of pollResultEmbed.fields) { pollFields[field.name] = field.value; } } // Get referenced poll message (should have poll info) const pollMessage = referenced_message; // Winner info const winnerText = pollFields['victor_answer_text'] || ''; const winnerEmoji = pollFields['victor_answer_emoji_name'] || ''; const winnerVotes = parseInt(pollFields['victor_answer_votes'] || '0', 10); const totalVotes = parseInt(pollFields['total_votes'] || '0', 10); const percent = totalVotes > 0 ? Math.round((winnerVotes / totalVotes) * 100) : 0; const pollTitle = pollFields['poll_question_text'] || (pollMessage?.poll?.question_text) || ''; // Author of the poll (from referenced message) const pollAuthor = pollMessage?.author; // Handler for View Poll button const handleViewPoll = () => { if (onJumpToMessage && pollMessage?.id) { onJumpToMessage(pollMessage.id); } }; return (
{pollAuthor?.displayName || pollAuthor?.username || 'Unknown'} ’s poll {pollTitle} has closed
{winnerEmoji} {winnerText}
{percent}% {totalVotes} vote{totalVotes !== 1 ? 's' : ''}
{formatTimestamp(displayTimestamp)}
); } // Default system message rendering return (
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown'} {' '} {formatTimestamp(displayTimestamp)}
); } return ( <> {/* Reply context */} {referenced_message && (
{ if (referenced_message.id && onJumpToMessage) { onJumpToMessage(referenced_message.id); } }} style={{ cursor: referenced_message.id ? 'pointer' : 'default' }} > ' } alt="" className="discord-reply-avatar" /> {referenced_message.author?.displayName || referenced_message.author?.username || 'Unknown'} 100 ? referenced_message.content.slice(0, 100) + '...' : referenced_message.content ).replace(/\n/g, ' '), { channelMap, usersMap, rolesMap, emojiCache } ).replace(//gi, ' ') : 'Click to see attachment' }} />
)}
onContextMenu?.(e, id, content)} > {isFirstInGroup ? (
{avatarUrl ? ( ) : (
{getInitial(displayAuthor?.username)}
)}
) : (
{formatTimestamp(displayTimestamp, 'time')}
)}
{isFirstInGroup && (
{/* Tags appear BEFORE the name */} {displayAuthor?.isServerForwarded && ( SERVER )} {(displayAuthor?.bot || displayAuthor?.isWebhook) && !displayAuthor?.isServerForwarded && ( APP )} {archivedMessage && ( ARCHIVED )} {displayAuthor?.displayName || displayAuthor?.username || 'Unknown User'} {formatTimestamp(displayTimestamp)} {isSearchResult && onJumpToMessage && ( )}
)} {displayContent && (
)} {/* Attachments */} {attachments?.length > 0 && (
{attachments.map((att, idx) => ( ))}
)} {/* Stickers */} {stickers?.length > 0 && (
{stickers.map((sticker) => (
{sticker.formatType === 3 ? ( // Lottie stickers - show placeholder or name
{sticker.name}
) : ( {sticker.name} )}
))}
)} {/* Poll */} {poll && (
{poll.question.emoji && ( poll.question.emoji.id ? ( {poll.question.emoji.name} ) : ( {poll.question.emoji.name} ) )} {poll.question.text} {poll.allowMultiselect && ( Select multiple )}
{poll.answers.map((answer) => { const percentage = poll.totalVotes > 0 ? Math.round((answer.voteCount / poll.totalVotes) * 100) : 0; return (
onPollVoterClick?.(e, answer)} style={{ cursor: answer.voters?.length ? 'pointer' : 'default' }}>
{answer.emoji && ( answer.emoji.id ? ( {answer.emoji.name} ) : ( {answer.emoji.name} ) )} {answer.text} {answer.voteCount} ({percentage}%)
{answer.voters?.length > 0 && (
{answer.voters.slice(0, 10).map((voter) => (
{voter.avatar ? ( ) : ( {(voter.displayName || voter.username || 'U')[0].toUpperCase()} )}
))} {answer.voters.length > 10 && ( +{answer.voters.length - 10} )}
)}
); })}
{poll.totalVotes} {poll.totalVotes === 1 ? 'vote' : 'votes'} {poll.isFinalized && ( Poll ended )} {!poll.isFinalized && poll.expiry && ( Ends {new Date(poll.expiry).toLocaleDateString()} )}
)} {/* Original embeds from Discord */} {embeds?.map((embed, idx) => (
{embed.author && (
{embed.author.icon_url && ( )} {embed.author.name}
)} {embed.title && ( embed.url ? ( {decodeHtmlEntities(embed.title)} ) : (
{decodeHtmlEntities(embed.title)}
) )} {embed.description && (
)} {/* Embed fields */} {embed.fields?.length > 0 && (
{embed.fields.map((field, fieldIdx) => (
{field.name && (
{field.name}
)} {field.value && (
)}
))}
)}
{embed.thumbnail?.url && embed.thumbnail.url.trim() && ( e.target.style.display = 'none'} /> )}
{embed.image?.url && ( )} {embed.video?.url && ( // Check if it's a YouTube URL - use click-to-play thumbnail embed.video.url.includes('youtube.com/embed/') || embed.video.url.includes('youtu.be') ? ( (() => { // Extract video ID from embed URL const videoId = embed.video.url.includes('youtube.com/embed/') ? embed.video.url.split('/embed/')[1]?.split('?')[0] : embed.video.url.split('/').pop()?.split('?')[0]; const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; const watchUrl = `https://www.youtube.com/watch?v=${videoId}`; return (
{embed.title
); })() ) : (
))} {/* Link previews for URLs in content */} {urlsToPreview.map((url) => ( ))} {/* Reactions */} {reactions?.length > 0 && (
{reactions.map((reaction, idx) => ( ))}
)}
); }); // ============================================================================ // Main DiscordLogs Component // ============================================================================ export default function DiscordLogs() { const messagesContainerRef = useRef(null); const pendingTargetMessageRef = useRef(null); // For deep-linking - used in fetch, cleared after scroll const scrollToBottomRef = useRef(false); // Flag to trigger scroll to bottom after initial load const [channels, setChannels] = useState([]); const [channelsByGuild, setChannelsByGuild] = useState({}); const [guilds, setGuilds] = useState([]); const [selectedGuild, setSelectedGuild] = useState(null); const [selectedChannel, setSelectedChannel] = useState(null); const [messages, setMessages] = useState([]); const [usersMap, setUsersMap] = useState({}); // Map of user ID -> { displayName, username, color } const [emojiCache, setEmojiCache] = useState({}); // Map of emoji ID -> { url, animated } const [members, setMembers] = useState(null); // { groups: [...], roles: [...] } const [loadingMembers, setLoadingMembers] = useState(false); const [memberListExpanded, setMemberListExpanded] = useState(false); // Default to collapsed const [linkCopied, setLinkCopied] = useState(false); const [loading, setLoading] = useState(true); const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(true); const [hasNewerMessages, setHasNewerMessages] = useState(false); // When viewing historical messages const [loadingNewer, setLoadingNewer] = useState(false); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); // Server-side search results const [searchLoading, setSearchLoading] = useState(false); const [previewCache, setPreviewCache] = useState({}); const [imageModal, setImageModal] = useState(null); // { url, alt } const [reactionPopup, setReactionPopup] = useState(null); // { x, y, emoji, users, loading } const [pollVoterPopup, setPollVoterPopup] = useState(null); // { x, y, answer, voters } const [contextMenu, setContextMenu] = useState(null); // { x, y, messageId, content } const [channelContextMenu, setChannelContextMenu] = useState(null); // { x, y, channel } const [topicExpanded, setTopicExpanded] = useState(false); // Show full channel topic const [refetchCounter, setRefetchCounter] = useState(0); // Counter to force re-fetch messages const lastPollTimeRef = useRef(new Date().toISOString()); // Track last poll time for edit detection // Collapsible categories in sidebar const [collapsedCategories, setCollapsedCategories] = useState({}); const handleCategoryToggle = useCallback((catName) => { setCollapsedCategories(prev => ({ ...prev, [catName]: !prev[catName] })); }, []); // Debounced server-side search useEffect(() => { if (!searchQuery.trim() || searchQuery.length < 2 || !selectedChannel) { setSearchResults(null); return; } const timeoutId = setTimeout(async () => { setSearchLoading(true); try { const response = await authFetch( `/api/discord/search?channelId=${selectedChannel.id}&q=${encodeURIComponent(searchQuery)}&limit=50` ); if (response.ok) { const data = await response.json(); // Normalize the search results const normalizedMessages = (data.messages || []).map(msg => ({ ...msg, referenced_message: msg.referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, content_type: att.contentType || att.content_type, })), })); setSearchResults(normalizedMessages); } } catch (err) { console.error('Search failed:', err); } finally { setSearchLoading(false); } }, 300); // 300ms debounce return () => clearTimeout(timeoutId); }, [searchQuery, selectedChannel]); // Open image modal const openImageModal = useCallback((imageData) => { setImageModal(imageData); }, []); // Close image modal const closeImageModal = useCallback(() => { setImageModal(null); }, []); // Handle reaction click - fetch users who reacted const handleReactionClick = useCallback(async (e, messageId, reaction) => { const rect = e.target.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top; // Show loading state setReactionPopup({ x, y, emoji: reaction.emoji, users: [], loading: true, }); try { const params = new URLSearchParams({ messageId, emojiName: reaction.emoji.name, }); if (reaction.emoji.id) { params.append('emojiId', reaction.emoji.id); } const response = await authFetch(`/api/discord/reaction-users?${params}`); if (!response.ok) throw new Error('Failed to fetch'); const users = await response.json(); setReactionPopup(prev => prev ? { ...prev, users, loading: false, } : null); } catch (err) { console.error('Failed to fetch reaction users:', err); setReactionPopup(null); } }, []); // Close reaction popup const closeReactionPopup = useCallback(() => { setReactionPopup(null); }, []); // Handle poll voter click - show voters popup const handlePollVoterClick = useCallback((e, answer) => { e.stopPropagation(); // Prevent click from bubbling up to handleClickOutside if (!answer?.voters?.length) return; const rect = e.currentTarget.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top; setPollVoterPopup({ x, y, answer, voters: answer.voters, }); }, []); // Close poll voter popup const closePollVoterPopup = useCallback(() => { setPollVoterPopup(null); }, []); // Handle message context menu (right-click) const handleMessageContextMenu = useCallback((e, messageId, content) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId, content, }); }, []); // Copy message link to clipboard const copyMessageLink = useCallback(() => { if (!contextMenu || !selectedGuild || !selectedChannel) return; const url = `${window.location.origin}${window.location.pathname}#${selectedGuild.id}/${selectedChannel.id}/${contextMenu.messageId}`; navigator.clipboard.writeText(url); setContextMenu(null); }, [contextMenu, selectedGuild, selectedChannel]); // Copy message content to clipboard const copyMessageContent = useCallback(() => { if (!contextMenu?.content) return; navigator.clipboard.writeText(contextMenu.content); setContextMenu(null); }, [contextMenu]); // Copy message ID to clipboard const copyMessageId = useCallback(() => { if (!contextMenu?.messageId) return; navigator.clipboard.writeText(contextMenu.messageId); setContextMenu(null); }, [contextMenu]); // Helper to scroll to a message element with correction for layout shifts const scrollToMessageElement = useCallback((element, highlightDuration = 2000) => { if (!element) return; // First scroll immediately to get close element.scrollIntoView({ behavior: 'instant', block: 'center' }); // Add highlight effect element.classList.add('discord-message-highlight'); setTimeout(() => element.classList.remove('discord-message-highlight'), highlightDuration); // After a delay for images to start loading, scroll again to correct for layout shifts setTimeout(() => { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 300); }, []); // Helper to wait for an element to exist in DOM then scroll to it const waitForElementAndScroll = useCallback((messageId, highlightDuration = 2000, maxAttempts = 20) => { let attempts = 0; const tryScroll = () => { const element = document.getElementById(`message-${messageId}`); if (element) { scrollToMessageElement(element, highlightDuration); return true; } attempts++; if (attempts < maxAttempts) { requestAnimationFrame(tryScroll); return false; } console.warn(`Could not find message-${messageId} after ${maxAttempts} attempts`); return false; }; requestAnimationFrame(tryScroll); }, [scrollToMessageElement]); // Jump to a specific message (used from search results and poll result view) const jumpToMessage = useCallback((messageId) => { if (!messageId) return; // First check if the message is already loaded const existingMessage = messages.find(m => m.id === messageId); if (existingMessage) { // Message is already loaded, just scroll to it const element = document.getElementById(`message-${messageId}`); if (element) { scrollToMessageElement(element); return; } } // Message not loaded, need to fetch around it // Clear search to exit search mode setSearchQuery(''); setSearchResults(null); // Set the target message ID for the fetch effect pendingTargetMessageRef.current = messageId; // Trigger a re-fetch by incrementing the counter setRefetchCounter(c => c + 1); setLoadingMessages(true); }, [messages, scrollToMessageElement]); // Handle channel context menu (right-click) const handleChannelContextMenu = useCallback((e, channel) => { e.preventDefault(); setChannelContextMenu({ x: e.clientX, y: e.clientY, channel, }); }, []); // Copy channel link to clipboard const copyChannelLink = useCallback(() => { if (!channelContextMenu?.channel || !selectedGuild) return; const url = `${window.location.origin}${window.location.pathname}#${selectedGuild.id}/${channelContextMenu.channel.id}`; navigator.clipboard.writeText(url); setChannelContextMenu(null); }, [channelContextMenu, selectedGuild]); // Copy channel ID to clipboard const copyChannelId = useCallback(() => { if (!channelContextMenu?.channel) return; navigator.clipboard.writeText(channelContextMenu.channel.id); setChannelContextMenu(null); }, [channelContextMenu]); // Copy channel name to clipboard const copyChannelName = useCallback(() => { if (!channelContextMenu?.channel) return; navigator.clipboard.writeText(channelContextMenu.channel.name); setChannelContextMenu(null); }, [channelContextMenu]); // Handle escape key to close modal useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'Escape') { if (imageModal) closeImageModal(); if (reactionPopup) closeReactionPopup(); if (pollVoterPopup) closePollVoterPopup(); if (contextMenu) setContextMenu(null); if (channelContextMenu) setChannelContextMenu(null); } }; const handleClickOutside = (e) => { if (reactionPopup && !e.target.closest('.discord-reaction-popup')) { closeReactionPopup(); } if (pollVoterPopup && !e.target.closest('.discord-reaction-popup')) { closePollVoterPopup(); } if (contextMenu && !e.target.closest('.discord-context-menu')) { setContextMenu(null); } if (channelContextMenu && !e.target.closest('.discord-context-menu')) { setChannelContextMenu(null); } }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('click', handleClickOutside); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('click', handleClickOutside); }; }, [imageModal, reactionPopup, pollVoterPopup, contextMenu, channelContextMenu, closeImageModal, closeReactionPopup, closePollVoterPopup]); // Update URL hash when guild/channel changes useEffect(() => { if (selectedGuild && selectedChannel) { const newHash = `#${selectedGuild.id}/${selectedChannel.id}`; if (window.location.hash !== newHash) { window.history.replaceState(null, '', newHash); } } }, [selectedGuild, selectedChannel]); // Copy shareable link to clipboard const copyShareLink = useCallback((messageId = null) => { let url = window.location.origin + window.location.pathname; if (selectedGuild && selectedChannel) { url += `#${selectedGuild.id}/${selectedChannel.id}`; if (messageId) { url += `/${messageId}`; } } navigator.clipboard.writeText(url).then(() => { setLinkCopied(true); setTimeout(() => setLinkCopied(false), 2000); }); }, [selectedGuild, selectedChannel]); // Create channel lookup map for mentions const channelMap = useMemo(() => { const map = new Map(); channels.forEach(channel => { map.set(channel.id, { id: channel.id, name: channel.name, guildId: channel.guildId, guildName: channel.guildName, guildIcon: channel.guildIcon, }); }); return map; }, [channels]); // Create roles lookup map for role mentions (defensive) const rolesMap = useMemo(() => { const map = new Map(); if (Array.isArray(members?.roles)) { members.roles.forEach(role => map.set(role?.id, role)); } return map; }, [members?.roles]); // Handle channel selection from mentions const handleChannelSelect = useCallback((channel) => { // Find the full channel object with guild info const guildId = channel.guildId || channelMap.get(channel.id)?.guildId; if (guildId) { const guild = guilds.find(g => g.id === guildId); if (guild) { setSelectedGuild(guild); } const channelList = channelsByGuild[guildId]; const fullChannel = channelList?.find(c => c.id === channel.id); if (fullChannel) { setSelectedChannel(fullChannel); } } }, [guilds, channelsByGuild, channelMap]); // Load channels from API useEffect(() => { async function fetchChannels() { try { const response = await authFetch('/api/discord/channels'); if (!response.ok) throw new Error('Failed to fetch channels'); const data = await response.json(); // Separate categories (type 4), text channels (type 0), and threads (types 10, 11, 12) const categories = {}; const textChannels = []; const threads = []; data.forEach(channel => { if (channel.type === 4) { categories[channel.id] = { id: channel.id, name: channel.name, position: channel.position, guildId: channel.guildId, }; } else if (channel.type === 10 || channel.type === 11 || channel.type === 12) { threads.push(channel); } else { textChannels.push(channel); } }); // Create a map of parent channel ID to threads const threadsByParent = {}; threads.forEach(thread => { if (thread.parentId) { if (!threadsByParent[thread.parentId]) { threadsByParent[thread.parentId] = []; } threadsByParent[thread.parentId].push(thread); } }); // Group channels by guild const byGuild = {}; const guildMap = {}; textChannels.forEach(channel => { const guildId = channel.guildId || 'unknown'; if (!byGuild[guildId]) { byGuild[guildId] = []; guildMap[guildId] = { id: guildId, name: channel.guildName || 'Discord Archive', icon: channel.guildIcon || null, }; } const categoryName = channel.parentId ? categories[channel.parentId]?.name : null; const categoryPosition = channel.parentId ? categories[channel.parentId]?.position : -1; // Get threads for this channel const channelThreads = threadsByParent[channel.id] || []; byGuild[guildId].push({ id: channel.id, name: channel.name, type: channel.type, position: channel.position, topic: channel.topic, parentId: channel.parentId, categoryName, categoryPosition, guild: guildMap[guildId], messageCount: channel.messageCount || 0, threads: channelThreads.map(t => ({ id: t.id, name: t.name, type: t.type, messageCount: t.messageCount || 0, parentId: t.parentId, guildId: t.guildId, guildName: t.guildName, guildIcon: t.guildIcon, })), }); }); // Sort guilds - put "no place like ::1" first const guildList = Object.values(guildMap).sort((a, b) => { if (a.name === 'no place like ::1') return -1; if (b.name === 'no place like ::1') return 1; return a.name.localeCompare(b.name); }); // Sort channels within each guild by category position, then channel position Object.keys(byGuild).forEach(guildId => { byGuild[guildId].sort((a, b) => { // First sort by category position (no category = -1, comes first) if (a.categoryPosition !== b.categoryPosition) { return a.categoryPosition - b.categoryPosition; } // Then by channel position within category return (a.position || 0) - (b.position || 0); }); }); setGuilds(guildList); setChannelsByGuild(byGuild); setChannels(data); // Check URL hash for deep linking const hash = window.location.hash.slice(1); // Remove # const [hashGuildId, hashChannelId, hashMessageId] = hash.split('/'); let initialGuild = null; let initialChannel = null; // Set target message ID for scrolling after messages load if (hashMessageId) { pendingTargetMessageRef.current = hashMessageId; } // Try to find guild/channel from hash if (hashGuildId && guildMap[hashGuildId]) { initialGuild = guildMap[hashGuildId]; if (hashChannelId && byGuild[hashGuildId]) { initialChannel = byGuild[hashGuildId].find(c => c.id === hashChannelId && c.messageCount > 0); } } // Fall back to first guild/channel if hash doesn't match if (!initialGuild && guildList.length > 0) { initialGuild = guildList[0]; } if (!initialChannel && initialGuild && byGuild[initialGuild.id]?.length > 0) { // Pick first channel with messages initialChannel = byGuild[initialGuild.id].find(c => c.type !== 4 && c.messageCount > 0); } if (initialGuild) { setSelectedGuild(initialGuild); } if (initialChannel) { setSelectedChannel(initialChannel); } } catch (err) { console.error('Failed to fetch channels:', err); setError('Failed to load channels'); } finally { setLoading(false); } } // Wait for auth to be ready before fetching fetchChannels(); }, []); // Load members when guild or channel changes useEffect(() => { if (!selectedGuild) return; async function fetchMembers() { setLoadingMembers(true); try { // Include channelId to filter members by channel visibility let url = `/api/discord/members?guildId=${selectedGuild.id}`; if (selectedChannel?.id) { url += `&channelId=${selectedChannel.id}`; } const response = await authFetch(url); if (!response.ok) throw new Error('Failed to fetch members'); const data = await response.json(); setMembers(data); } catch (err) { console.error('Failed to fetch members:', err); setMembers(null); } finally { setLoadingMembers(false); } } fetchMembers(); }, [selectedGuild, selectedChannel]); // Load messages from API when channel changes useEffect(() => { if (!selectedChannel) return; // Reset topic expanded state when channel changes setTopicExpanded(false); // Set loading immediately to prevent flash of "no messages" state setLoadingMessages(true); async function fetchMessages() { setMessages([]); setUsersMap({}); setEmojiCache({}); setHasMoreMessages(true); setHasNewerMessages(false); // Loading latest messages // Capture target message ID from ref (for deep-linking) const targetMessageId = pendingTargetMessageRef.current; try { // If we have a target message ID, use 'around' to fetch messages centered on it let url = `/api/discord/messages?channelId=${selectedChannel.id}&limit=50`; if (targetMessageId) { url += `&around=${targetMessageId}`; } const response = await authFetch(url); if (!response.ok) throw new Error('Failed to fetch messages'); const data = await response.json(); // Handle new response format { messages, users, emojiCache } const messagesData = data.messages || data; const usersData = data.users || {}; const emojiCacheData = data.emojiCache || {}; // If we were looking for a specific message but got no results, // fall back to loading the latest messages if (targetMessageId && messagesData.length === 0) { console.warn(`Target message ${targetMessageId} not found, loading latest messages`); pendingTargetMessageRef.current = null; const fallbackResponse = await authFetch(`/api/discord/messages?channelId=${selectedChannel.id}&limit=50`); if (fallbackResponse.ok) { const fallbackData = await fallbackResponse.json(); const fallbackMessages = fallbackData.messages || fallbackData; const fallbackUsers = fallbackData.users || {}; const fallbackEmojis = fallbackData.emojiCache || {}; const normalizedFallback = fallbackMessages.map(msg => ({ ...msg, referenced_message: msg.referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, content_type: att.contentType || att.content_type, })), })); setMessages(normalizedFallback.reverse()); setUsersMap(fallbackUsers); setEmojiCache(fallbackEmojis); setHasMoreMessages(fallbackMessages.length === 50); scrollToBottomRef.current = true; lastPollTimeRef.current = new Date().toISOString(); return; } } // Normalize field names from API to component expectations const normalizedMessages = messagesData.map(msg => ({ ...msg, referenced_message: msg.referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, content_type: att.contentType || att.content_type, })), })); // When using 'around', messages come back in ASC order already // When using default (no around), they come DESC so reverse them const orderedMessages = targetMessageId ? normalizedMessages : normalizedMessages.reverse(); setMessages(orderedMessages); setUsersMap(usersData); setEmojiCache(emojiCacheData); setHasMoreMessages(messagesData.length === 50); // Reset poll time for edit detection lastPollTimeRef.current = new Date().toISOString(); // Set flag to scroll after render (handled by useLayoutEffect) if (targetMessageId) { // Keep the target in pendingTargetMessageRef for useLayoutEffect } else { scrollToBottomRef.current = true; } } catch (err) { console.error('Failed to fetch messages:', err); setMessages([]); } finally { setLoadingMessages(false); } } fetchMessages(); // Re-fetch when channel ID changes or refetchCounter is incremented (for jump-to-message) // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedChannel?.id, refetchCounter]); // Handle scroll positioning after messages are rendered useLayoutEffect(() => { if (messages.length === 0) return; const container = messagesContainerRef.current; if (!container) return; // Handle target message (deep-linking) const targetMessageId = pendingTargetMessageRef.current; if (targetMessageId) { pendingTargetMessageRef.current = null; waitForElementAndScroll(targetMessageId, 5000); // 3 pulses (1.5s) + 3s fade return; } // Handle scroll to bottom on initial channel load if (scrollToBottomRef.current) { scrollToBottomRef.current = false; // Wait for content to render, then scroll to bottom const scrollToBottom = () => { container.scrollTop = container.scrollHeight; }; // Scroll immediately scrollToBottom(); // Then scroll again after a delay to correct for any layout shifts setTimeout(scrollToBottom, 300); setTimeout(scrollToBottom, 600); } }, [messages, waitForElementAndScroll]); // Load more messages (pagination) const loadMoreMessages = useCallback(async () => { if (loadingMore || !hasMoreMessages || messages.length === 0) return; setLoadingMore(true); try { // Get the oldest message ID for pagination (messages are in ASC order, so first = oldest) const oldestMessage = messages[0]; const response = await authFetch( `/api/discord/messages?channelId=${selectedChannel.id}&limit=50&before=${oldestMessage.id}` ); if (!response.ok) throw new Error('Failed to fetch more messages'); const data = await response.json(); // Handle new response format { messages, users, emojiCache } const messagesData = data.messages || data; const usersData = data.users || {}; const emojiCacheData = data.emojiCache || {}; const normalizedMessages = messagesData.map(msg => ({ ...msg, referenced_message: msg.referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, content_type: att.contentType || att.content_type, })), })); // Prepend older messages (reversed to maintain ASC order) setMessages(prev => [...normalizedMessages.reverse(), ...prev]); // Merge new users into existing usersMap setUsersMap(prev => ({ ...prev, ...usersData })); // Merge new emojis into existing emojiCache setEmojiCache(prev => ({ ...prev, ...emojiCacheData })); setHasMoreMessages(messagesData.length === 50); } catch (err) { console.error('Failed to load more messages:', err); } finally { setLoadingMore(false); } }, [loadingMore, hasMoreMessages, messages, selectedChannel]); // Jump to first message in channel const jumpToFirstMessage = useCallback(async () => { if (!selectedChannel || loadingMessages) return; setLoadingMessages(true); setMessages([]); try { // Fetch oldest messages using oldest=true parameter const response = await authFetch( `/api/discord/messages?channelId=${selectedChannel.id}&limit=50&oldest=true` ); if (!response.ok) throw new Error('Failed to fetch first messages'); const data = await response.json(); const messagesData = data.messages || data; const usersData = data.users || {}; const emojiCacheData = data.emojiCache || {}; const normalizedMessages = messagesData.map(msg => ({ ...msg, referenced_message: msg.referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, content_type: att.contentType || att.content_type, })), })); setMessages(normalizedMessages); setUsersMap(usersData); setEmojiCache(emojiCacheData); setHasMoreMessages(messagesData.length === 50); setHasNewerMessages(true); // We're viewing oldest, so there are newer messages // Scroll to top after messages load requestAnimationFrame(() => { if (messagesContainerRef.current) { messagesContainerRef.current.scrollTop = 0; } }); } catch (err) { console.error('Failed to jump to first message:', err); } finally { setLoadingMessages(false); } }, [selectedChannel, loadingMessages]); // Load newer messages (when viewing historical/oldest messages) const loadNewerMessages = useCallback(async () => { if (loadingNewer || !hasNewerMessages || messages.length === 0) return; setLoadingNewer(true); try { // Get the newest message ID currently loaded const newestMessage = messages[messages.length - 1]; const response = await authFetch( `/api/discord/messages?channelId=${selectedChannel.id}&limit=50&after=${newestMessage.id}` ); if (!response.ok) throw new Error('Failed to fetch newer messages'); const data = await response.json(); const messagesData = data.messages || data; const usersData = data.users || {}; const emojiCacheData = data.emojiCache || {}; const normalizedMessages = messagesData.map(msg => ({ ...msg, referenced_message: msg.referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, content_type: att.contentType || att.content_type, })), })); // Append newer messages setMessages(prev => [...prev, ...normalizedMessages]); setUsersMap(prev => ({ ...prev, ...usersData })); setEmojiCache(prev => ({ ...prev, ...emojiCacheData })); // If we got less than 50, we've reached the end (no more newer messages) if (messagesData.length < 50) { setHasNewerMessages(false); } } catch (err) { console.error('Failed to load newer messages:', err); } finally { setLoadingNewer(false); } }, [loadingNewer, hasNewerMessages, messages, selectedChannel]); // Infinite scroll: load more when scrolling near the top or bottom useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const handleScroll = () => { // Don't load more when viewing search results if (searchQuery.trim().length >= 2 && searchResults !== null) return; // Load older messages when within 200px of the top if (container.scrollTop < 200 && hasMoreMessages && !loadingMore) { // Save scroll position before loading const scrollHeightBefore = container.scrollHeight; loadMoreMessages().then(() => { // Restore scroll position after new messages are prepended requestAnimationFrame(() => { const scrollHeightAfter = container.scrollHeight; container.scrollTop = scrollHeightAfter - scrollHeightBefore; }); }); } // Load newer messages when within 200px of the bottom (when viewing historical) const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; if (distanceFromBottom < 200 && hasNewerMessages && !loadingNewer) { loadNewerMessages(); } }; container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); }, [hasMoreMessages, loadingMore, loadMoreMessages, hasNewerMessages, loadingNewer, loadNewerMessages, searchQuery, searchResults]); // Poll for new messages and edits every 5 seconds useEffect(() => { if (!selectedChannel || loadingMessages || messages.length === 0) return; // Don't poll when viewing search results - it would add messages that aren't in search if (searchQuery.trim().length >= 2 && searchResults !== null) return; // Don't poll when viewing historical messages (jumped to first) if (hasNewerMessages) return; const pollInterval = setInterval(async () => { try { const pollStartTime = new Date().toISOString(); // Get the newest message ID (messages are in ASC order, so last = newest) const newestMessage = messages[messages.length - 1]; // Fetch new messages after newest const newMsgsResponse = await authFetch( `/api/discord/messages?channelId=${selectedChannel.id}&limit=50&after=${newestMessage.id}` ); let newMessages = []; let newUsersData = {}; let newEmojiCacheData = {}; if (newMsgsResponse.ok) { const data = await newMsgsResponse.json(); const messagesData = data.messages || data; newUsersData = data.users || {}; newEmojiCacheData = data.emojiCache || {}; if (messagesData.length > 0) { newMessages = messagesData.map(msg => ({ ...msg, referenced_message: msg.referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, content_type: att.contentType || att.content_type, })), })); } } // Fetch messages edited since last poll const editedResponse = await authFetch( `/api/discord/messages?channelId=${selectedChannel.id}&limit=100&editedSince=${encodeURIComponent(lastPollTimeRef.current)}` ); let editedMessages = []; if (editedResponse.ok) { const editedData = await editedResponse.json(); const editedMessagesData = editedData.messages || editedData; const editedUsersData = editedData.users || {}; const editedEmojiCacheData = editedData.emojiCache || {}; newUsersData = { ...newUsersData, ...editedUsersData }; newEmojiCacheData = { ...newEmojiCacheData, ...editedEmojiCacheData }; editedMessages = editedMessagesData.map(msg => ({ ...msg, referenced_message: msg.referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, content_type: att.contentType || att.content_type, })), })); } // Update last poll time for next iteration lastPollTimeRef.current = pollStartTime; // Check if user is scrolled near bottom before adding new messages const container = messagesContainerRef.current; const isNearBottom = container && (container.scrollHeight - container.scrollTop - container.clientHeight < 100); // Only update state if there are changes if (newMessages.length > 0 || editedMessages.length > 0) { setMessages(prev => { // Create a map of existing messages by ID const msgMap = new Map(prev.map(m => [m.id, m])); // Update with edited messages (overwrite existing) for (const msg of editedMessages) { if (msgMap.has(msg.id)) { msgMap.set(msg.id, msg); } } // Append new messages for (const msg of newMessages) { if (!msgMap.has(msg.id)) { msgMap.set(msg.id, msg); } } // Convert back to array and sort by ID (snowflake = timestamp order) return Array.from(msgMap.values()).sort((a, b) => { if (a.id < b.id) return -1; if (a.id > b.id) return 1; return 0; }); }); setUsersMap(prev => ({ ...prev, ...newUsersData })); setEmojiCache(prev => ({ ...prev, ...newEmojiCacheData })); } // Auto-scroll to bottom if user was already near bottom and there are new messages if (isNearBottom && container && newMessages.length > 0) { setTimeout(() => { container.scrollTop = container.scrollHeight; }, 50); } } catch (err) { console.error('Polling failed:', err); } }, 5000); return () => clearInterval(pollInterval); }, [selectedChannel, loadingMessages, messages, searchQuery, searchResults, hasNewerMessages]); // Poll for channel/guild updates every 5 seconds useEffect(() => { if (loading) return; // Don't poll during initial load const pollInterval = setInterval(async () => { try { const response = await authFetch('/api/discord/channels'); if (!response.ok) return; const data = await response.json(); // Separate categories (type 4), text channels (type 0), and threads (types 10, 11, 12) const categories = {}; const textChannels = []; const threads = []; data.forEach(channel => { if (channel.type === 4) { categories[channel.id] = { id: channel.id, name: channel.name, position: channel.position, guildId: channel.guildId, }; } else if (channel.type === 10 || channel.type === 11 || channel.type === 12) { threads.push(channel); } else { textChannels.push(channel); } }); // Create a map of parent channel ID to threads const threadsByParent = {}; threads.forEach(thread => { if (thread.parentId) { if (!threadsByParent[thread.parentId]) { threadsByParent[thread.parentId] = []; } threadsByParent[thread.parentId].push(thread); } }); // Rebuild guild/channel maps const byGuild = {}; const guildMap = {}; textChannels.forEach(channel => { const guildId = channel.guildId || 'unknown'; if (!byGuild[guildId]) { byGuild[guildId] = []; guildMap[guildId] = { id: guildId, name: channel.guildName || 'Discord Archive', icon: channel.guildIcon || null, }; } const categoryName = channel.parentId ? categories[channel.parentId]?.name : null; const categoryPosition = channel.parentId ? categories[channel.parentId]?.position : -1; // Get threads for this channel const channelThreads = threadsByParent[channel.id] || []; byGuild[guildId].push({ id: channel.id, name: channel.name, type: channel.type, position: channel.position, topic: channel.topic, parentId: channel.parentId, categoryName, categoryPosition, guild: guildMap[guildId], messageCount: channel.messageCount || 0, threads: channelThreads.map(t => ({ id: t.id, name: t.name, type: t.type, messageCount: t.messageCount || 0, parentId: t.parentId, guildId: t.guildId, guildName: t.guildName, guildIcon: t.guildIcon, })), }); }); // Sort guilds const guildList = Object.values(guildMap).sort((a, b) => { if (a.name === 'no place like ::1') return -1; if (b.name === 'no place like ::1') return 1; return a.name.localeCompare(b.name); }); // Sort channels within each guild Object.keys(byGuild).forEach(guildId => { byGuild[guildId].sort((a, b) => { if (a.categoryPosition !== b.categoryPosition) { return a.categoryPosition - b.categoryPosition; } return (a.position || 0) - (b.position || 0); }); }); setGuilds(guildList); setChannelsByGuild(byGuild); setChannels(data); // Note: We don't update selectedChannel here to avoid triggering message reload // The channel list will show updated message counts from channelsByGuild } catch (err) { console.error('Channel polling failed:', err); } }, 5000); return () => clearInterval(pollInterval); }, [loading]); // Poll for member updates every 5 seconds useEffect(() => { if (!selectedGuild) return; const pollInterval = setInterval(async () => { try { // Include channelId to filter members by channel visibility let url = `/api/discord/members?guildId=${selectedGuild.id}`; if (selectedChannel?.id) { url += `&channelId=${selectedChannel.id}`; } const response = await authFetch(url); if (!response.ok) return; const data = await response.json(); setMembers(data); } catch (err) { console.error('Member polling failed:', err); } }, 5000); return () => clearInterval(pollInterval); }, [selectedGuild?.id, selectedChannel?.id]); // Handle preview cache updates const handlePreviewLoad = useCallback((url, preview) => { setPreviewCache(prev => ({ ...prev, [url]: preview })); }, []); // Filter messages by search - use server results if available, otherwise filter locally const filteredMessages = useMemo(() => { // If we have server-side search results, use those if (searchQuery.trim().length >= 2 && searchResults !== null) { return searchResults; } // No search query - return all messages if (!searchQuery.trim()) return messages; // Local filtering for short queries or while waiting for server results const query = searchQuery.toLowerCase(); return messages.filter(msg => { // Check message content if (msg.content?.toLowerCase().includes(query)) return true; // Check author username/displayName if (msg.author?.username?.toLowerCase().includes(query)) return true; if (msg.author?.displayName?.toLowerCase().includes(query)) return true; // Check embed content if (msg.embeds?.length > 0) { for (const embed of msg.embeds) { if (embed.title?.toLowerCase().includes(query)) return true; if (embed.description?.toLowerCase().includes(query)) return true; if (embed.author?.name?.toLowerCase().includes(query)) return true; if (embed.footer?.text?.toLowerCase().includes(query)) return true; // Check embed fields if (embed.fields?.length > 0) { for (const field of embed.fields) { if (field.name?.toLowerCase().includes(query)) return true; if (field.value?.toLowerCase().includes(query)) return true; } } } } return false; }); }, [messages, searchQuery, searchResults]); // Check if current channel is dds-archive const isArchiveChannel = selectedChannel?.name === 'dds-archive'; // Group messages by author and time window (5 minutes) // Messages are in ASC order (oldest first, newest at bottom), so we group accordingly // For archive channel, parse messages to get real author/timestamp for grouping const groupedMessages = useMemo(() => { const groups = []; let currentGroup = null; let lastDate = null; filteredMessages.forEach((message) => { // For archive channel, parse to get real author and timestamp let effectiveAuthor = message.author; let effectiveTimestamp = message.timestamp; if (isArchiveChannel) { const parsed = parseArchivedMessage(message.content); if (parsed) { // Use resolved user for proper grouping by real user ID const resolvedUser = resolveArchivedUser(parsed.originalAuthor, usersMap, members); effectiveAuthor = resolvedUser; effectiveTimestamp = parsed.originalTimestamp; } } const messageDate = new Date(effectiveTimestamp).toDateString(); // Check if we need a date divider (date changed from previous message) if (messageDate !== lastDate) { if (currentGroup) groups.push(currentGroup); groups.push({ type: 'divider', date: effectiveTimestamp }); currentGroup = null; lastDate = messageDate; } // For ASC order: check time diff from the LAST message in current group // (which is the most recent since we're iterating oldest to newest) const shouldStartNewGroup = !currentGroup || currentGroup.effectiveAuthor?.id !== effectiveAuthor?.id || Math.abs(new Date(currentGroup.messages[currentGroup.messages.length - 1].effectiveTimestamp) - new Date(effectiveTimestamp)) > 5 * 60 * 1000; if (shouldStartNewGroup) { if (currentGroup) groups.push(currentGroup); currentGroup = { type: 'messages', author: effectiveAuthor, effectiveAuthor, messages: [{ ...message, effectiveTimestamp }], }; } else { currentGroup.messages.push({ ...message, effectiveTimestamp }); } }); if (currentGroup) groups.push(currentGroup); return groups; }, [filteredMessages, isArchiveChannel, usersMap, members]); if (loading) { return (
Loading Discord archive...
); } if (error) { return (

Error

{error}

); } return (
{/* Header */} {selectedChannel && (
{selectedChannel.guild?.icon && ( )}

{selectedChannel.guild?.name || 'Discord Archive'}

{/* Show thread icon for thread types (10, 11, 12), otherwise channel hash */} {selectedChannel.type === 10 || selectedChannel.type === 11 || selectedChannel.type === 12 ? ( ) : ( )} {selectedChannel.name} {selectedChannel.topic && ( <> | setTopicExpanded(!topicExpanded)} title={topicExpanded ? 'Click to collapse' : 'Click to expand'} dangerouslySetInnerHTML={{ __html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap, rolesMap, emojiCache }) }} /> )}
{selectedChannel.messageCount?.toLocaleString() || messages.length} messages
)} {/* Main layout: sidebar + content */}
{/* Sidebar with Guild and Channel selector */}
{/* Guild tabs - compact vertical strip */} {guilds.length > 1 && (
{guilds.map((guild) => ( ))}
)} {/* Channel list for selected guild */}
{selectedGuild && channelsByGuild[selectedGuild.id]?.length > 0 && (
{selectedGuild.name}
{/* Collapsible categories state and handler at top level */} {/* ...existing code... */} {(() => { let lastCategory = null; // Filter out channels with no messages (no access), but include if they have threads with messages const accessibleChannels = channelsByGuild[selectedGuild.id].filter(c => c.messageCount > 0 || c.threads?.some(t => t.messageCount > 0) ); return accessibleChannels.map((channel) => { const showCategoryHeader = channel.categoryName !== lastCategory; lastCategory = channel.categoryName; // Filter threads that have messages const accessibleThreads = channel.threads?.filter(t => t.messageCount > 0) || []; const isCollapsed = channel.categoryName && collapsedCategories[channel.categoryName]; return ( {showCategoryHeader && channel.categoryName && (
handleCategoryToggle(channel.categoryName)} style={{ cursor: 'pointer', userSelect: 'none' }}> {channel.categoryName}
)} {!isCollapsed && channel.messageCount > 0 && ( )} {/* Threads under this channel */} {!isCollapsed && accessibleThreads.map((thread) => ( ))}
); }); })()}
)}
{/* Content area: search + messages */}
{/* Search bar */}
setSearchQuery(e.target.value)} /> {searchLoading && (
)}
{/* Search info banner */} {searchQuery.trim().length >= 2 && searchResults !== null && (
Found {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} across all messages
)} {/* Messages */} {loadingMessages ? (
Loading messages...
) : filteredMessages.length === 0 ? (

No messages found

{searchQuery ? 'No messages match your search. Try different keywords.' : 'This channel has no archived messages'}

) : (
{/* Loading indicator at top when fetching older messages */} {loadingMore && (
)} {groupedMessages.map((group, groupIdx) => { if (group.type === 'divider') { return (
{formatDateDivider(group.date)}
); } // Check if we're showing search results const isSearchMode = searchQuery.trim().length >= 2 && searchResults !== null; return (
{group.messages.map((message, msgIdx) => ( ))}
); })}
)}
{/* Member list panel */} {memberListExpanded && (
{loadingMembers ? (
) : members?.groups?.length > 0 ? ( <> {members.groups.map((group) => (
{group.role.name} — {group.members.length}
{group.members.map((member) => (
{member.avatar ? ( ) : (
{(member.displayName || member.username || 'U')[0].toUpperCase()}
)} {member.displayName || member.username} {member.isBot && APP}
))}
))} ) : (
No members found
)}
)}
{/* Image Modal */} {imageModal && (
e.stopPropagation()}> {imageModal.alt} Open original
)} {/* Reaction Users Popup */} {reactionPopup && (
e.stopPropagation()} >
{reactionPopup.emoji.id ? ( {reactionPopup.emoji.name} ) : ( {reactionPopup.emoji.name} )} {reactionPopup.users?.length || 0}
{reactionPopup.loading ? (
) : reactionPopup.users?.length > 0 ? ( reactionPopup.users.map((user) => (
{user.displayName}
)) ) : (
No users found
)}
)} {/* Poll Voter Popup - same style as reaction popup */} {pollVoterPopup && (
e.stopPropagation()} >
{pollVoterPopup.voters?.length || 0}
{pollVoterPopup.voters?.length > 0 ? ( pollVoterPopup.voters.map((voter) => (
{voter.displayName || voter.username}
)) ) : (
No voters found
)}
)} {/* Message Context Menu */} {contextMenu && (
e.stopPropagation()} >
)} {/* Channel Context Menu */} {channelContextMenu && (
e.stopPropagation()} >
)}
); }