Better Chat
All checks were successful
Build and Release / build-and-release (push) Successful in 11m38s

This commit is contained in:
Bryan1029384756
2026-02-17 10:12:38 -06:00
parent 9162ca7c94
commit bebf0bf989
11 changed files with 1142 additions and 201 deletions

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import {
@@ -23,11 +24,13 @@ import Tooltip from './Tooltip';
import UserProfilePopup from './UserProfilePopup';
import Avatar from './Avatar';
import MentionMenu from './MentionMenu';
import SlashCommandMenu from './SlashCommandMenu';
import MessageItem, { getUserColor } from './MessageItem';
import ColoredIcon from './ColoredIcon';
import { usePlatform } from '../platform';
import { useVoice } from '../contexts/VoiceContext';
import { useSearch } from '../contexts/SearchContext';
import { generateUniqueMessage } from '../utils/floodMessages';
const metadataCache = new Map();
const attachmentCache = new Map();
@@ -133,6 +136,17 @@ const filterRolesForMention = (roles, query) => {
return [...prefix, ...substring];
};
const SLASH_COMMANDS = [
{ name: 'ping', description: 'Responds with Pong!', category: 'Built-In' },
{ name: 'flood', description: 'Generate test messages (e.g. /flood 100)', category: 'Testing' },
];
const filterSlashCommands = (commands, query) => {
if (!query) return commands;
const q = query.toLowerCase();
return commands.filter(c => c.name.toLowerCase().startsWith(q));
};
const isNewDay = (current, previous) => {
if (!previous) return true;
return current.getDate() !== previous.getDate()
@@ -490,7 +504,7 @@ const InputContextMenu = ({ x, y, onClose, onPaste }) => {
);
};
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned }) => {
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned, jumpToMessageId, onClearJumpToMessage }) => {
const { crypto } = usePlatform();
const { isReceivingScreenShareAudio } = useVoice();
const searchCtx = useSearch();
@@ -512,26 +526,40 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const [profilePopup, setProfilePopup] = useState(null);
const [mentionQuery, setMentionQuery] = useState(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [slashQuery, setSlashQuery] = useState(null);
const [slashIndex, setSlashIndex] = useState(0);
const [ephemeralMessages, setEphemeralMessages] = useState([]);
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const inputDivRef = useRef(null);
const savedRangeRef = useRef(null);
const fileInputRef = useRef(null);
const typingTimeoutRef = useRef(null);
const lastTypingEmitRef = useRef(0);
const isInitialLoadRef = useRef(true);
const decryptionDoneRef = useRef(false);
const channelLoadIdRef = useRef(0);
const jumpToMessageIdRef = useRef(null);
const pingSeededRef = useRef(false);
const prevScrollHeightRef = useRef(0);
const isLoadingMoreRef = useRef(false);
const statusRef = useRef(null);
const loadMoreRef = useRef(null);
const userSentMessageRef = useRef(false);
const topSentinelRef = useRef(null);
const scrollOnNextDataRef = useRef(false);
const notifiedMessageIdsRef = useRef(new Set());
const pendingNotificationIdsRef = useRef(new Set());
const lastPingTimeRef = useRef(0);
// Virtuoso refs and state
const virtuosoRef = useRef(null);
const scrollerElRef = useRef(null);
const chatInputFormRef = useRef(null);
const INITIAL_FIRST_INDEX = 100000;
const [firstItemIndex, setFirstItemIndex] = useState(INITIAL_FIRST_INDEX);
const prevMessageCountRef = useRef(0);
const prevFirstMsgIdRef = useRef(null);
const isAtBottomRef = useRef(true);
const convex = useConvex();
const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
@@ -545,6 +573,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
{ initialNumItems: 50 }
);
useEffect(() => {
statusRef.current = status;
loadMoreRef.current = loadMore;
}, [status, loadMore]);
const typingData = useQuery(
api.typing.getTyping,
channelId ? { channelId } : "skip"
@@ -559,6 +592,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const startTypingMutation = useMutation(api.typing.startTyping);
const stopTypingMutation = useMutation(api.typing.stopTyping);
const markReadMutation = useMutation(api.readState.markRead);
const sendBatchMutation = useMutation(api.messages.sendBatch);
const floodInProgressRef = useRef(false);
const floodAbortRef = useRef(false);
const readState = useQuery(
api.readState.getReadState,
@@ -621,7 +657,21 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
if (needsDecryption.length === 0) {
// Still re-render from cache in case optimistic matches were added
if (!cancelled) setDecryptedMessages(buildFromCache());
if (!cancelled) {
decryptionDoneRef.current = true;
setDecryptedMessages(buildFromCache());
if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
const loadId = channelLoadIdRef.current;
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
requestAnimationFrame(() => requestAnimationFrame(() => {
if (channelLoadIdRef.current === loadId) scrollEnd();
}));
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
}
}
return;
}
@@ -731,7 +781,21 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
if (cancelled) return;
// Phase 3: Re-render with newly decrypted content
decryptionDoneRef.current = true;
setDecryptedMessages(buildFromCache());
// After decryption, items may be taller — re-scroll to bottom.
// Double-rAF waits for paint + ResizeObserver cycle; escalating timeouts are safety nets.
if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
const loadId = channelLoadIdRef.current;
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
requestAnimationFrame(() => requestAnimationFrame(() => {
if (channelLoadIdRef.current === loadId) scrollEnd();
}));
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
}
};
processUncached();
@@ -758,8 +822,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
useEffect(() => {
// Don't clear messageDecryptionCache — it persists across channel switches
channelLoadIdRef.current += 1;
setDecryptedMessages([]);
isInitialLoadRef.current = true;
decryptionDoneRef.current = false;
pingSeededRef.current = false;
notifiedMessageIdsRef.current = new Set();
pendingNotificationIdsRef.current = new Set();
@@ -768,9 +834,45 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setMentionQuery(null);
setUnreadDividerTimestamp(null);
setReactionPickerMsgId(null);
setSlashQuery(null);
setEphemeralMessages([]);
floodAbortRef.current = true;
setFirstItemIndex(INITIAL_FIRST_INDEX);
prevMessageCountRef.current = 0;
prevFirstMsgIdRef.current = null;
onTogglePinned();
}, [channelId]);
// Sync jumpToMessageId prop to ref
useEffect(() => {
jumpToMessageIdRef.current = jumpToMessageId || null;
}, [jumpToMessageId]);
// Jump to a specific message (from search results)
useEffect(() => {
if (!jumpToMessageId || !decryptedMessages.length || !decryptionDoneRef.current) return;
const idx = decryptedMessages.findIndex(m => m.id === jumpToMessageId);
if (idx !== -1 && virtuosoRef.current) {
isInitialLoadRef.current = false;
virtuosoRef.current.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
setTimeout(() => {
const el = document.getElementById(`msg-${jumpToMessageId}`);
if (el) {
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
}, 300);
onClearJumpToMessage?.();
}
}, [jumpToMessageId, decryptedMessages, onClearJumpToMessage]);
// Safety timeout: clear jumpToMessageId if message never found (too old / not loaded)
useEffect(() => {
if (!jumpToMessageId) return;
const timer = setTimeout(() => onClearJumpToMessage?.(), 5000);
return () => clearTimeout(timer);
}, [jumpToMessageId, onClearJumpToMessage]);
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
const isMentionedInContent = useCallback((content) => {
@@ -888,91 +990,93 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
...filteredMentionMembers.map(m => ({ type: 'member', ...m })),
];
const scrollToBottom = useCallback((force = false) => {
const container = messagesContainerRef.current;
if (!container) return;
if (force) {
container.scrollTop = container.scrollHeight;
if (isInitialLoadRef.current) {
const el = scrollerElRef.current;
if (el) el.scrollTop = el.scrollHeight;
return;
}
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 300) {
container.scrollTop = container.scrollHeight;
if (force) {
// Direct DOM scroll is more reliable than scrollToIndex for user-sent messages
const snap = () => {
const el = scrollerElRef.current;
if (el) el.scrollTop = el.scrollHeight;
};
snap();
// Escalating retries for late-sizing content (images, embeds)
setTimeout(snap, 50);
setTimeout(snap, 150);
} else if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'smooth',
});
}
}, []);
useLayoutEffect(() => {
const container = messagesContainerRef.current;
if (!container || decryptedMessages.length === 0) return;
if (isLoadingMoreRef.current) {
const newScrollHeight = container.scrollHeight;
const heightDifference = newScrollHeight - prevScrollHeightRef.current;
container.scrollTop += heightDifference;
isLoadingMoreRef.current = false;
return;
// Virtuoso: startReached replaces IntersectionObserver
const handleStartReached = useCallback(() => {
if (statusRef.current === 'CanLoadMore') {
loadMoreRef.current(50);
}
}, []);
if (userSentMessageRef.current || isInitialLoadRef.current) {
container.scrollTop = container.scrollHeight;
// Virtuoso: firstItemIndex management for prepend without jitter
useEffect(() => {
const prevCount = prevMessageCountRef.current;
const newCount = decryptedMessages.length;
if (newCount > prevCount && prevCount > 0) {
if (prevFirstMsgIdRef.current && decryptedMessages[0]?.id !== prevFirstMsgIdRef.current) {
const prependedCount = newCount - prevCount;
setFirstItemIndex(prev => prev - prependedCount);
}
}
prevMessageCountRef.current = newCount;
prevFirstMsgIdRef.current = decryptedMessages[0]?.id || null;
}, [decryptedMessages]);
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
const followOutput = useCallback((isAtBottom) => {
console.log('[Virtuoso] followOutput:', { isAtBottom, jumpTo: jumpToMessageIdRef.current, userSent: userSentMessageRef.current });
if (jumpToMessageIdRef.current) return false;
// If user sent a message, ALWAYS scroll to bottom aggressively
if (userSentMessageRef.current) {
userSentMessageRef.current = false;
isInitialLoadRef.current = false;
return;
return 'auto';
}
// Always auto-scroll if near bottom — handles decryption content changes,
// new messages, and any height shifts
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 300) {
container.scrollTop = container.scrollHeight;
// During initial load, disable followOutput so it doesn't conflict with manual scrollToIndex calls
if (isInitialLoadRef.current) {
return false;
}
}, [decryptedMessages, rawMessages?.length]);
// Use 'smooth' again to see if 'auto' was causing the jump
return isAtBottom ? 'smooth' : false;
}, []);
useEffect(() => {
const sentinel = topSentinelRef.current;
const container = messagesContainerRef.current;
if (!sentinel || !container) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && status === 'CanLoadMore') {
prevScrollHeightRef.current = container.scrollHeight;
isLoadingMoreRef.current = true;
loadMore(50);
}
},
{ root: container, rootMargin: '200px 0px 0px 0px', threshold: 0 }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [status, loadMore]);
// Mark as read when scrolled to bottom
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 50) {
markChannelAsRead();
// Virtuoso: atBottomStateChange replaces manual scroll listener for read state
const handleAtBottomStateChange = useCallback((atBottom) => {
console.log('[Virtuoso] atBottomStateChange:', atBottom);
isAtBottomRef.current = atBottom;
if (atBottom) {
markChannelAsRead();
// Delay clearing isInitialLoadRef so self-correction has time for late-loading content
if (isInitialLoadRef.current && decryptionDoneRef.current) {
const loadId = channelLoadIdRef.current;
setTimeout(() => {
if (channelLoadIdRef.current === loadId) {
isInitialLoadRef.current = false;
}
}, 1500);
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
} else if (isInitialLoadRef.current && decryptionDoneRef.current) {
// Content resize pushed us off bottom during initial load — snap back
const el = scrollerElRef.current;
if (el) el.scrollTop = el.scrollHeight;
}
}, [markChannelAsRead]);
// Mark as read on initial load (already scrolled to bottom)
useEffect(() => {
if (decryptedMessages.length > 0) {
const container = messagesContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 50) {
markChannelAsRead();
}
}
}, [decryptedMessages.length, markChannelAsRead]);
// Mark as read when component unmounts (e.g., switching to voice channel)
useEffect(() => {
return () => {
@@ -980,6 +1084,80 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
};
}, []);
// Track input height for synchronous scroll adjustment
const prevInputHeightRef = useRef(0);
// Use useLayoutEffect to adjust scroll BEFORE paint when React updates (e.g. isMultiline change)
React.useLayoutEffect(() => {
const el = chatInputFormRef.current;
if (!el) return;
const currentHeight = el.clientHeight;
if (prevInputHeightRef.current > 0 && currentHeight !== prevInputHeightRef.current) {
const heightDiff = currentHeight - prevInputHeightRef.current;
const scroller = scrollerElRef.current;
if (scroller) {
const scrollTop = scroller.scrollTop;
const scrollHeight = scroller.scrollHeight;
const clientHeight = scroller.clientHeight;
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
// If we were at bottom (approx), force stay at bottom
if (previousDistanceFromBottom < 50) {
console.log('[LayoutEffect] Sync scroll adjustment', { heightDiff, previousDistanceFromBottom });
scroller.scrollTop = scrollHeight;
}
}
}
prevInputHeightRef.current = currentHeight;
});
useEffect(() => {
const el = chatInputFormRef.current;
if (!el) {
console.error('[ResizeObserver] chatInputFormRef is null!');
return;
}
console.log('[ResizeObserver] Attaching to form', el);
const observer = new ResizeObserver(() => {
const newHeight = el.clientHeight;
// We use a separate ref for ResizeObserver to avoid conflict/loop with layout effect if needed,
// but sharing prevInputHeightRef is mostly fine if we are careful.
// Actually, let's just use the ref we have.
if (newHeight !== prevInputHeightRef.current) {
const heightDiff = newHeight - prevInputHeightRef.current;
prevInputHeightRef.current = newHeight;
const scroller = scrollerElRef.current;
if (!scroller) return;
const scrollTop = scroller.scrollTop;
const scrollHeight = scroller.scrollHeight;
const clientHeight = scroller.clientHeight;
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
console.log('[ResizeObserver] Input resize:', {
newHeight,
heightDiff,
previousDistanceFromBottom
});
if (previousDistanceFromBottom < 50) {
console.log('[ResizeObserver] Forcing scroll to bottom');
scroller.scrollTop = scrollHeight;
}
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const saveSelection = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
@@ -1048,6 +1226,134 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
}
};
const checkSlashTrigger = () => {
if (!inputDivRef.current) return;
const text = inputDivRef.current.textContent;
if (text.startsWith('/')) {
setSlashQuery(text.slice(1));
setSlashIndex(0);
} else {
setSlashQuery(null);
}
};
const handleSlashSelect = (cmd) => {
if (!inputDivRef.current) return;
inputDivRef.current.textContent = `/${cmd.name}`;
setInput(`/${cmd.name}`);
setSlashQuery(null);
// Place cursor at end
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(inputDivRef.current);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
inputDivRef.current.focus();
};
const filteredSlashCommands = slashQuery !== null ? filterSlashCommands(SLASH_COMMANDS, slashQuery) : [];
const executeSlashCommand = (command, cmdArgs) => {
if (command.name === 'ping') {
setEphemeralMessages(prev => [...prev, {
id: `ephemeral-${Date.now()}`,
type: 'ephemeral',
command: '/ping',
username,
content: 'Pong!',
created_at: Date.now(),
}]);
} else if (command.name === 'flood') {
const count = Math.min(Math.max(parseInt(cmdArgs) || 100, 1), 5000);
if (floodInProgressRef.current) {
setEphemeralMessages(prev => [...prev, {
id: `ephemeral-${Date.now()}`,
type: 'ephemeral',
command: '/flood',
username,
content: 'A flood is already in progress. Please wait for it to finish.',
created_at: Date.now(),
}]);
return;
}
if (!channelKey) {
setEphemeralMessages(prev => [...prev, {
id: `ephemeral-${Date.now()}`,
type: 'ephemeral',
command: '/flood',
username,
content: 'Cannot flood: Missing encryption key for this channel.',
created_at: Date.now(),
}]);
return;
}
const senderId = localStorage.getItem('userId');
const signingKey = sessionStorage.getItem('signingKey');
if (!senderId || !signingKey) return;
floodInProgressRef.current = true;
floodAbortRef.current = false;
const progressId = `ephemeral-flood-${Date.now()}`;
setEphemeralMessages(prev => [...prev, {
id: progressId,
type: 'ephemeral',
command: '/flood',
username,
content: `Generating messages... 0/${count} (0%)`,
created_at: Date.now(),
}]);
(async () => {
const BATCH_SIZE = 50;
let sent = 0;
try {
for (let i = 0; i < count; i += BATCH_SIZE) {
if (floodAbortRef.current) break;
const batchEnd = Math.min(i + BATCH_SIZE, count);
const batch = [];
for (let j = i; j < batchEnd; j++) {
const text = generateUniqueMessage(j);
const { content: encryptedContent, iv, tag } = await crypto.encryptData(text, channelKey);
const ciphertext = encryptedContent + tag;
const signature = await crypto.signMessage(signingKey, ciphertext);
batch.push({
channelId,
senderId,
ciphertext,
nonce: iv,
signature,
keyVersion: 1,
});
}
await sendBatchMutation({ messages: batch });
sent += batch.length;
const pct = Math.round((sent / count) * 100);
setEphemeralMessages(prev => prev.map(m =>
m.id === progressId
? { ...m, content: `Generating messages... ${sent}/${count} (${pct}%)` }
: m
));
}
setEphemeralMessages(prev => prev.map(m =>
m.id === progressId
? { ...m, content: floodAbortRef.current ? `Flood stopped. Sent ${sent}/${count} messages.` : `Done! Sent ${sent} test messages.` }
: m
));
} catch (err) {
console.error('Flood error:', err);
setEphemeralMessages(prev => prev.map(m =>
m.id === progressId
? { ...m, content: `Flood error after ${sent} messages: ${err.message}` }
: m
));
} finally {
floodInProgressRef.current = false;
}
})();
}
};
const insertMention = (item) => {
if (!inputDivRef.current) return;
const selection = window.getSelection();
@@ -1167,8 +1473,30 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
}
if (!messageContent && pendingFiles.length === 0) return;
// Intercept slash commands
if (messageContent.startsWith('/') && pendingFiles.length === 0) {
const parts = messageContent.slice(1).split(/\s+/);
const cmdName = parts[0];
const cmdArgs = parts.slice(1).join(' ');
const command = SLASH_COMMANDS.find(c => c.name === cmdName);
if (command) {
executeSlashCommand(command, cmdArgs);
if (inputDivRef.current) inputDivRef.current.innerHTML = '';
setInput(''); setHasImages(false);
setSlashQuery(null);
clearTypingState();
userSentMessageRef.current = true;
scrollOnNextDataRef.current = true;
isInitialLoadRef.current = false;
setTimeout(() => scrollToBottom(true), 100);
return;
}
}
setUploading(true);
userSentMessageRef.current = true;
scrollOnNextDataRef.current = true;
isInitialLoadRef.current = false;
const replyId = replyingTo?.messageId;
try {
for (const file of pendingFiles) await uploadAndSendFile(file);
@@ -1182,6 +1510,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setReplyingTo(null);
setMentionQuery(null);
markChannelAsRead();
setTimeout(() => scrollToBottom(true), 100);
} catch (err) {
console.error("Error sending message/files:", err);
alert("Failed to send message/files");
@@ -1215,6 +1544,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
};
const handleKeyDown = (e) => {
if (slashQuery !== null && filteredSlashCommands.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex(i => (i + 1) % filteredSlashCommands.length); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex(i => (i - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); return; }
if (e.key === 'Tab') { e.preventDefault(); handleSlashSelect(filteredSlashCommands[slashIndex]); return; }
if (e.key === 'Escape') { e.preventDefault(); setSlashQuery(null); return; }
}
if (mentionQuery !== null && mentionItems.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % mentionItems.length); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + mentionItems.length) % mentionItems.length); return; }
@@ -1287,14 +1622,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setContextMenu(null);
};
const scrollToMessage = (messageId) => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
const scrollToMessage = useCallback((messageId) => {
const idx = decryptedMessages.findIndex(m => m.id === messageId);
if (idx !== -1 && virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
setTimeout(() => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
}, 300);
}
};
}, [decryptedMessages]);
// Stable callbacks for MessageItem
const handleProfilePopup = useCallback((e, msg) => {
@@ -1304,6 +1644,181 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const isDM = channelType === 'dm';
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
// Merge messages + ephemeral for Virtuoso data
const allDisplayMessages = useMemo(() => {
return [...decryptedMessages, ...ephemeralMessages];
}, [decryptedMessages, ephemeralMessages]);
// When user sends a message, scroll to bottom once the new message arrives in data
// Virtuoso handles followOutput automatically via the prop
// We don't need manual scrolling here which might conflict
useEffect(() => {
if (scrollOnNextDataRef.current) {
scrollOnNextDataRef.current = false;
// followOutput already returned 'auto' but it's unreliable — force DOM scroll
requestAnimationFrame(() => {
const el = scrollerElRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}
}, [allDisplayMessages]);
// Header component for Virtuoso — shows skeleton loader or channel beginning
const renderListHeader = useCallback(() => {
return (
<>
{status === 'LoadingMore' && (
<>
{[
{ name: 80, lines: [260, 180] },
{ name: 60, lines: [310] },
{ name: 100, lines: [240, 140] },
{ name: 70, lines: [290] },
{ name: 90, lines: [200, 260] },
{ name: 55, lines: [330] },
].map((s, i) => (
<div key={i} className="skeleton-message" style={{ animationDelay: `${i * 0.1}s` }}>
<div className="skeleton-avatar" />
<div style={{ flex: 1 }}>
<div className="skeleton-name" style={{ width: s.name }} />
{s.lines.map((w, j) => (
<div key={j} className="skeleton-line" style={{ width: w }} />
))}
</div>
</div>
))}
</>
)}
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
<div className="channel-beginning">
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
<h1 className="channel-beginning-title">
{isDM ? `${channelName}` : `Welcome to #${channelName}`}
</h1>
<p className="channel-beginning-subtitle">
{isDM
? `This is the beginning of your direct message history with ${channelName}.`
: `This is the start of the #${channelName} channel.`
}
</p>
</div>
)}
</>
);
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName]);
// Render individual message item for Virtuoso
const renderMessageItem = useCallback((item, arrayIndex) => {
// Handle ephemeral messages (they come after decryptedMessages in allDisplayMessages)
if (item.type === 'ephemeral') {
const emsg = item;
return (
<div className="message-item ephemeral-message">
<div className="message-reply-context ephemeral-reply-context">
<div className="reply-spine" />
<div className="ephemeral-reply-avatar">
<svg width="16" height="12" viewBox="0 0 28 20">
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
</svg>
</div>
<span className="reply-author" style={{ color: '#5865f2' }}>System</span>
<span className="reply-text">{emsg.username} used {emsg.command}</span>
</div>
<div className="message-avatar-wrapper">
<div className="ephemeral-avatar">
<svg width="28" height="20" viewBox="0 0 28 20">
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
</svg>
</div>
</div>
<div className="message-body">
<div className="message-header">
<span className="username" style={{ color: '#5865f2' }}>System</span>
<span className="ephemeral-bot-badge">BOT</span>
<span className="timestamp">{new Date(emsg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div className="message-content">
<span>{emsg.content}</span>
</div>
<div className="ephemeral-message-footer">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.6 }}>
<path d="M8 3C4.5 3 1.6 5.1.3 8c1.3 2.9 4.2 5 7.7 5s6.4-2.1 7.7-5c-1.3-2.9-4.2-5-7.7-5zm0 8.3c-1.8 0-3.3-1.5-3.3-3.3S6.2 4.7 8 4.7s3.3 1.5 3.3 3.3S9.8 11.3 8 11.3zM8 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
<span className="ephemeral-message-footer-text">Only you can see this</span>
<span className="ephemeral-message-footer-sep">&middot;</span>
<span
className="ephemeral-message-dismiss"
onClick={() => setEphemeralMessages(prev => prev.filter(m => m.id !== emsg.id))}
>
Dismiss message
</span>
</div>
</div>
</div>
);
}
// Regular message
const msg = item;
const idx = arrayIndex;
const currentDate = new Date(msg.created_at);
const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1]?.created_at) : null;
const isMentioned = isMentionedInContent(msg.content);
const isOwner = msg.username === username;
const canDelete = isOwner || !!myPermissions?.manage_messages;
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
const isGrouped = prevMsg
&& prevMsg.username === msg.username
&& !isNewDay(currentDate, previousDate)
&& (currentDate - new Date(prevMsg.created_at)) < 60000
&& !msg.replyToId;
const showDateDivider = isNewDay(currentDate, previousDate);
const dateLabel = showDateDivider ? currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : '';
const showUnreadDivider = unreadDividerTimestamp != null
&& msg.created_at > unreadDividerTimestamp
&& (idx === 0 || decryptedMessages[idx - 1]?.created_at <= unreadDividerTimestamp);
return (
<MessageItem
msg={msg}
isGrouped={isGrouped}
showDateDivider={showDateDivider}
showUnreadDivider={showUnreadDivider}
dateLabel={dateLabel}
isMentioned={isMentioned}
isOwner={isOwner}
roles={roles}
customEmojis={customEmojis}
isEditing={editingMessage?.id === msg.id}
isHovered={hoveredMessageId === msg.id}
editInput={editInput}
username={username}
onHover={() => setHoveredMessageId(msg.id)}
onLeave={() => setHoveredMessageId(null)}
onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }}
onAddReaction={(emoji) => { if (emoji) { addReaction({ messageId: msg.id, userId: currentUserId, emoji }); } else { setReactionPickerMsgId(reactionPickerMsgId === msg.id ? null : msg.id); } }}
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner, canDelete }); }}
onEditInputChange={(e) => setEditInput(e.target.value)}
onEditKeyDown={handleEditKeyDown}
onEditSave={handleEditSave}
onEditCancel={() => { setEditingMessage(null); setEditInput(''); }}
onReactionClick={handleReactionClick}
onScrollToMessage={scrollToMessage}
onProfilePopup={handleProfilePopup}
onImageClick={setZoomedImage}
scrollToBottom={scrollToBottom}
Attachment={Attachment}
LinkPreview={LinkPreview}
DirectVideo={DirectVideo}
/>
);
}, [decryptedMessages, username, myPermissions, isMentionedInContent, unreadDividerTimestamp, editingMessage, hoveredMessageId, editInput, roles, customEmojis, reactionPickerMsgId, currentUserId, addReaction, handleEditKeyDown, handleEditSave, handleReactionClick, scrollToMessage, handleProfilePopup, scrollToBottom]);
return (
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
{isDragging && <DragOverlay />}
@@ -1325,95 +1840,33 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onImageClick={setZoomedImage}
/>
<div className="messages-list" ref={messagesContainerRef}>
<div className="messages-content-wrapper">
<div ref={topSentinelRef} style={{ height: '1px', width: '100%' }} />
{status === 'LoadingMore' && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<div className="loading-spinner" />
</div>
)}
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
<div className="channel-beginning">
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
<h1 className="channel-beginning-title">
{isDM ? `${channelName}` : `Welcome to #${channelName}`}
</h1>
<p className="channel-beginning-subtitle">
{isDM
? `This is the beginning of your direct message history with ${channelName}.`
: `This is the start of the #${channelName} channel.`
}
</p>
</div>
)}
{status === 'LoadingFirstPage' && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
<div className="loading-spinner" />
</div>
)}
{decryptedMessages.map((msg, idx) => {
const currentDate = new Date(msg.created_at);
const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1].created_at) : null;
const isMentioned = isMentionedInContent(msg.content);
const isOwner = msg.username === username;
const canDelete = isOwner || !!myPermissions?.manage_messages;
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
const isGrouped = prevMsg
&& prevMsg.username === msg.username
&& !isNewDay(currentDate, previousDate)
&& (currentDate - new Date(prevMsg.created_at)) < 60000
&& !msg.replyToId;
const showDateDivider = isNewDay(currentDate, previousDate);
const dateLabel = showDateDivider ? currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : '';
// Show unread divider before the first message after lastReadTimestamp
const showUnreadDivider = unreadDividerTimestamp != null
&& msg.created_at > unreadDividerTimestamp
&& (idx === 0 || decryptedMessages[idx - 1].created_at <= unreadDividerTimestamp);
return (
<MessageItem
key={msg.id || idx}
msg={msg}
isGrouped={isGrouped}
showDateDivider={showDateDivider}
showUnreadDivider={showUnreadDivider}
dateLabel={dateLabel}
isMentioned={isMentioned}
isOwner={isOwner}
roles={roles}
customEmojis={customEmojis}
isEditing={editingMessage?.id === msg.id}
isHovered={hoveredMessageId === msg.id}
editInput={editInput}
username={username}
onHover={() => setHoveredMessageId(msg.id)}
onLeave={() => setHoveredMessageId(null)}
onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }}
onAddReaction={(emoji) => { if (emoji) { addReaction({ messageId: msg.id, userId: currentUserId, emoji }); } else { setReactionPickerMsgId(reactionPickerMsgId === msg.id ? null : msg.id); } }}
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner, canDelete }); }}
onEditInputChange={(e) => setEditInput(e.target.value)}
onEditKeyDown={handleEditKeyDown}
onEditSave={handleEditSave}
onEditCancel={() => { setEditingMessage(null); setEditInput(''); }}
onReactionClick={handleReactionClick}
onScrollToMessage={scrollToMessage}
onProfilePopup={handleProfilePopup}
onImageClick={setZoomedImage}
scrollToBottom={scrollToBottom}
Attachment={Attachment}
LinkPreview={LinkPreview}
DirectVideo={DirectVideo}
/>
);
})}
<div ref={messagesEndRef} />
</div>
<div className="messages-list">
{status === 'LoadingFirstPage' ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
<div className="loading-spinner" />
</div>
) : (
<Virtuoso
ref={virtuosoRef}
scrollerRef={(el) => { scrollerElRef.current = el; }}
firstItemIndex={firstItemIndex}
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
alignToBottom={true}
atBottomThreshold={20}
data={allDisplayMessages}
startReached={handleStartReached}
followOutput={followOutput}
atBottomStateChange={handleAtBottomStateChange}
increaseViewportBy={{ top: 400, bottom: 400 }}
defaultItemHeight={60}
computeItemKey={(index, item) => item.id || `idx-${index}`}
components={{
Header: () => renderListHeader(),
Footer: () => <div style={{ height: '1px' }} />,
}}
itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)}
/>
)}
</div>
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
{reactionPickerMsgId && (
@@ -1465,7 +1918,15 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
} catch {}
}} />}
<form className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
<form ref={chatInputFormRef} className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
{slashQuery !== null && filteredSlashCommands.length > 0 && (
<SlashCommandMenu
commands={filteredSlashCommands}
selectedIndex={slashIndex}
onSelect={handleSlashSelect}
onHover={setSlashIndex}
/>
)}
{mentionQuery !== null && mentionItems.length > 0 && (
<MentionMenu
items={mentionItems}
@@ -1542,6 +2003,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
}
checkTypedEmoji();
checkMentionTrigger();
checkSlashTrigger();
const now = Date.now();
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});