misc
This commit is contained in:
@@ -3,6 +3,13 @@ import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { authFetch } from '@/utils/authFetch';
|
||||
|
||||
// ============================================================================
|
||||
// Discord Message Type Constants
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||
const MESSAGE_TYPE_DEFAULT = 0;
|
||||
const MESSAGE_TYPE_REPLY = 19;
|
||||
const MESSAGE_TYPE_CHAT_INPUT_COMMAND = 20;
|
||||
const MESSAGE_TYPE_CONTEXT_MENU_COMMAND = 23;
|
||||
const MESSAGE_TYPE_POLL_RESULT = 46;
|
||||
|
||||
// Image modal context for child components to trigger modal
|
||||
const ImageModalContext = createContext(null);
|
||||
@@ -413,26 +420,28 @@ function resolveArchivedUser(archivedUsername, usersMap, members) {
|
||||
* @param {Map} options.channelMap - Map of channel IDs to channel objects
|
||||
* @param {Object} options.usersMap - Map of user IDs to user objects { displayName, username, color }
|
||||
* @param {Object} options.rolesMap - Map of role IDs to role objects { name, color }
|
||||
* @param {Object} options.emojiCache - Map of emoji IDs to cached emoji objects { url, animated }
|
||||
* @param {Function} options.onChannelClick - Callback when channel is clicked
|
||||
*/
|
||||
function parseDiscordMarkdown(text, options = {}) {
|
||||
if (!text) return '';
|
||||
|
||||
// Normalize HTML entities that sometimes make it into messages/embed fields
|
||||
// We decode before we escape so strings like "A & B" become "A & B"
|
||||
// and avoid double-encoding when we later run an escape pass.
|
||||
try {
|
||||
// Normalize HTML entities that sometimes make it into messages/embed fields
|
||||
// We decode before we escape so strings like "A & B" become "A & B"
|
||||
// and avoid double-encoding when we later run an escape pass.
|
||||
|
||||
const { channelMap = new Map(), usersMap = {}, rolesMap = new Map(), onChannelClick } = options;
|
||||
const { channelMap = new Map(), usersMap = {}, rolesMap = new Map(), emojiCache = {}, onChannelClick } = options;
|
||||
|
||||
// Normalize entities then escape HTML to avoid XSS while ensuring
|
||||
// already-encoded entities don't become double-encoded in the UI.
|
||||
const normalized = decodeHtmlEntities(text);
|
||||
// Normalize entities then escape HTML to avoid XSS while ensuring
|
||||
// already-encoded entities don't become double-encoded in the UI.
|
||||
const normalized = decodeHtmlEntities(text);
|
||||
|
||||
// Escape HTML first
|
||||
let parsed = normalized
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
// Escape HTML first
|
||||
let parsed = normalized
|
||||
.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
|
||||
@@ -593,22 +602,39 @@ function parseDiscordMarkdown(text, options = {}) {
|
||||
return `<span class="discord-mention">#${channelName}</span>`;
|
||||
});
|
||||
|
||||
// Role mentions (<@&123456789>)
|
||||
// Role mentions (<@&123456789>) - robust lookup to avoid errors when rolesMap is missing or malformed
|
||||
parsed = parsed.replace(/<@&(\d+)>/g, (_, roleId) => {
|
||||
const role = rolesMap?.get?.(roleId) || rolesMap?.[roleId];
|
||||
const roleName = role?.name || 'role';
|
||||
const roleColor = role?.color || null;
|
||||
const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : '';
|
||||
return `<span class="discord-mention discord-role-mention"${style}>@${roleName}</span>`;
|
||||
try {
|
||||
let role = null;
|
||||
if (rolesMap) {
|
||||
if (typeof rolesMap.get === 'function') {
|
||||
role = rolesMap.get(roleId);
|
||||
} else if (rolesMap[roleId]) {
|
||||
role = rolesMap[roleId];
|
||||
} else if (rolesMap[String(roleId)]) {
|
||||
role = rolesMap[String(roleId)];
|
||||
}
|
||||
}
|
||||
const roleName = role?.name || 'role';
|
||||
const roleColor = role?.color || null;
|
||||
const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : '';
|
||||
return `<span class="discord-mention discord-role-mention"${style}>@${roleName}</span>`;
|
||||
} catch (err) {
|
||||
// Defensive: log for telemetry/debug and return safe fallback
|
||||
try { console.error('parseDiscordMarkdown: role mention parse failed', { roleId, err, rolesMapType: rolesMap && typeof rolesMap }); } catch (e) { /* ignore logging errors */ }
|
||||
return `<span class="discord-mention discord-role-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>)
|
||||
// Use cached emoji URL if available, otherwise fall back to Discord CDN
|
||||
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}:">`;
|
||||
const cached = emojiCache[id];
|
||||
const url = cached?.url || `https://cdn.discordapp.com/emojis/${id}.${animated ? 'gif' : 'png'}`;
|
||||
return `<img class="discord-emoji" src="${url}" alt=":${name}:" title=":${name}:">`;
|
||||
});
|
||||
|
||||
// Unicode emoji (keep as-is, they render natively)
|
||||
@@ -632,11 +658,20 @@ function parseDiscordMarkdown(text, options = {}) {
|
||||
// Newlines
|
||||
parsed = parsed.replace(/\n/g, '<br>');
|
||||
|
||||
// Unescape Discord markdown escape sequences (\_ \* \~ \` \| \\)
|
||||
// Unescape Discord markdown escape sequences (\\_ \\* \\~ \\` \\| \\\\)
|
||||
// Must be done after all markdown processing
|
||||
parsed = parsed.replace(/\\([_*~`|\\])/g, '$1');
|
||||
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
try { console.error('parseDiscordMarkdown failed', err); } catch (e) { /* ignore logging errors */ }
|
||||
// Fallback: return a safely-escaped version of the input to avoid crashing the UI
|
||||
const safe = String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return safe;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -743,27 +778,42 @@ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoa
|
||||
return null; // Don't show anything for failed previews
|
||||
}
|
||||
|
||||
// YouTube embed - trusted, use iframe
|
||||
// YouTube embed - use click-to-play thumbnail
|
||||
if (preview.type === 'youtube' && preview.videoId) {
|
||||
const thumbnailUrl = `https://img.youtube.com/vi/${preview.videoId}/maxresdefault.jpg`;
|
||||
const watchUrl = `https://www.youtube.com/watch?v=${preview.videoId}`;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<a href={watchUrl} target="_blank" rel="noopener noreferrer" className="discord-embed-title">
|
||||
{decodeHtmlEntities(preview.title)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="discord-embed-video-container">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${preview.videoId}`}
|
||||
title={decodeHtmlEntities(preview.title) || 'YouTube video'}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="discord-embed-iframe"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
href={watchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="discord-embed-video-thumbnail-link"
|
||||
title="Watch on YouTube"
|
||||
>
|
||||
<div className="discord-embed-video-container discord-embed-video-thumbnail">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={decodeHtmlEntities(preview.title) || 'YouTube video'}
|
||||
className="discord-embed-video-thumbnail-img"
|
||||
/>
|
||||
<div className="discord-embed-video-play-overlay">
|
||||
<svg viewBox="0 0 68 48" className="discord-embed-video-play-icon">
|
||||
<path d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z" fill="#f00"></path>
|
||||
<path d="M 45,24 27,14 27,34" fill="#fff"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -866,7 +916,7 @@ const Attachment = memo(function Attachment({ 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 isVideo = content_type?.startsWith('video/') || VIDEO_EXTENSIONS.test(filename || url) || (url && url.includes('/api/discord/cached-video'));
|
||||
const isAudio = content_type?.startsWith('audio/') || AUDIO_EXTENSIONS.test(filename || url);
|
||||
|
||||
if (isImage) {
|
||||
@@ -958,6 +1008,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
onPreviewLoad,
|
||||
channelMap,
|
||||
usersMap,
|
||||
emojiCache,
|
||||
members,
|
||||
onChannelSelect,
|
||||
channelName,
|
||||
@@ -1019,6 +1070,9 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
}
|
||||
});
|
||||
|
||||
// Exclude URLs that match any attachment's originalUrl
|
||||
const attachmentOriginalUrls = new Set(attachments?.map(a => a.originalUrl).filter(Boolean));
|
||||
|
||||
return urls.filter(url => {
|
||||
// Skip if exact URL match
|
||||
if (embedUrls.has(url)) return false;
|
||||
@@ -1027,18 +1081,23 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
const ytId = getYouTubeId(url);
|
||||
if (ytId && embedYouTubeIds.has(ytId)) return false;
|
||||
|
||||
// Skip if URL matches any attachment originalUrl
|
||||
if (attachmentOriginalUrls.has(url)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [urls, embeds]);
|
||||
}, [urls, embeds, attachments]);
|
||||
|
||||
// Build rolesMap from members data for role mention parsing
|
||||
// Build rolesMap from members data for role mention parsing (defensive)
|
||||
const rolesMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
members?.roles?.forEach(role => map.set(role.id, role));
|
||||
if (Array.isArray(members?.roles)) {
|
||||
members.roles.forEach(role => map.set(role?.id, role));
|
||||
}
|
||||
return map;
|
||||
}, [members?.roles]);
|
||||
|
||||
const parsedContent = useMemo(() => parseDiscordMarkdown(displayContent, { channelMap, usersMap, rolesMap }), [displayContent, channelMap, usersMap, rolesMap]);
|
||||
const parsedContent = useMemo(() => parseDiscordMarkdown(displayContent, { channelMap, usersMap, rolesMap, emojiCache }), [displayContent, channelMap, usersMap, rolesMap, emojiCache]);
|
||||
|
||||
// Handle channel link clicks
|
||||
const handleContentClick = useCallback((e) => {
|
||||
@@ -1069,9 +1128,15 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
// System messages (join, boost, etc.)
|
||||
// Type 0 = default, 19 = reply, 20 = chat input command, 23 = context menu command
|
||||
// Types 20 and 23 are app/bot command messages and should render normally
|
||||
if (type && type !== 0 && type !== 19 && type !== 20 && type !== 23) {
|
||||
if (
|
||||
type &&
|
||||
type !== MESSAGE_TYPE_DEFAULT &&
|
||||
type !== MESSAGE_TYPE_REPLY &&
|
||||
type !== MESSAGE_TYPE_CHAT_INPUT_COMMAND &&
|
||||
type !== MESSAGE_TYPE_CONTEXT_MENU_COMMAND
|
||||
) {
|
||||
// Special handling for poll result system messages (type 46)
|
||||
if (type === 46) {
|
||||
if (type === MESSAGE_TYPE_POLL_RESULT) {
|
||||
// Find the poll_result embed
|
||||
const pollResultEmbed = embeds?.find(e => e.type === 'poll_result');
|
||||
let pollFields = {};
|
||||
@@ -1163,7 +1228,15 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
<>
|
||||
{/* Reply context */}
|
||||
{referenced_message && (
|
||||
<div className="discord-reply-context">
|
||||
<div
|
||||
className="discord-reply-context"
|
||||
onClick={() => {
|
||||
if (referenced_message.id && onJumpToMessage) {
|
||||
onJumpToMessage(referenced_message.id);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: referenced_message.id ? 'pointer' : 'default' }}
|
||||
>
|
||||
<img
|
||||
src={referenced_message.author?.avatar
|
||||
? (referenced_message.author.avatar.startsWith('http')
|
||||
@@ -1182,11 +1255,12 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: referenced_message.content
|
||||
? parseDiscordMarkdown(
|
||||
referenced_message.content.length > 100
|
||||
(referenced_message.content.length > 100
|
||||
? referenced_message.content.slice(0, 100) + '...'
|
||||
: referenced_message.content,
|
||||
{ channelMap, usersMap, rolesMap }
|
||||
)
|
||||
: referenced_message.content
|
||||
).replace(/\n/g, ' '),
|
||||
{ channelMap, usersMap, rolesMap, emojiCache }
|
||||
).replace(/<br\s*\/?>/gi, ' ')
|
||||
: 'Click to see attachment'
|
||||
}}
|
||||
/>
|
||||
@@ -1303,7 +1377,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
{poll.question.emoji && (
|
||||
poll.question.emoji.id ? (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/emojis/${poll.question.emoji.id}.${poll.question.emoji.animated ? 'gif' : 'png'}`}
|
||||
src={emojiCache[poll.question.emoji.id]?.url || `https://cdn.discordapp.com/emojis/${poll.question.emoji.id}.${poll.question.emoji.animated ? 'gif' : 'png'}`}
|
||||
alt={poll.question.emoji.name}
|
||||
className="discord-poll-emoji"
|
||||
/>
|
||||
@@ -1331,7 +1405,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
{answer.emoji && (
|
||||
answer.emoji.id ? (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/emojis/${answer.emoji.id}.${answer.emoji.animated ? 'gif' : 'png'}`}
|
||||
src={emojiCache[answer.emoji.id]?.url || `https://cdn.discordapp.com/emojis/${answer.emoji.id}.${answer.emoji.animated ? 'gif' : 'png'}`}
|
||||
alt={answer.emoji.name}
|
||||
className="discord-poll-answer-emoji"
|
||||
/>
|
||||
@@ -1422,7 +1496,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
<div
|
||||
className="discord-embed-description"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: parseDiscordMarkdown(embed.description, { channelMap, usersMap, rolesMap })
|
||||
__html: parseDiscordMarkdown(embed.description, { channelMap, usersMap, rolesMap, emojiCache })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1441,7 +1515,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
<div
|
||||
className="discord-embed-field-value"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: parseDiscordMarkdown(field.value, { channelMap, usersMap, rolesMap })
|
||||
__html: parseDiscordMarkdown(field.value, { channelMap, usersMap, rolesMap, emojiCache })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1463,15 +1537,40 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
<img src={embed.image.url} alt="" className="discord-embed-image" />
|
||||
)}
|
||||
{embed.video?.url && (
|
||||
// Check if it's a YouTube embed URL - use iframe instead of video
|
||||
// Check if it's a YouTube URL - use click-to-play thumbnail
|
||||
embed.video.url.includes('youtube.com/embed/') || embed.video.url.includes('youtu.be') ? (
|
||||
<iframe
|
||||
src={embed.video.url}
|
||||
title={embed.title || 'YouTube video'}
|
||||
className="discord-embed-video-iframe"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
(() => {
|
||||
// Extract video ID from embed URL
|
||||
const videoId = embed.video.url.includes('youtube.com/embed/')
|
||||
? embed.video.url.split('/embed/')[1]?.split('?')[0]
|
||||
: embed.video.url.split('/').pop()?.split('?')[0];
|
||||
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
|
||||
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={watchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="discord-embed-video-thumbnail-link"
|
||||
title="Watch on YouTube"
|
||||
>
|
||||
<div className="discord-embed-video-thumbnail">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={embed.title || 'YouTube video'}
|
||||
className="discord-embed-video-thumbnail-img"
|
||||
/>
|
||||
<div className="discord-embed-video-play-overlay">
|
||||
<svg viewBox="0 0 68 48" className="discord-embed-video-play-icon">
|
||||
<path d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z" fill="#f00"></path>
|
||||
<path d="M 45,24 27,14 27,34" fill="#fff"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<video src={embed.video.url} controls className="discord-embed-video-player" />
|
||||
)
|
||||
@@ -1518,7 +1617,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
>
|
||||
{reaction.emoji.id ? (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/emojis/${reaction.emoji.id}.${reaction.emoji.animated ? 'gif' : 'png'}`}
|
||||
src={reaction.emoji.url || `https://cdn.discordapp.com/emojis/${reaction.emoji.id}.${reaction.emoji.animated ? 'gif' : 'png'}`}
|
||||
alt={reaction.emoji.name}
|
||||
className="discord-reaction-emoji"
|
||||
/>
|
||||
@@ -1551,6 +1650,7 @@ export default function DiscordLogs() {
|
||||
const [selectedChannel, setSelectedChannel] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [usersMap, setUsersMap] = useState({}); // Map of user ID -> { displayName, username, color }
|
||||
const [emojiCache, setEmojiCache] = useState({}); // Map of emoji ID -> { url, animated }
|
||||
const [members, setMembers] = useState(null); // { groups: [...], roles: [...] }
|
||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||
const [memberListExpanded, setMemberListExpanded] = useState(false); // Default to collapsed
|
||||
@@ -1559,6 +1659,8 @@ export default function DiscordLogs() {
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(true);
|
||||
const [hasNewerMessages, setHasNewerMessages] = useState(false); // When viewing historical messages
|
||||
const [loadingNewer, setLoadingNewer] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState(null); // Server-side search results
|
||||
@@ -1722,6 +1824,46 @@ export default function DiscordLogs() {
|
||||
setContextMenu(null);
|
||||
}, [contextMenu]);
|
||||
|
||||
// Helper to scroll to a message element with correction for layout shifts
|
||||
const scrollToMessageElement = useCallback((element, highlightDuration = 2000) => {
|
||||
if (!element) return;
|
||||
|
||||
// First scroll immediately to get close
|
||||
element.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
|
||||
// Add highlight effect
|
||||
element.classList.add('discord-message-highlight');
|
||||
setTimeout(() => element.classList.remove('discord-message-highlight'), highlightDuration);
|
||||
|
||||
// After a delay for images to start loading, scroll again to correct for layout shifts
|
||||
setTimeout(() => {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
// Helper to wait for an element to exist in DOM then scroll to it
|
||||
const waitForElementAndScroll = useCallback((messageId, highlightDuration = 2000, maxAttempts = 20) => {
|
||||
let attempts = 0;
|
||||
|
||||
const tryScroll = () => {
|
||||
const element = document.getElementById(`message-${messageId}`);
|
||||
if (element) {
|
||||
scrollToMessageElement(element, highlightDuration);
|
||||
return true;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
requestAnimationFrame(tryScroll);
|
||||
return false;
|
||||
}
|
||||
console.warn(`Could not find message-${messageId} after ${maxAttempts} attempts`);
|
||||
return false;
|
||||
};
|
||||
|
||||
requestAnimationFrame(tryScroll);
|
||||
}, [scrollToMessageElement]);
|
||||
|
||||
// Jump to a specific message (used from search results and poll result view)
|
||||
const jumpToMessage = useCallback((messageId) => {
|
||||
if (!messageId) return;
|
||||
@@ -1732,10 +1874,7 @@ export default function DiscordLogs() {
|
||||
// Message is already loaded, just scroll to it
|
||||
const element = document.getElementById(`message-${messageId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Add a brief highlight effect
|
||||
element.classList.add('discord-message-highlight');
|
||||
setTimeout(() => element.classList.remove('discord-message-highlight'), 2000);
|
||||
scrollToMessageElement(element);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1749,7 +1888,7 @@ export default function DiscordLogs() {
|
||||
// Trigger a re-fetch by incrementing the counter
|
||||
setRefetchCounter(c => c + 1);
|
||||
setLoadingMessages(true);
|
||||
}, [messages]);
|
||||
}, [messages, scrollToMessageElement]);
|
||||
|
||||
// Handle channel context menu (right-click)
|
||||
const handleChannelContextMenu = useCallback((e, channel) => {
|
||||
@@ -1856,10 +1995,12 @@ export default function DiscordLogs() {
|
||||
return map;
|
||||
}, [channels]);
|
||||
|
||||
// Create roles lookup map for role mentions
|
||||
// Create roles lookup map for role mentions (defensive)
|
||||
const rolesMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
members?.roles?.forEach(role => map.set(role.id, role));
|
||||
if (Array.isArray(members?.roles)) {
|
||||
members.roles.forEach(role => map.set(role?.id, role));
|
||||
}
|
||||
return map;
|
||||
}, [members?.roles]);
|
||||
|
||||
@@ -2073,7 +2214,9 @@ export default function DiscordLogs() {
|
||||
async function fetchMessages() {
|
||||
setMessages([]);
|
||||
setUsersMap({});
|
||||
setEmojiCache({});
|
||||
setHasMoreMessages(true);
|
||||
setHasNewerMessages(false); // Loading latest messages
|
||||
|
||||
// Capture target message ID from ref (for deep-linking)
|
||||
const targetMessageId = pendingTargetMessageRef.current;
|
||||
@@ -2089,9 +2232,10 @@ export default function DiscordLogs() {
|
||||
if (!response.ok) throw new Error('Failed to fetch messages');
|
||||
const data = await response.json();
|
||||
|
||||
// Handle new response format { messages, users }
|
||||
// Handle new response format { messages, users, emojiCache }
|
||||
const messagesData = data.messages || data;
|
||||
const usersData = data.users || {};
|
||||
const emojiCacheData = data.emojiCache || {};
|
||||
|
||||
// If we were looking for a specific message but got no results,
|
||||
// fall back to loading the latest messages
|
||||
@@ -2103,6 +2247,7 @@ export default function DiscordLogs() {
|
||||
const fallbackData = await fallbackResponse.json();
|
||||
const fallbackMessages = fallbackData.messages || fallbackData;
|
||||
const fallbackUsers = fallbackData.users || {};
|
||||
const fallbackEmojis = fallbackData.emojiCache || {};
|
||||
const normalizedFallback = fallbackMessages.map(msg => ({
|
||||
...msg,
|
||||
referenced_message: msg.referencedMessage || msg.referenced_message,
|
||||
@@ -2113,6 +2258,7 @@ export default function DiscordLogs() {
|
||||
}));
|
||||
setMessages(normalizedFallback.reverse());
|
||||
setUsersMap(fallbackUsers);
|
||||
setEmojiCache(fallbackEmojis);
|
||||
setHasMoreMessages(fallbackMessages.length === 50);
|
||||
scrollToBottomRef.current = true;
|
||||
lastPollTimeRef.current = new Date().toISOString();
|
||||
@@ -2138,6 +2284,7 @@ export default function DiscordLogs() {
|
||||
|
||||
setMessages(orderedMessages);
|
||||
setUsersMap(usersData);
|
||||
setEmojiCache(emojiCacheData);
|
||||
setHasMoreMessages(messagesData.length === 50);
|
||||
|
||||
// Reset poll time for edit detection
|
||||
@@ -2172,28 +2319,28 @@ export default function DiscordLogs() {
|
||||
// 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;
|
||||
});
|
||||
pendingTargetMessageRef.current = null;
|
||||
waitForElementAndScroll(targetMessageId, 5000); // 3 pulses (1.5s) + 3s fade
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle scroll to bottom on initial channel load
|
||||
if (scrollToBottomRef.current) {
|
||||
scrollToBottomRef.current = false;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
// Wait for content to render, then scroll to bottom
|
||||
const scrollToBottom = () => {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
};
|
||||
|
||||
// Scroll immediately
|
||||
scrollToBottom();
|
||||
|
||||
// Then scroll again after a delay to correct for any layout shifts
|
||||
setTimeout(scrollToBottom, 300);
|
||||
setTimeout(scrollToBottom, 600);
|
||||
}
|
||||
}, [messages]);
|
||||
}, [messages, waitForElementAndScroll]);
|
||||
|
||||
// Load more messages (pagination)
|
||||
const loadMoreMessages = useCallback(async () => {
|
||||
@@ -2209,9 +2356,10 @@ export default function DiscordLogs() {
|
||||
if (!response.ok) throw new Error('Failed to fetch more messages');
|
||||
const data = await response.json();
|
||||
|
||||
// Handle new response format { messages, users }
|
||||
// Handle new response format { messages, users, emojiCache }
|
||||
const messagesData = data.messages || data;
|
||||
const usersData = data.users || {};
|
||||
const emojiCacheData = data.emojiCache || {};
|
||||
|
||||
const normalizedMessages = messagesData.map(msg => ({
|
||||
...msg,
|
||||
@@ -2226,6 +2374,8 @@ export default function DiscordLogs() {
|
||||
setMessages(prev => [...normalizedMessages.reverse(), ...prev]);
|
||||
// Merge new users into existing usersMap
|
||||
setUsersMap(prev => ({ ...prev, ...usersData }));
|
||||
// Merge new emojis into existing emojiCache
|
||||
setEmojiCache(prev => ({ ...prev, ...emojiCacheData }));
|
||||
setHasMoreMessages(messagesData.length === 50);
|
||||
} catch (err) {
|
||||
console.error('Failed to load more messages:', err);
|
||||
@@ -2234,7 +2384,96 @@ export default function DiscordLogs() {
|
||||
}
|
||||
}, [loadingMore, hasMoreMessages, messages, selectedChannel]);
|
||||
|
||||
// Infinite scroll: load more when scrolling near the top
|
||||
// Jump to first message in channel
|
||||
const jumpToFirstMessage = useCallback(async () => {
|
||||
if (!selectedChannel || loadingMessages) return;
|
||||
|
||||
setLoadingMessages(true);
|
||||
setMessages([]);
|
||||
|
||||
try {
|
||||
// Fetch oldest messages using oldest=true parameter
|
||||
const response = await authFetch(
|
||||
`/api/discord/messages?channelId=${selectedChannel.id}&limit=50&oldest=true`
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to fetch first messages');
|
||||
const data = await response.json();
|
||||
|
||||
const messagesData = data.messages || data;
|
||||
const usersData = data.users || {};
|
||||
const emojiCacheData = data.emojiCache || {};
|
||||
|
||||
const normalizedMessages = messagesData.map(msg => ({
|
||||
...msg,
|
||||
referenced_message: msg.referencedMessage || msg.referenced_message,
|
||||
attachments: (msg.attachments || []).map(att => ({
|
||||
...att,
|
||||
content_type: att.contentType || att.content_type,
|
||||
})),
|
||||
}));
|
||||
|
||||
setMessages(normalizedMessages);
|
||||
setUsersMap(usersData);
|
||||
setEmojiCache(emojiCacheData);
|
||||
setHasMoreMessages(messagesData.length === 50);
|
||||
setHasNewerMessages(true); // We're viewing oldest, so there are newer messages
|
||||
|
||||
// Scroll to top after messages load
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to jump to first message:', err);
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}, [selectedChannel, loadingMessages]);
|
||||
|
||||
// Load newer messages (when viewing historical/oldest messages)
|
||||
const loadNewerMessages = useCallback(async () => {
|
||||
if (loadingNewer || !hasNewerMessages || messages.length === 0) return;
|
||||
|
||||
setLoadingNewer(true);
|
||||
try {
|
||||
// Get the newest message ID currently loaded
|
||||
const newestMessage = messages[messages.length - 1];
|
||||
const response = await authFetch(
|
||||
`/api/discord/messages?channelId=${selectedChannel.id}&limit=50&after=${newestMessage.id}`
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to fetch newer messages');
|
||||
const data = await response.json();
|
||||
|
||||
const messagesData = data.messages || data;
|
||||
const usersData = data.users || {};
|
||||
const emojiCacheData = data.emojiCache || {};
|
||||
|
||||
const normalizedMessages = messagesData.map(msg => ({
|
||||
...msg,
|
||||
referenced_message: msg.referencedMessage || msg.referenced_message,
|
||||
attachments: (msg.attachments || []).map(att => ({
|
||||
...att,
|
||||
content_type: att.contentType || att.content_type,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Append newer messages
|
||||
setMessages(prev => [...prev, ...normalizedMessages]);
|
||||
setUsersMap(prev => ({ ...prev, ...usersData }));
|
||||
setEmojiCache(prev => ({ ...prev, ...emojiCacheData }));
|
||||
// If we got less than 50, we've reached the end (no more newer messages)
|
||||
if (messagesData.length < 50) {
|
||||
setHasNewerMessages(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load newer messages:', err);
|
||||
} finally {
|
||||
setLoadingNewer(false);
|
||||
}
|
||||
}, [loadingNewer, hasNewerMessages, messages, selectedChannel]);
|
||||
|
||||
// Infinite scroll: load more when scrolling near the top or bottom
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
@@ -2243,7 +2482,7 @@ export default function DiscordLogs() {
|
||||
// Don't load more when viewing search results
|
||||
if (searchQuery.trim().length >= 2 && searchResults !== null) return;
|
||||
|
||||
// Load more when within 200px of the top
|
||||
// Load older messages when within 200px of the top
|
||||
if (container.scrollTop < 200 && hasMoreMessages && !loadingMore) {
|
||||
// Save scroll position before loading
|
||||
const scrollHeightBefore = container.scrollHeight;
|
||||
@@ -2255,17 +2494,25 @@ export default function DiscordLogs() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Load newer messages when within 200px of the bottom (when viewing historical)
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
if (distanceFromBottom < 200 && hasNewerMessages && !loadingNewer) {
|
||||
loadNewerMessages();
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [hasMoreMessages, loadingMore, loadMoreMessages, searchQuery, searchResults]);
|
||||
}, [hasMoreMessages, loadingMore, loadMoreMessages, hasNewerMessages, loadingNewer, loadNewerMessages, searchQuery, searchResults]);
|
||||
|
||||
// Poll for new messages and edits every 5 seconds
|
||||
useEffect(() => {
|
||||
if (!selectedChannel || loadingMessages || messages.length === 0) return;
|
||||
// Don't poll when viewing search results - it would add messages that aren't in search
|
||||
if (searchQuery.trim().length >= 2 && searchResults !== null) return;
|
||||
// Don't poll when viewing historical messages (jumped to first)
|
||||
if (hasNewerMessages) return;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
@@ -2281,11 +2528,13 @@ export default function DiscordLogs() {
|
||||
|
||||
let newMessages = [];
|
||||
let newUsersData = {};
|
||||
let newEmojiCacheData = {};
|
||||
|
||||
if (newMsgsResponse.ok) {
|
||||
const data = await newMsgsResponse.json();
|
||||
const messagesData = data.messages || data;
|
||||
newUsersData = data.users || {};
|
||||
newEmojiCacheData = data.emojiCache || {};
|
||||
|
||||
if (messagesData.length > 0) {
|
||||
newMessages = messagesData.map(msg => ({
|
||||
@@ -2309,7 +2558,9 @@ export default function DiscordLogs() {
|
||||
const editedData = await editedResponse.json();
|
||||
const editedMessagesData = editedData.messages || editedData;
|
||||
const editedUsersData = editedData.users || {};
|
||||
const editedEmojiCacheData = editedData.emojiCache || {};
|
||||
newUsersData = { ...newUsersData, ...editedUsersData };
|
||||
newEmojiCacheData = { ...newEmojiCacheData, ...editedEmojiCacheData };
|
||||
|
||||
editedMessages = editedMessagesData.map(msg => ({
|
||||
...msg,
|
||||
@@ -2358,6 +2609,7 @@ export default function DiscordLogs() {
|
||||
});
|
||||
|
||||
setUsersMap(prev => ({ ...prev, ...newUsersData }));
|
||||
setEmojiCache(prev => ({ ...prev, ...newEmojiCacheData }));
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom if user was already near bottom and there are new messages
|
||||
@@ -2372,7 +2624,7 @@ export default function DiscordLogs() {
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [selectedChannel, loadingMessages, messages, searchQuery, searchResults]); // Poll for channel/guild updates every 5 seconds
|
||||
}, [selectedChannel, loadingMessages, messages, searchQuery, searchResults, hasNewerMessages]); // Poll for channel/guild updates every 5 seconds
|
||||
useEffect(() => {
|
||||
if (loading) return; // Don't poll during initial load
|
||||
|
||||
@@ -2677,7 +2929,7 @@ export default function DiscordLogs() {
|
||||
onClick={() => setTopicExpanded(!topicExpanded)}
|
||||
title={topicExpanded ? 'Click to collapse' : 'Click to expand'}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap, rolesMap })
|
||||
__html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap, rolesMap, emojiCache })
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
@@ -2703,6 +2955,16 @@ export default function DiscordLogs() {
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="discord-jump-first-btn"
|
||||
onClick={jumpToFirstMessage}
|
||||
title="Jump to first message"
|
||||
disabled={loadingMessages}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<path d="M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6zM6 6h2v12H6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`discord-member-toggle-btn ${memberListExpanded ? 'active' : ''}`}
|
||||
onClick={() => setMemberListExpanded(!memberListExpanded)}
|
||||
@@ -2908,6 +3170,7 @@ export default function DiscordLogs() {
|
||||
onPreviewLoad={handlePreviewLoad}
|
||||
channelMap={channelMap}
|
||||
usersMap={usersMap}
|
||||
emojiCache={emojiCache}
|
||||
members={members}
|
||||
onChannelSelect={handleChannelSelect}
|
||||
channelName={selectedChannel?.name}
|
||||
@@ -3016,7 +3279,7 @@ export default function DiscordLogs() {
|
||||
<div className="discord-reaction-popup-header">
|
||||
{reactionPopup.emoji.id ? (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/emojis/${reactionPopup.emoji.id}.${reactionPopup.emoji.animated ? 'gif' : 'png'}`}
|
||||
src={reactionPopup.emoji.url || `https://cdn.discordapp.com/emojis/${reactionPopup.emoji.id}.${reactionPopup.emoji.animated ? 'gif' : 'png'}`}
|
||||
alt={reactionPopup.emoji.name}
|
||||
className="discord-reaction-popup-emoji"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user