2585 lines
117 KiB
React
2585 lines
117 KiB
React
|
|
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, '<')
|
||
|
|
.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 `<pre class="discord-code-block" data-lenis-prevent><code>${code.trim()}</code></pre>`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Inline code (`) - must be early to prevent formatting inside code
|
||
|
|
parsed = parsed.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
|
|
|
||
|
|
// 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}<blockquote class="discord-blockquote" data-lenis-prevent>${content}</blockquote>`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Headings (# ## ###) - must be at start of line
|
||
|
|
// Process before other inline formatting
|
||
|
|
parsed = parsed.replace(/(^|\n)### (.+?)(?=\n|$)/gm, (_, before, content) => {
|
||
|
|
return `${before}<span class="discord-heading discord-heading-3">${content}</span>`;
|
||
|
|
});
|
||
|
|
parsed = parsed.replace(/(^|\n)## (.+?)(?=\n|$)/gm, (_, before, content) => {
|
||
|
|
return `${before}<span class="discord-heading discord-heading-2">${content}</span>`;
|
||
|
|
});
|
||
|
|
parsed = parsed.replace(/(^|\n)# (.+?)(?=\n|$)/gm, (_, before, content) => {
|
||
|
|
return `${before}<span class="discord-heading discord-heading-1">${content}</span>`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Subtext/small text (-# at start of line) - process before newline conversion
|
||
|
|
parsed = parsed.replace(/(^|\n)-# (.+?)(?=\n|$)/gm, (_, before, content) => {
|
||
|
|
return `${before}<span class="discord-subtext">${content}</span>`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Unordered lists (- or * at start of line, but not ---)
|
||
|
|
parsed = parsed.replace(/(^|\n)[-*] (.+?)(?=\n|$)/gm, (_, before, content) => {
|
||
|
|
return `${before}<span class="discord-list-item">• ${content}</span>`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Ordered lists (1. 2. etc at start of line)
|
||
|
|
parsed = parsed.replace(/(^|\n)(\d+)\. (.+?)(?=\n|$)/gm, (_, before, num, content) => {
|
||
|
|
return `${before}<span class="discord-list-item">${num}. ${content}</span>`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Bold + Italic + Underline combinations (most specific first)
|
||
|
|
// ___***text***___ or ***___text___***
|
||
|
|
parsed = parsed.replace(/(\*\*\*|___)(\*\*\*|___)([^*_]+)\2\1/g, '<strong><em><u>$3</u></em></strong>');
|
||
|
|
|
||
|
|
// Bold + Italic (***text***)
|
||
|
|
parsed = parsed.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||
|
|
|
||
|
|
// Bold + Underline (__**text**__ or **__text__**)
|
||
|
|
parsed = parsed.replace(/__\*\*([^*_]+)\*\*__/g, '<u><strong>$1</strong></u>');
|
||
|
|
parsed = parsed.replace(/\*\*__([^*_]+)__\*\*/g, '<strong><u>$1</u></strong>');
|
||
|
|
|
||
|
|
// Italic + Underline (__*text*__ or *__text__* or ___text___)
|
||
|
|
parsed = parsed.replace(/__\*([^*_]+)\*__/g, '<u><em>$1</em></u>');
|
||
|
|
parsed = parsed.replace(/\*__([^*_]+)__\*/g, '<em><u>$1</u></em>');
|
||
|
|
parsed = parsed.replace(/___([^_]+)___/g, '<u><em>$1</em></u>');
|
||
|
|
|
||
|
|
// Bold (**)
|
||
|
|
parsed = parsed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
|
|
|
||
|
|
// Underline (__) - must come before italic _ handling
|
||
|
|
parsed = parsed.replace(/__([^_]+)__/g, '<u>$1</u>');
|
||
|
|
|
||
|
|
// Italic (* or _)
|
||
|
|
parsed = parsed.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||
|
|
parsed = parsed.replace(/\b_([^_]+)_\b/g, '<em>$1</em>');
|
||
|
|
|
||
|
|
// Strikethrough (~~)
|
||
|
|
parsed = parsed.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
||
|
|
|
||
|
|
// Spoiler (||)
|
||
|
|
parsed = parsed.replace(/\|\|([^|]+)\|\|/g, '<span class="discord-spoiler">$1</span>');
|
||
|
|
|
||
|
|
// Discord Timestamps (<t:1234567890:F>)
|
||
|
|
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 `<span class="discord-timestamp" title="${date.toLocaleString()}">${formatted}</span>`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// 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 `<span class="discord-mention"${colorStyle}>@${displayName}</span>`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// @everyone and @here mentions
|
||
|
|
parsed = parsed.replace(/@(everyone|here)/g, '<span class="discord-mention discord-mention-everyone">@$1</span>');
|
||
|
|
|
||
|
|
// Channel mentions (<#123456789>)
|
||
|
|
parsed = parsed.replace(/<#(\d+)>/g, (_, channelId) => {
|
||
|
|
const channel = channelMap.get(channelId);
|
||
|
|
const channelName = channel?.name || 'channel';
|
||
|
|
if (channel) {
|
||
|
|
return `<a href="#" class="discord-mention discord-channel-link" data-channel-id="${channelId}">#${channelName}</a>`;
|
||
|
|
}
|
||
|
|
return `<span class="discord-mention">#${channelName}</span>`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Role mentions (<@&123456789>)
|
||
|
|
parsed = parsed.replace(/<@&(\d+)>/g, '<span class="discord-mention">@role</span>');
|
||
|
|
|
||
|
|
// Slash command mentions (</command:123456789>)
|
||
|
|
parsed = parsed.replace(/<\/([^:]+):(\d+)>/g, '<span class="discord-slash-command">/$1</span>');
|
||
|
|
|
||
|
|
// Custom emoji (<:name:123456789> or <a:name:123456789>)
|
||
|
|
parsed = parsed.replace(/<(a)?:(\w+):(\d+)>/g, (_, animated, name, id) => {
|
||
|
|
const ext = animated ? 'gif' : 'png';
|
||
|
|
return `<img class="discord-emoji" src="https://cdn.discordapp.com/emojis/${id}.${ext}" alt=":${name}:" title=":${name}:">`;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Unicode emoji (keep as-is, they render natively)
|
||
|
|
|
||
|
|
// Masked/Markdown links [text](url) or [text](url "title") - process before bare URL detection
|
||
|
|
parsed = parsed.replace(
|
||
|
|
/\[([^\]]+)\]\((https?:\/\/[^\s)"]+)(?:\s+"([^"]+)")?\)/g,
|
||
|
|
(_, text, url, title) => {
|
||
|
|
const titleAttr = title ? ` title="${title}"` : '';
|
||
|
|
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="discord-link"${titleAttr}>${text}</a>`;
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
// Links (URLs) - use negative lookbehind to skip URLs in HTML attributes (src=", href=")
|
||
|
|
// Match URLs that are NOT preceded by =" or ='
|
||
|
|
parsed = parsed.replace(
|
||
|
|
/(?<![="'])(?<![=]["'])(https?:\/\/[^\s<>"']+)/g,
|
||
|
|
'<a href="$1" target="_blank" rel="noopener noreferrer" class="discord-link">$1</a>'
|
||
|
|
);
|
||
|
|
|
||
|
|
// Newlines
|
||
|
|
parsed = parsed.replace(/\n/g, '<br>');
|
||
|
|
|
||
|
|
// 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 (
|
||
|
|
<div className="discord-embed discord-embed-loading">
|
||
|
|
<ProgressSpinner style={{ width: '20px', height: '20px' }} strokeWidth="4" />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error || !preview) {
|
||
|
|
return null; // Don't show anything for failed previews
|
||
|
|
}
|
||
|
|
|
||
|
|
// YouTube embed - trusted, use iframe
|
||
|
|
if (preview.type === 'youtube' && preview.videoId) {
|
||
|
|
return (
|
||
|
|
<div className="discord-embed discord-embed-video" style={{ borderColor: preview.themeColor || '#FF0000' }}>
|
||
|
|
<div className="discord-embed-content">
|
||
|
|
<div className="discord-embed-provider">YouTube</div>
|
||
|
|
{preview.title && (
|
||
|
|
<a href={url} target="_blank" rel="noopener noreferrer" className="discord-embed-title">
|
||
|
|
{preview.title}
|
||
|
|
</a>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="discord-embed-video-container">
|
||
|
|
<iframe
|
||
|
|
src={`https://www.youtube.com/embed/${preview.videoId}`}
|
||
|
|
title={preview.title || 'YouTube video'}
|
||
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||
|
|
allowFullScreen
|
||
|
|
className="discord-embed-iframe"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 (
|
||
|
|
<div className="discord-embed discord-embed-video" style={{ borderColor: preview.themeColor || '#5865f2' }}>
|
||
|
|
<video
|
||
|
|
src={videoUrl}
|
||
|
|
controls
|
||
|
|
className="discord-embed-video-player"
|
||
|
|
preload="metadata"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Direct image - server already returns safe (proxied) URLs
|
||
|
|
if (preview.type === 'image') {
|
||
|
|
const imageUrl = preview.image || url;
|
||
|
|
return (
|
||
|
|
<div className="discord-attachment-image-wrapper">
|
||
|
|
<img
|
||
|
|
src={imageUrl}
|
||
|
|
alt="Linked image"
|
||
|
|
className="discord-attachment-image"
|
||
|
|
loading="lazy"
|
||
|
|
onError={(e) => e.target.style.display = 'none'}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Standard link preview - server already returns safe (proxied) image URLs
|
||
|
|
const hasLargeImage = preview.image && !preview.thumbnail;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className="discord-embed"
|
||
|
|
style={{ borderColor: preview.themeColor || '#5865f2' }}
|
||
|
|
>
|
||
|
|
<div className="discord-embed-content-wrapper">
|
||
|
|
<div className="discord-embed-content">
|
||
|
|
{preview.siteName && (
|
||
|
|
<div className="discord-embed-provider">{preview.siteName}</div>
|
||
|
|
)}
|
||
|
|
{preview.title && (
|
||
|
|
<a
|
||
|
|
href={url}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="discord-embed-title"
|
||
|
|
>
|
||
|
|
{preview.title}
|
||
|
|
</a>
|
||
|
|
)}
|
||
|
|
{preview.description && (
|
||
|
|
<div className="discord-embed-description">
|
||
|
|
{preview.description.length > 300
|
||
|
|
? preview.description.slice(0, 300) + '...'
|
||
|
|
: preview.description}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{preview.image && !hasLargeImage && !imageError && (
|
||
|
|
<img
|
||
|
|
src={preview.image}
|
||
|
|
alt=""
|
||
|
|
className="discord-embed-thumbnail"
|
||
|
|
loading="lazy"
|
||
|
|
onError={() => setImageError(true)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{preview.image && hasLargeImage && !imageError && (
|
||
|
|
<img
|
||
|
|
src={preview.image}
|
||
|
|
alt=""
|
||
|
|
className="discord-embed-image"
|
||
|
|
loading="lazy"
|
||
|
|
onError={() => setImageError(true)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// 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 (
|
||
|
|
<div className="discord-attachment-image-wrapper">
|
||
|
|
<img
|
||
|
|
src={url}
|
||
|
|
alt={filename || 'Image'}
|
||
|
|
className="discord-attachment-image"
|
||
|
|
onClick={() => openImageModal?.({ url, alt: filename || 'Image' })}
|
||
|
|
style={{
|
||
|
|
cursor: 'pointer',
|
||
|
|
...(width && height ? {
|
||
|
|
maxWidth: Math.min(width, 400),
|
||
|
|
aspectRatio: `${width} / ${height}`
|
||
|
|
} : {})
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isVideo) {
|
||
|
|
return (
|
||
|
|
<div className="discord-attachment-video-wrapper">
|
||
|
|
<video
|
||
|
|
src={url}
|
||
|
|
controls
|
||
|
|
className="discord-attachment-video"
|
||
|
|
preload="metadata"
|
||
|
|
style={width && height ? {
|
||
|
|
maxWidth: Math.min(width, 400),
|
||
|
|
aspectRatio: `${width} / ${height}`
|
||
|
|
} : undefined}
|
||
|
|
>
|
||
|
|
<source src={url} type={content_type} />
|
||
|
|
Your browser does not support the video tag.
|
||
|
|
</video>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isAudio) {
|
||
|
|
return (
|
||
|
|
<div className="discord-attachment-audio-wrapper">
|
||
|
|
<div className="discord-attachment-file">
|
||
|
|
<svg className="discord-attachment-icon" viewBox="0 0 24 24" fill="currentColor">
|
||
|
|
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||
|
|
</svg>
|
||
|
|
<div className="discord-attachment-info">
|
||
|
|
<a href={url} target="_blank" rel="noopener noreferrer" className="discord-attachment-name">
|
||
|
|
{filename || 'Audio file'}
|
||
|
|
</a>
|
||
|
|
{size && <div className="discord-attachment-size">{formatFileSize(size)}</div>}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<audio src={url} controls className="discord-attachment-audio" preload="metadata">
|
||
|
|
Your browser does not support the audio tag.
|
||
|
|
</audio>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generic file
|
||
|
|
return (
|
||
|
|
<div className="discord-attachment-file">
|
||
|
|
<svg className="discord-attachment-icon" viewBox="0 0 24 24" fill="currentColor">
|
||
|
|
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
|
||
|
|
</svg>
|
||
|
|
<div className="discord-attachment-info">
|
||
|
|
<a href={url} target="_blank" rel="noopener noreferrer" className="discord-attachment-name">
|
||
|
|
{filename || 'File'}
|
||
|
|
</a>
|
||
|
|
{size && <div className="discord-attachment-size">{formatFileSize(size)}</div>}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// 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 (
|
||
|
|
<div className="discord-message discord-system-message">
|
||
|
|
<div className="discord-system-icon">
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||
|
|
<path d="M18.5 12c0-1.77-.77-3.37-2-4.47V4.5h-2v1.98A5.96 5.96 0 0 0 12 6c-.97 0-1.89.23-2.71.63L7.11 4.45 5.64 5.92l2.18 2.18A5.96 5.96 0 0 0 6 12c0 3.31 2.69 6 6 6s6-2.69 6-6zm-6 4c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z" />
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
<span className="discord-system-content">
|
||
|
|
<span className="discord-username" style={displayAuthor?.color ? { color: displayAuthor.color } : undefined}>
|
||
|
|
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown'}
|
||
|
|
</span>
|
||
|
|
{' '}
|
||
|
|
<span dangerouslySetInnerHTML={{ __html: parsedContent }} />
|
||
|
|
</span>
|
||
|
|
<span className="discord-timestamp">{formatTimestamp(displayTimestamp)}</span>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{/* Reply context */}
|
||
|
|
{referenced_message && (
|
||
|
|
<div className="discord-reply-context">
|
||
|
|
<img
|
||
|
|
src={referenced_message.author?.avatar
|
||
|
|
? (referenced_message.author.avatar.startsWith('http')
|
||
|
|
? referenced_message.author.avatar
|
||
|
|
: `https://cdn.discordapp.com/avatars/${referenced_message.author.id}/${referenced_message.author.avatar}.png?size=32`)
|
||
|
|
: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>'
|
||
|
|
}
|
||
|
|
alt=""
|
||
|
|
className="discord-reply-avatar"
|
||
|
|
/>
|
||
|
|
<span className="discord-reply-username">
|
||
|
|
{referenced_message.author?.displayName || referenced_message.author?.username || 'Unknown'}
|
||
|
|
</span>
|
||
|
|
<span className="discord-reply-content">
|
||
|
|
{referenced_message.content?.slice(0, 100) || 'Click to see attachment'}
|
||
|
|
{referenced_message.content?.length > 100 ? '...' : ''}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div
|
||
|
|
id={`message-${id}`}
|
||
|
|
className={`discord-message ${isFirstInGroup ? 'first-in-group' : ''}`}
|
||
|
|
onContextMenu={(e) => onContextMenu?.(e, id, content)}
|
||
|
|
>
|
||
|
|
{isFirstInGroup ? (
|
||
|
|
<div className="discord-avatar-wrapper">
|
||
|
|
{avatarUrl ? (
|
||
|
|
<img src={avatarUrl} alt="" className="discord-avatar" />
|
||
|
|
) : (
|
||
|
|
<div className="discord-avatar-placeholder">
|
||
|
|
{getInitial(displayAuthor?.username)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="discord-timestamp-gutter">
|
||
|
|
<span className="discord-hover-timestamp">
|
||
|
|
{formatTimestamp(displayTimestamp, 'time')}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="discord-message-content">
|
||
|
|
{isFirstInGroup && (
|
||
|
|
<div className="discord-message-header">
|
||
|
|
{/* Tags appear BEFORE the name */}
|
||
|
|
{displayAuthor?.isServerForwarded && (
|
||
|
|
<span className="discord-server-tag">SERVER</span>
|
||
|
|
)}
|
||
|
|
{(displayAuthor?.bot || displayAuthor?.isWebhook) && !displayAuthor?.isServerForwarded && (
|
||
|
|
<span className="discord-bot-tag">APP</span>
|
||
|
|
)}
|
||
|
|
{archivedMessage && (
|
||
|
|
<span className="discord-archive-tag" title={archivedMessage.topic}>
|
||
|
|
ARCHIVED
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<span
|
||
|
|
className={`discord-username ${(displayAuthor?.bot || displayAuthor?.isWebhook) ? 'discord-bot-user' : ''}`}
|
||
|
|
style={displayAuthor?.color ? { color: displayAuthor.color } : undefined}
|
||
|
|
>
|
||
|
|
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown User'}
|
||
|
|
</span>
|
||
|
|
<span className="discord-timestamp">{formatTimestamp(displayTimestamp)}</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{displayContent && (
|
||
|
|
<div
|
||
|
|
className="discord-text"
|
||
|
|
dangerouslySetInnerHTML={{ __html: parsedContent }}
|
||
|
|
onClick={handleContentClick}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Attachments */}
|
||
|
|
{attachments?.length > 0 && (
|
||
|
|
<div className="discord-attachments">
|
||
|
|
{attachments.map((att, idx) => (
|
||
|
|
<Attachment key={att.id || idx} attachment={att} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Stickers */}
|
||
|
|
{stickers?.length > 0 && (
|
||
|
|
<div className="discord-stickers">
|
||
|
|
{stickers.map((sticker) => (
|
||
|
|
<div key={sticker.id} className="discord-sticker" title={sticker.name}>
|
||
|
|
{sticker.formatType === 3 ? (
|
||
|
|
// Lottie stickers - show placeholder or name
|
||
|
|
<div className="discord-sticker-lottie">
|
||
|
|
<span>{sticker.name}</span>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<img
|
||
|
|
src={sticker.url}
|
||
|
|
alt={sticker.name}
|
||
|
|
className="discord-sticker-image"
|
||
|
|
loading="lazy"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Original embeds from Discord */}
|
||
|
|
{embeds?.map((embed, idx) => (
|
||
|
|
<div
|
||
|
|
key={idx}
|
||
|
|
className="discord-embed"
|
||
|
|
style={{ borderColor: embed.color ? `#${embed.color.toString(16).padStart(6, '0')}` : '#5865f2' }}
|
||
|
|
>
|
||
|
|
<div className="discord-embed-content-wrapper">
|
||
|
|
<div className="discord-embed-content">
|
||
|
|
{embed.author && (
|
||
|
|
<div className="discord-embed-author">
|
||
|
|
{embed.author.icon_url && (
|
||
|
|
<img src={embed.author.icon_url} alt="" className="discord-embed-author-icon" />
|
||
|
|
)}
|
||
|
|
<span className="discord-embed-author-name">{embed.author.name}</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{embed.title && (
|
||
|
|
embed.url ? (
|
||
|
|
<a
|
||
|
|
href={embed.url}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="discord-embed-title"
|
||
|
|
>
|
||
|
|
{embed.title}
|
||
|
|
</a>
|
||
|
|
) : (
|
||
|
|
<div className="discord-embed-title">{embed.title}</div>
|
||
|
|
)
|
||
|
|
)}
|
||
|
|
{embed.description && (
|
||
|
|
<div
|
||
|
|
className="discord-embed-description"
|
||
|
|
dangerouslySetInnerHTML={{
|
||
|
|
__html: parseDiscordMarkdown(embed.description, { channelMap, usersMap })
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{/* Embed fields */}
|
||
|
|
{embed.fields?.length > 0 && (
|
||
|
|
<div className="discord-embed-fields">
|
||
|
|
{embed.fields.map((field, fieldIdx) => (
|
||
|
|
<div
|
||
|
|
key={fieldIdx}
|
||
|
|
className={`discord-embed-field ${field.inline ? 'inline' : ''}`}
|
||
|
|
>
|
||
|
|
{field.name && (
|
||
|
|
<div className="discord-embed-field-name">{field.name}</div>
|
||
|
|
)}
|
||
|
|
{field.value && (
|
||
|
|
<div
|
||
|
|
className="discord-embed-field-value"
|
||
|
|
dangerouslySetInnerHTML={{
|
||
|
|
__html: parseDiscordMarkdown(field.value, { channelMap, usersMap })
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{embed.thumbnail?.url && embed.thumbnail.url.trim() && (
|
||
|
|
<img
|
||
|
|
src={embed.thumbnail.url}
|
||
|
|
alt=""
|
||
|
|
className="discord-embed-thumbnail"
|
||
|
|
onError={(e) => e.target.style.display = 'none'}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{embed.image?.url && (
|
||
|
|
<img src={embed.image.url} alt="" className="discord-embed-image" />
|
||
|
|
)}
|
||
|
|
{embed.video?.url && (
|
||
|
|
<video src={embed.video.url} controls className="discord-embed-video-player" />
|
||
|
|
)}
|
||
|
|
{embed.footer && (
|
||
|
|
<div className="discord-embed-footer">
|
||
|
|
{embed.footer.icon_url && (
|
||
|
|
<img src={embed.footer.icon_url} alt="" className="discord-embed-footer-icon" />
|
||
|
|
)}
|
||
|
|
<span>{embed.footer.text}</span>
|
||
|
|
{embed.timestamp && (
|
||
|
|
<>
|
||
|
|
<span className="discord-embed-footer-separator">•</span>
|
||
|
|
<span>{formatTimestamp(embed.timestamp, 'short')}</span>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* Link previews for URLs in content */}
|
||
|
|
{urlsToPreview.map((url) => (
|
||
|
|
<LinkPreview
|
||
|
|
key={url}
|
||
|
|
url={url}
|
||
|
|
cachedPreview={previewCache?.[url]}
|
||
|
|
onPreviewLoad={onPreviewLoad}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* Reactions */}
|
||
|
|
{reactions?.length > 0 && (
|
||
|
|
<div className="discord-reactions">
|
||
|
|
{reactions.map((reaction, idx) => (
|
||
|
|
<button
|
||
|
|
key={idx}
|
||
|
|
className={`discord-reaction ${reaction.me ? 'reacted' : ''}`}
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
onReactionClick?.(e, id, reaction);
|
||
|
|
}}
|
||
|
|
title="Click to see who reacted"
|
||
|
|
>
|
||
|
|
{reaction.emoji.id ? (
|
||
|
|
<img
|
||
|
|
src={`https://cdn.discordapp.com/emojis/${reaction.emoji.id}.${reaction.emoji.animated ? 'gif' : 'png'}`}
|
||
|
|
alt={reaction.emoji.name}
|
||
|
|
className="discord-reaction-emoji"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<span>{reaction.emoji.name}</span>
|
||
|
|
)}
|
||
|
|
<span>{reaction.count}</span>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// 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 (
|
||
|
|
<div className="discord-logs-container">
|
||
|
|
<div className="discord-loading">
|
||
|
|
<ProgressSpinner style={{ width: '40px', height: '40px' }} strokeWidth="4" />
|
||
|
|
<span className="discord-loading-text">Loading Discord archive...</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error) {
|
||
|
|
return (
|
||
|
|
<div className="discord-logs-container">
|
||
|
|
<div className="discord-empty">
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||
|
|
</svg>
|
||
|
|
<h3 className="discord-empty-title">Error</h3>
|
||
|
|
<p className="discord-empty-description">{error}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ImageModalContext.Provider value={openImageModal}>
|
||
|
|
<div className="discord-logs-container">
|
||
|
|
{/* Header */}
|
||
|
|
{selectedChannel && (
|
||
|
|
<div className="discord-header">
|
||
|
|
{selectedChannel.guild?.icon && (
|
||
|
|
<img
|
||
|
|
src={selectedChannel.guild.icon}
|
||
|
|
alt=""
|
||
|
|
className="discord-server-icon"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
<div className="discord-header-info">
|
||
|
|
<h2 className="discord-server-name">
|
||
|
|
{selectedChannel.guild?.name || 'Discord Archive'}
|
||
|
|
</h2>
|
||
|
|
<div className="discord-channel-name">
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||
|
|
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z" />
|
||
|
|
</svg>
|
||
|
|
{selectedChannel.name}
|
||
|
|
{selectedChannel.topic && (
|
||
|
|
<>
|
||
|
|
<span className="discord-topic-divider">|</span>
|
||
|
|
<span
|
||
|
|
className={`discord-channel-topic ${topicExpanded ? 'expanded' : ''}`}
|
||
|
|
onClick={() => setTopicExpanded(!topicExpanded)}
|
||
|
|
title={topicExpanded ? 'Click to collapse' : 'Click to expand'}
|
||
|
|
dangerouslySetInnerHTML={{
|
||
|
|
__html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap })
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="discord-header-actions">
|
||
|
|
<span className="discord-message-count">
|
||
|
|
{selectedChannel.messageCount?.toLocaleString() || messages.length} messages
|
||
|
|
</span>
|
||
|
|
<button
|
||
|
|
className={`discord-copy-link-btn ${linkCopied ? 'copied' : ''}`}
|
||
|
|
onClick={() => copyShareLink()}
|
||
|
|
title={linkCopied ? 'Copied!' : 'Copy link to channel'}
|
||
|
|
>
|
||
|
|
{linkCopied ? (
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||
|
|
</svg>
|
||
|
|
) : (
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||
|
|
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z" />
|
||
|
|
</svg>
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
className={`discord-member-toggle-btn ${memberListExpanded ? 'active' : ''}`}
|
||
|
|
onClick={() => setMemberListExpanded(!memberListExpanded)}
|
||
|
|
title={memberListExpanded ? 'Hide member list' : 'Show member list'}
|
||
|
|
>
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||
|
|
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
|
||
|
|
</svg>
|
||
|
|
{members?.totalMembers && (
|
||
|
|
<span className="discord-member-count">{members.totalMembers}</span>
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Main layout: sidebar + content */}
|
||
|
|
<div className="discord-main-layout">
|
||
|
|
{/* Sidebar with Guild and Channel selector */}
|
||
|
|
<div className="discord-sidebar">
|
||
|
|
{/* Guild tabs */}
|
||
|
|
{guilds.length > 1 && (
|
||
|
|
<div className="discord-guild-list">
|
||
|
|
{guilds.map((guild) => (
|
||
|
|
<button
|
||
|
|
key={guild.id}
|
||
|
|
className={`discord-guild-btn ${selectedGuild?.id === guild.id ? 'active' : ''}`}
|
||
|
|
onClick={() => {
|
||
|
|
setSelectedGuild(guild);
|
||
|
|
const guildChannels = channelsByGuild[guild.id];
|
||
|
|
// Select first channel with messages
|
||
|
|
const firstAccessible = guildChannels?.find(c => c.messageCount > 0);
|
||
|
|
if (firstAccessible) {
|
||
|
|
setSelectedChannel(firstAccessible);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
title={guild.name}
|
||
|
|
>
|
||
|
|
{guild.icon ? (
|
||
|
|
<img
|
||
|
|
src={guild.icon}
|
||
|
|
alt={guild.name}
|
||
|
|
className="discord-guild-icon"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<span className="discord-guild-initial">
|
||
|
|
{guild.name.charAt(0).toUpperCase()}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Channel list for selected guild */}
|
||
|
|
{selectedGuild && channelsByGuild[selectedGuild.id]?.length > 0 && (
|
||
|
|
<div className="discord-channel-list" data-lenis-prevent>
|
||
|
|
<div className="discord-channel-list-header">
|
||
|
|
{selectedGuild.name}
|
||
|
|
</div>
|
||
|
|
{(() => {
|
||
|
|
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 (
|
||
|
|
<React.Fragment key={channel.id}>
|
||
|
|
{showCategoryHeader && channel.categoryName && (
|
||
|
|
<div className="discord-category-header">
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="12" height="12">
|
||
|
|
<path d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z" />
|
||
|
|
</svg>
|
||
|
|
{channel.categoryName}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<button
|
||
|
|
className={`discord-channel-btn ${channel.categoryName ? 'has-category' : ''} ${selectedChannel?.id === channel.id ? 'active' : ''}`}
|
||
|
|
onClick={() => setSelectedChannel(channel)}
|
||
|
|
onContextMenu={(e) => handleChannelContextMenu(e, channel)}
|
||
|
|
title={channel.name}
|
||
|
|
>
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||
|
|
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z" />
|
||
|
|
</svg>
|
||
|
|
<span>{channel.name}</span>
|
||
|
|
</button>
|
||
|
|
</React.Fragment>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
})()}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Content area: search + messages */}
|
||
|
|
<div className="discord-content-area">
|
||
|
|
{/* Search bar */}
|
||
|
|
<div className="discord-search-bar">
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
className="discord-search-input"
|
||
|
|
placeholder="Search messages..."
|
||
|
|
value={searchQuery}
|
||
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||
|
|
/>
|
||
|
|
{searchLoading && (
|
||
|
|
<div className="discord-search-loading">
|
||
|
|
<ProgressSpinner style={{ width: '16px', height: '16px' }} strokeWidth="4" />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Search info banner */}
|
||
|
|
{searchQuery.trim().length >= 2 && searchResults !== null && (
|
||
|
|
<div className="discord-search-info">
|
||
|
|
Found {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} across all messages
|
||
|
|
<button
|
||
|
|
className="discord-search-clear"
|
||
|
|
onClick={() => setSearchQuery('')}
|
||
|
|
>
|
||
|
|
Clear search
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Messages */}
|
||
|
|
{loadingMessages ? (
|
||
|
|
<div className="discord-loading">
|
||
|
|
<ProgressSpinner style={{ width: '30px', height: '30px' }} strokeWidth="4" />
|
||
|
|
<span className="discord-loading-text">Loading messages...</span>
|
||
|
|
</div>
|
||
|
|
) : filteredMessages.length === 0 ? (
|
||
|
|
<div className="discord-empty">
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||
|
|
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" />
|
||
|
|
</svg>
|
||
|
|
<h3 className="discord-empty-title">No messages found</h3>
|
||
|
|
<p className="discord-empty-description">
|
||
|
|
{searchQuery ? 'No messages match your search. Try different keywords.' : 'This channel has no archived messages'}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="discord-messages" data-lenis-prevent ref={messagesContainerRef}>
|
||
|
|
{/* Loading indicator at top when fetching older messages */}
|
||
|
|
{loadingMore && (
|
||
|
|
<div className="discord-loading-more">
|
||
|
|
<ProgressSpinner style={{ width: '20px', height: '20px' }} strokeWidth="4" />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{groupedMessages.map((group, groupIdx) => {
|
||
|
|
if (group.type === 'divider') {
|
||
|
|
return (
|
||
|
|
<div key={`divider-${groupIdx}`} className="discord-date-divider">
|
||
|
|
<span>{formatDateDivider(group.date)}</span>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div key={`group-${groupIdx}`} className="discord-message-group">
|
||
|
|
{group.messages.map((message, msgIdx) => (
|
||
|
|
<DiscordMessage
|
||
|
|
key={message.id}
|
||
|
|
message={message}
|
||
|
|
isFirstInGroup={msgIdx === 0}
|
||
|
|
previewCache={previewCache}
|
||
|
|
onPreviewLoad={handlePreviewLoad}
|
||
|
|
channelMap={channelMap}
|
||
|
|
usersMap={usersMap}
|
||
|
|
members={members}
|
||
|
|
onChannelSelect={handleChannelSelect}
|
||
|
|
channelName={selectedChannel?.name}
|
||
|
|
onReactionClick={handleReactionClick}
|
||
|
|
onContextMenu={handleMessageContextMenu}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Member list panel */}
|
||
|
|
{memberListExpanded && (
|
||
|
|
<div className="discord-member-list" data-lenis-prevent>
|
||
|
|
{loadingMembers ? (
|
||
|
|
<div className="discord-member-list-loading">
|
||
|
|
<ProgressSpinner style={{ width: '24px', height: '24px' }} strokeWidth="4" />
|
||
|
|
</div>
|
||
|
|
) : members?.groups?.length > 0 ? (
|
||
|
|
<>
|
||
|
|
{members.groups.map((group) => (
|
||
|
|
<div key={group.role.id} className="discord-member-group">
|
||
|
|
<div
|
||
|
|
className="discord-member-group-header"
|
||
|
|
style={group.role.color ? { color: group.role.color } : undefined}
|
||
|
|
>
|
||
|
|
{group.role.name} — {group.members.length}
|
||
|
|
</div>
|
||
|
|
{group.members.map((member) => (
|
||
|
|
<div key={member.id} className="discord-member-item">
|
||
|
|
{member.avatar ? (
|
||
|
|
<img
|
||
|
|
src={member.avatar}
|
||
|
|
alt=""
|
||
|
|
className="discord-member-avatar"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<div className="discord-member-avatar-placeholder">
|
||
|
|
{(member.displayName || member.username || 'U')[0].toUpperCase()}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<span
|
||
|
|
className="discord-member-name"
|
||
|
|
style={member.color ? { color: member.color } : undefined}
|
||
|
|
>
|
||
|
|
{member.displayName || member.username}
|
||
|
|
</span>
|
||
|
|
{member.isBot && <span className="discord-bot-tag">APP</span>}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<div className="discord-member-list-empty">No members found</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Image Modal */}
|
||
|
|
{imageModal && (
|
||
|
|
<div className="discord-image-modal-overlay" onClick={closeImageModal}>
|
||
|
|
<div className="discord-image-modal" onClick={(e) => e.stopPropagation()}>
|
||
|
|
<button className="discord-image-modal-close" onClick={closeImageModal}>
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||
|
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
<img
|
||
|
|
src={imageModal.url}
|
||
|
|
alt={imageModal.alt}
|
||
|
|
className="discord-image-modal-img"
|
||
|
|
/>
|
||
|
|
<a
|
||
|
|
href={imageModal.url}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="discord-image-modal-link"
|
||
|
|
>
|
||
|
|
Open original
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Reaction Users Popup */}
|
||
|
|
{reactionPopup && (
|
||
|
|
<div
|
||
|
|
className="discord-reaction-popup"
|
||
|
|
style={{
|
||
|
|
position: 'fixed',
|
||
|
|
left: reactionPopup.x,
|
||
|
|
top: reactionPopup.y,
|
||
|
|
transform: 'translate(-50%, -100%) translateY(-8px)',
|
||
|
|
}}
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
>
|
||
|
|
<div className="discord-reaction-popup-header">
|
||
|
|
{reactionPopup.emoji.id ? (
|
||
|
|
<img
|
||
|
|
src={`https://cdn.discordapp.com/emojis/${reactionPopup.emoji.id}.${reactionPopup.emoji.animated ? 'gif' : 'png'}`}
|
||
|
|
alt={reactionPopup.emoji.name}
|
||
|
|
className="discord-reaction-popup-emoji"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<span className="discord-reaction-popup-emoji">{reactionPopup.emoji.name}</span>
|
||
|
|
)}
|
||
|
|
<span className="discord-reaction-popup-count">{reactionPopup.users?.length || 0}</span>
|
||
|
|
</div>
|
||
|
|
<div className="discord-reaction-popup-users">
|
||
|
|
{reactionPopup.loading ? (
|
||
|
|
<div className="discord-reaction-popup-loading">
|
||
|
|
<ProgressSpinner style={{ width: '20px', height: '20px' }} strokeWidth="4" />
|
||
|
|
</div>
|
||
|
|
) : reactionPopup.users?.length > 0 ? (
|
||
|
|
reactionPopup.users.map((user) => (
|
||
|
|
<div key={user.id} className="discord-reaction-popup-user">
|
||
|
|
<img
|
||
|
|
src={user.avatar || `https://cdn.discordapp.com/embed/avatars/0.png`}
|
||
|
|
alt=""
|
||
|
|
className="discord-reaction-popup-avatar"
|
||
|
|
/>
|
||
|
|
<span className="discord-reaction-popup-name">{user.displayName}</span>
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
) : (
|
||
|
|
<div className="discord-reaction-popup-empty">No users found</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Message Context Menu */}
|
||
|
|
{contextMenu && (
|
||
|
|
<div
|
||
|
|
className="discord-context-menu"
|
||
|
|
style={{
|
||
|
|
position: 'fixed',
|
||
|
|
left: contextMenu.x,
|
||
|
|
top: contextMenu.y,
|
||
|
|
}}
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
>
|
||
|
|
<button className="discord-context-menu-item" onClick={copyMessageLink}>
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||
|
|
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z" />
|
||
|
|
</svg>
|
||
|
|
Copy Message Link
|
||
|
|
</button>
|
||
|
|
<button className="discord-context-menu-item" onClick={copyMessageContent}>
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||
|
|
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
|
||
|
|
</svg>
|
||
|
|
Copy Text
|
||
|
|
</button>
|
||
|
|
<button className="discord-context-menu-item" onClick={copyMessageId}>
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||
|
|
<path d="M20 9H4v2h16V9zM4 15h16v-2H4v2z" />
|
||
|
|
</svg>
|
||
|
|
Copy Message ID
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Channel Context Menu */}
|
||
|
|
{channelContextMenu && (
|
||
|
|
<div
|
||
|
|
className="discord-context-menu"
|
||
|
|
style={{
|
||
|
|
position: 'fixed',
|
||
|
|
left: channelContextMenu.x,
|
||
|
|
top: channelContextMenu.y,
|
||
|
|
}}
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
>
|
||
|
|
<button className="discord-context-menu-item" onClick={copyChannelLink}>
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||
|
|
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z" />
|
||
|
|
</svg>
|
||
|
|
Copy Channel Link
|
||
|
|
</button>
|
||
|
|
<button className="discord-context-menu-item" onClick={copyChannelName}>
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||
|
|
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z" />
|
||
|
|
</svg>
|
||
|
|
Copy Channel Name
|
||
|
|
</button>
|
||
|
|
<button className="discord-context-menu-item" onClick={copyChannelId}>
|
||
|
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||
|
|
<path d="M20 9H4v2h16V9zM4 15h16v-2H4v2z" />
|
||
|
|
</svg>
|
||
|
|
Copy Channel ID
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</ImageModalContext.Provider>
|
||
|
|
);
|
||
|
|
}
|