import React, { useState, useEffect, useLayoutEffect, useMemo, useCallback, memo, createContext, useContext, useRef } from 'react'; import { ProgressSpinner } from 'primereact/progressspinner'; import { authFetch } from '@/utils/authFetch'; // ============================================================================ // 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 {Function} options.onChannelClick - Callback when channel is clicked */ function parseDiscordMarkdown(text, options = {}) { if (!text) return ''; // 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(), 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>) parsed = parsed.replace(/<@&(\d+)>/g, (_, roleId) => { const role = rolesMap?.get?.(roleId) || rolesMap?.[roleId]; const roleName = role?.name || 'role'; const roleColor = role?.color || null; const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : ''; return `@${roleName}`; }); // Slash command mentions () parsed = parsed.replace(/<\/([^:]+):(\d+)>/g, '/$1'); // Custom emoji (<:name:123456789> or ) parsed = parsed.replace(/<(a)?:(\w+):(\d+)>/g, (_, animated, name, id) => { const ext = 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; } /** * 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 - trusted, use iframe if (preview.type === 'youtube' && preview.videoId) { return (
YouTube
{preview.title && ( {decodeHtmlEntities(preview.title)} )}