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('en-US', {
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('en-US', {
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('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
});
}
/**
* 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 {Function} options.onChannelClick - Callback when channel is clicked
*/
function parseDiscordMarkdown(text, options = {}) {
if (!text) return '';
const { channelMap = new Map(), usersMap = {}, onChannelClick } = options;
// Escape HTML first
let parsed = text
.replace(/&/g, '&')
.replace(//g, '>');
// Code blocks (``` ```) - add data-lenis-prevent for independent scrolling
// Must be processed first to prevent other formatting inside code
parsed = parsed.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => {
return `
${code.trim()}
`;
});
// 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, '@role');
// 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 `
`;
});
// 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 (
);
}
// 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 && (
{preview.siteName}
)}
{preview.title && (
{preview.title}
)}
{preview.description && (
{preview.description.length > 300
? preview.description.slice(0, 300) + '...'
: preview.description}
)}
{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);
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,
members,
onChannelSelect,
channelName,
onReactionClick,
onContextMenu
}) {
const {
id,
author,
content,
timestamp,
attachments = [],
embeds = [],
stickers = [],
reactions = [],
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
const urlsToPreview = useMemo(() => {
const embedUrls = new Set(embeds?.map(e => e.url).filter(Boolean));
return urls.filter(url => !embedUrls.has(url));
}, [urls, embeds]);
const parsedContent = useMemo(() => parseDiscordMarkdown(displayContent, { channelMap, usersMap }), [displayContent, channelMap, usersMap]);
// 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.)
if (type && type !== 0 && type !== 19) {
return (
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown'}
{' '}
{formatTimestamp(displayTimestamp)}
);
}
return (
<>
{/* Reply context */}
{referenced_message && (

'
}
alt=""
className="discord-reply-avatar"
/>
{referenced_message.author?.displayName || referenced_message.author?.username || 'Unknown'}
{referenced_message.content?.slice(0, 100) || 'Click to see attachment'}
{referenced_message.content?.length > 100 ? '...' : ''}
)}
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)}
)}
{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}
) : (

)}
))}
)}
{/* Original embeds from Discord */}
{embeds?.map((embed, idx) => (
{embed.author && (
{embed.author.icon_url && (

)}
{embed.author.name}
)}
{embed.title && (
embed.url ? (
{embed.title}
) : (
{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 && (
)}
{embed.footer && (
{embed.footer.icon_url && (

)}
{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 [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 [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 [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
// 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 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]);
// 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 (contextMenu) setContextMenu(null);
if (channelContextMenu) setChannelContextMenu(null);
}
};
const handleClickOutside = (e) => {
if (reactionPopup && !e.target.closest('.discord-reaction-popup')) {
closeReactionPopup();
}
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, contextMenu, closeImageModal, closeReactionPopup]);
// 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]);
// 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) from text channels (type 0)
const categories = {};
const textChannels = [];
data.forEach(channel => {
if (channel.type === 4) {
categories[channel.id] = {
id: channel.id,
name: channel.name,
position: channel.position,
guildId: channel.guildId,
};
} else {
textChannels.push(channel);
}
});
// 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;
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,
});
});
// 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({});
setHasMoreMessages(true);
// 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 }
const messagesData = data.messages || data;
const usersData = data.users || {};
// 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);
setHasMoreMessages(messagesData.length === 50);
// 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();
// Only re-fetch when channel ID changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedChannel?.id]);
// 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) {
// Use requestAnimationFrame to ensure DOM is fully painted
requestAnimationFrame(() => {
const targetElement = document.getElementById(`message-${targetMessageId}`);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Highlight the message with pulse + fade animation
targetElement.classList.add('discord-message-highlight');
setTimeout(() => {
targetElement.classList.remove('discord-message-highlight');
}, 5000); // 3 pulses (1.5s) + 3s fade
}
pendingTargetMessageRef.current = null;
});
return;
}
// Handle scroll to bottom on initial channel load
if (scrollToBottomRef.current) {
scrollToBottomRef.current = false;
container.scrollTop = container.scrollHeight;
}
}, [messages]);
// 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 }
const messagesData = data.messages || data;
const usersData = data.users || {};
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 }));
setHasMoreMessages(messagesData.length === 50);
} catch (err) {
console.error('Failed to load more messages:', err);
} finally {
setLoadingMore(false);
}
}, [loadingMore, hasMoreMessages, messages, selectedChannel]);
// Infinite scroll: load more when scrolling near the top
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 more 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;
});
});
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [hasMoreMessages, loadingMore, loadMoreMessages, searchQuery, searchResults]);
// Poll for new messages 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;
const pollInterval = setInterval(async () => {
try {
// Get the newest message ID (messages are in ASC order, so last = newest)
const newestMessage = messages[messages.length - 1];
const response = await authFetch(
`/api/discord/messages?channelId=${selectedChannel.id}&limit=50&after=${newestMessage.id}`
);
if (!response.ok) return;
const data = await response.json();
const messagesData = data.messages || data;
const usersData = data.users || {};
if (messagesData.length === 0) return;
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,
})),
}));
// 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);
// Append new messages (already in ASC order from API)
setMessages(prev => [...prev, ...normalizedMessages]);
setUsersMap(prev => ({ ...prev, ...usersData }));
// Auto-scroll to bottom if user was already near bottom
if (isNearBottom && container) {
setTimeout(() => {
container.scrollTop = container.scrollHeight;
}, 50);
}
} catch (err) {
console.error('Polling failed:', err);
}
}, 5000);
return () => clearInterval(pollInterval);
}, [selectedChannel, loadingMessages, messages, searchQuery, searchResults]);
// 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 from text channels
const categories = {};
const textChannels = [];
data.forEach(channel => {
if (channel.type === 4) {
categories[channel.id] = {
id: channel.id,
name: channel.name,
position: channel.position,
guildId: channel.guildId,
};
} else {
textChannels.push(channel);
}
});
// 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;
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,
});
});
// 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'}
{selectedChannel.name}
{selectedChannel.topic && (
<>
|
setTopicExpanded(!topicExpanded)}
title={topicExpanded ? 'Click to collapse' : 'Click to expand'}
dangerouslySetInnerHTML={{
__html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap })
}}
/>
>
)}
{selectedChannel.messageCount?.toLocaleString() || messages.length} messages
)}
{/* Main layout: sidebar + content */}
{/* Sidebar with Guild and Channel selector */}
{/* Guild tabs */}
{guilds.length > 1 && (
{guilds.map((guild) => (
))}
)}
{/* Channel list for selected guild */}
{selectedGuild && channelsByGuild[selectedGuild.id]?.length > 0 && (
{selectedGuild.name}
{(() => {
let lastCategory = null;
// Filter out channels with no messages (no access)
const accessibleChannels = channelsByGuild[selectedGuild.id].filter(c => c.messageCount > 0);
return accessibleChannels.map((channel) => {
const showCategoryHeader = channel.categoryName !== lastCategory;
lastCategory = channel.categoryName;
return (
{showCategoryHeader && channel.categoryName && (
)}
);
});
})()}
)}
{/* 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)}
);
}
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
)}
)}
{/* Message Context Menu */}
{contextMenu && (
e.stopPropagation()}
>
)}
{/* Channel Context Menu */}
{channelContextMenu && (
e.stopPropagation()}
>
)}
);
}