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 `
`;
});
// 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 (
);
}
// 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 (

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 (

openImageModal?.({ url, alt: filename || 'Image' })}
style={{
cursor: 'pointer',
...(width && height ? {
maxWidth: Math.min(width, 400),
aspectRatio: `${width} / ${height}`
} : {})
}}
/>
);
}
if (isVideo) {
return (
);
}
if (isAudio) {
return (
);
}
// Generic file
return (
);
});
// ============================================================================
// 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}
) : (

)}
))}
)}
{/* Poll */}
{poll && (
{poll.question.emoji && (
poll.question.emoji.id ? (

) : (
{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.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.footer && (
{embed.footer.icon_url && (

)}
{decodeHtmlEntities(embed.footer.text)}
{embed.timestamp && (
<>
•
{formatTimestamp(embed.timestamp, 'short')}
>
)}
)}
))}
{/* 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 (
);
}
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 */}
{/* Search info banner */}
{searchQuery.trim().length >= 2 && searchResults !== null && (
Found {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} across all messages
)}
{/* Messages */}
{loadingMessages ? (
) : 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 && (
)}
{/* Reaction Users Popup */}
{reactionPopup && (
e.stopPropagation()}
>
{reactionPopup.emoji.id ? (

) : (
{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()}
>
)}
);
}