feat: Introduce comprehensive user settings, voice, chat, and screen sharing features with new components, contexts, icons, and Convex backend integrations.
All checks were successful
Build and Release / build-and-release (push) Successful in 13m55s

This commit is contained in:
Bryan1029384756
2026-02-18 14:48:57 -06:00
parent a9490f7bd4
commit bdc16b9d3f
22 changed files with 755 additions and 126 deletions

View File

@@ -536,6 +536,16 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
// Focused mode state (for jumping to old messages not in paginated view)
const [focusedMode, setFocusedMode] = useState(false);
const [focusedMessageId, setFocusedMessageId] = useState(null);
const [focusedMessages, setFocusedMessages] = useState([]);
const [focusedHasOlder, setFocusedHasOlder] = useState(false);
const [focusedHasNewer, setFocusedHasNewer] = useState(false);
const [focusedLoading, setFocusedLoading] = useState(false);
const focusedModeRef = useRef(false);
const focusedLoadingMoreRef = useRef(false);
const inputDivRef = useRef(null);
const savedRangeRef = useRef(null);
const fileInputRef = useRef(null);
@@ -579,7 +589,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
api.messages.list,
channelId ? { channelId, userId: currentUserId || undefined } : "skip",
channelId && !focusedMode ? { channelId, userId: currentUserId || undefined } : "skip",
{ initialNumItems: 50 }
);
@@ -632,6 +642,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const TAG_LENGTH = 32;
useEffect(() => {
if (focusedModeRef.current) return;
if (!rawMessages || rawMessages.length === 0) {
setDecryptedMessages([]);
return;
@@ -880,6 +891,15 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setReactionPickerMsgId(null);
setSlashQuery(null);
setEphemeralMessages([]);
// Reset focused mode
setFocusedMode(false);
focusedModeRef.current = false;
setFocusedMessageId(null);
setFocusedMessages([]);
setFocusedHasOlder(false);
setFocusedHasNewer(false);
setFocusedLoading(false);
focusedLoadingMoreRef.current = false;
floodAbortRef.current = true;
isLoadingMoreRef.current = false;
loadMoreSettlingRef.current = false;
@@ -900,7 +920,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
jumpToMessageIdRef.current = jumpToMessageId || null;
}, [jumpToMessageId]);
// Jump to a specific message (from search results)
// Jump to a specific message (from search results or pinned panel)
useEffect(() => {
if (!jumpToMessageId || !decryptedMessages.length || !decryptionDoneRef.current) return;
const idx = decryptedMessages.findIndex(m => m.id === jumpToMessageId);
@@ -916,16 +936,231 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
}
}, 300);
onClearJumpToMessage?.();
} else if (idx === -1 && !focusedModeRef.current) {
// Message not loaded in paginated view — enter focused mode
scrollLog('[SCROLL:jumpToMessage] entering focused mode for', jumpToMessageId);
setFocusedMessageId(jumpToMessageId);
onClearJumpToMessage?.();
}
}, [jumpToMessageId, decryptedMessages, onClearJumpToMessage]);
// Safety timeout: clear jumpToMessageId if message never found (too old / not loaded)
// Safety timeout: clear jumpToMessageId only in normal mode
useEffect(() => {
if (!jumpToMessageId) return;
if (!jumpToMessageId || focusedModeRef.current) return;
const timer = setTimeout(() => onClearJumpToMessage?.(), 5000);
return () => clearTimeout(timer);
}, [jumpToMessageId, onClearJumpToMessage]);
// Focused mode: fetch messages around target
useEffect(() => {
if (!focusedMessageId || !channelId) return;
let cancelled = false;
setFocusedLoading(true);
setFocusedMode(true);
focusedModeRef.current = true;
setDecryptedMessages([]);
decryptionDoneRef.current = false;
isInitialLoadRef.current = true;
initialScrollScheduledRef.current = false;
setFirstItemIndex(INITIAL_FIRST_INDEX);
prevMessageCountRef.current = 0;
prevFirstMsgIdRef.current = null;
(async () => {
try {
const result = await convex.query(api.messages.listAround, {
channelId,
messageId: focusedMessageId,
userId: currentUserId || undefined,
});
if (cancelled) return;
if (!result.targetFound) {
scrollLog('[SCROLL:focusedMode] target not found');
setFocusedLoading(false);
// Auto-exit focused mode after brief delay
setTimeout(() => {
if (!cancelled) {
setFocusedMode(false);
focusedModeRef.current = false;
setFocusedMessageId(null);
setFocusedMessages([]);
}
}, 2000);
return;
}
setFocusedMessages(result.messages);
setFocusedHasOlder(result.hasOlder);
setFocusedHasNewer(result.hasNewer);
setFocusedLoading(false);
} catch (err) {
console.error('Failed to load messages around target:', err);
if (!cancelled) {
setFocusedLoading(false);
setFocusedMode(false);
focusedModeRef.current = false;
setFocusedMessageId(null);
setFocusedMessages([]);
}
}
})();
return () => { cancelled = true; };
}, [focusedMessageId, channelId]);
// Focused mode: decrypt focusedMessages (parallel to normal decrypt effect)
useEffect(() => {
if (!focusedMode || focusedMessages.length === 0) return;
let cancelled = false;
const buildFromCache = () => {
return focusedMessages.map(msg => {
const cached = messageDecryptionCache.get(msg.id);
return {
...msg,
content: cached?.content ?? '[Decrypting...]',
isVerified: cached?.isVerified ?? null,
decryptedReply: cached?.decryptedReply ?? null,
};
});
};
const newMessages = buildFromCache();
// Adjust firstItemIndex for prepended messages
const prevCount = prevMessageCountRef.current;
const newCount = newMessages.length;
if (newCount > prevCount && prevCount > 0) {
if (prevFirstMsgIdRef.current && newMessages[0]?.id !== prevFirstMsgIdRef.current) {
const prependedCount = newCount - prevCount;
setFirstItemIndex(prev => prev - prependedCount);
}
}
prevMessageCountRef.current = newCount;
prevFirstMsgIdRef.current = newMessages[0]?.id || null;
setDecryptedMessages(newMessages);
const processUncached = async () => {
if (!channelKey) return;
const needsDecryption = focusedMessages.filter(msg => {
const cached = messageDecryptionCache.get(msg.id);
if (!cached) return true;
if (msg.replyToNonce && msg.replyToContent && cached.decryptedReply === null) return true;
return false;
});
if (needsDecryption.length === 0) {
if (!cancelled) {
decryptionDoneRef.current = true;
setDecryptedMessages(buildFromCache());
}
return;
}
const decryptItems = [];
const decryptMsgMap = [];
const replyDecryptItems = [];
const replyMsgMap = [];
const verifyItems = [];
const verifyMsgMap = [];
for (const msg of needsDecryption) {
if (msg.ciphertext && msg.ciphertext.length >= TAG_LENGTH) {
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
decryptItems.push({ ciphertext: content, key: channelKey, iv: msg.nonce, tag });
decryptMsgMap.push(msg);
}
if (msg.replyToContent && msg.replyToNonce) {
const rTag = msg.replyToContent.slice(-TAG_LENGTH);
const rContent = msg.replyToContent.slice(0, -TAG_LENGTH);
replyDecryptItems.push({ ciphertext: rContent, key: channelKey, iv: msg.replyToNonce, tag: rTag });
replyMsgMap.push(msg);
}
if (msg.signature && msg.public_signing_key) {
verifyItems.push({ publicKey: msg.public_signing_key, message: msg.ciphertext, signature: msg.signature });
verifyMsgMap.push(msg);
}
}
const [decryptResults, replyResults, verifyResults] = await Promise.all([
decryptItems.length > 0 ? crypto.decryptBatch(decryptItems) : [],
replyDecryptItems.length > 0 ? crypto.decryptBatch(replyDecryptItems) : [],
verifyItems.length > 0 ? crypto.verifyBatch(verifyItems) : [],
]);
if (cancelled) return;
const decryptedMap = new Map();
for (let i = 0; i < decryptResults.length; i++) {
const msg = decryptMsgMap[i];
const result = decryptResults[i];
decryptedMap.set(msg.id, result.success ? result.data : '[Decryption Error]');
}
const replyMap = new Map();
for (let i = 0; i < replyResults.length; i++) {
const msg = replyMsgMap[i];
const result = replyResults[i];
if (result.success) {
let text = result.data;
if (text.startsWith('{')) text = '[Attachment]';
else if (text.length > 100) text = text.substring(0, 100) + '...';
replyMap.set(msg.id, text);
} else {
replyMap.set(msg.id, '[Encrypted]');
}
}
const verifyMap = new Map();
for (let i = 0; i < verifyResults.length; i++) {
const msg = verifyMsgMap[i];
const verified = verifyResults[i].verified;
verifyMap.set(msg.id, verified === null ? null : (verifyResults[i].success && verified));
}
for (const msg of needsDecryption) {
const content = decryptedMap.get(msg.id) ??
(msg.ciphertext && msg.ciphertext.length < TAG_LENGTH ? '[Invalid Encrypted Message]' : '[Encrypted Message - Key Missing]');
const isVerified = verifyMap.has(msg.id) ? verifyMap.get(msg.id) : null;
const decryptedReply = replyMap.get(msg.id) ?? null;
messageDecryptionCache.set(msg.id, { content, isVerified, decryptedReply });
}
evictCacheIfNeeded();
if (cancelled) return;
decryptionDoneRef.current = true;
setDecryptedMessages(buildFromCache());
};
processUncached();
return () => { cancelled = true; };
}, [focusedMode, focusedMessages, channelKey]);
// Focused mode: scroll to target message after decryption completes
useEffect(() => {
if (!focusedMode || !focusedMessageId || !decryptionDoneRef.current || !decryptedMessages.length) return;
const idx = decryptedMessages.findIndex(m => m.id === focusedMessageId);
if (idx !== -1 && virtuosoRef.current) {
scrollLog('[SCROLL:focusedMode] scrolling to target', { focusedMessageId, idx });
isInitialLoadRef.current = false;
setTimeout(() => {
virtuosoRef.current?.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
setTimeout(() => {
const el = document.getElementById(`msg-${focusedMessageId}`);
if (el) {
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
}, 300);
}, 100);
}
}, [focusedMode, focusedMessageId, decryptedMessages]);
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
const isMentionedInContent = useCallback((content) => {
@@ -1080,6 +1315,33 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
// Virtuoso: startReached replaces IntersectionObserver
const handleStartReached = useCallback(() => {
if (focusedModeRef.current) {
if (focusedLoadingMoreRef.current || !focusedHasOlder) return;
const msgs = decryptedMessages;
if (!msgs.length) return;
const oldestTimestamp = new Date(msgs[0].created_at).getTime();
focusedLoadingMoreRef.current = true;
(async () => {
try {
const result = await convex.query(api.messages.listBefore, {
channelId,
beforeTimestamp: oldestTimestamp,
userId: currentUserId || undefined,
});
setFocusedMessages(prev => {
const existingIds = new Set(prev.map(m => m.id));
const newMsgs = result.messages.filter(m => !existingIds.has(m.id));
return [...newMsgs, ...prev];
});
setFocusedHasOlder(result.hasMore);
} catch (err) {
console.error('Failed to load older messages:', err);
} finally {
focusedLoadingMoreRef.current = false;
}
})();
return;
}
if (isLoadingMoreRef.current) return;
if (statusRef.current === 'CanLoadMore') {
isLoadingMoreRef.current = true;
@@ -1090,11 +1352,39 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
}
loadMoreRef.current(50);
}
}, []);
}, [focusedHasOlder, decryptedMessages, channelId, currentUserId]);
const handleEndReached = useCallback(() => {
if (!focusedModeRef.current || !focusedHasNewer || focusedLoadingMoreRef.current) return;
const msgs = decryptedMessages;
if (!msgs.length) return;
const newestTimestamp = new Date(msgs[msgs.length - 1].created_at).getTime();
focusedLoadingMoreRef.current = true;
(async () => {
try {
const result = await convex.query(api.messages.listAfter, {
channelId,
afterTimestamp: newestTimestamp,
userId: currentUserId || undefined,
});
setFocusedMessages(prev => {
const existingIds = new Set(prev.map(m => m.id));
const newMsgs = result.messages.filter(m => !existingIds.has(m.id));
return [...prev, ...newMsgs];
});
setFocusedHasNewer(result.hasMore);
} catch (err) {
console.error('Failed to load newer messages:', err);
} finally {
focusedLoadingMoreRef.current = false;
}
})();
}, [focusedHasNewer, decryptedMessages, channelId, currentUserId]);
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
const followOutput = useCallback((isAtBottom) => {
if (focusedModeRef.current) return false;
const metrics = {
isAtBottom,
userScrolledUp: userIsScrolledUpRef.current,
@@ -1143,6 +1433,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
// Virtuoso: atBottomStateChange replaces manual scroll listener for read state
const handleAtBottomStateChange = useCallback((atBottom) => {
if (focusedModeRef.current) return;
scrollLog('[SCROLL:atBottomStateChange]', { atBottom, settling: loadMoreSettlingRef.current, userScrolledUp: userIsScrolledUpRef.current });
if (loadMoreSettlingRef.current && atBottom) {
return;
@@ -1593,6 +1884,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
}
if (!messageContent && pendingFiles.length === 0) return;
// Exit focused mode when sending a message
if (focusedModeRef.current) {
setFocusedMode(false);
focusedModeRef.current = false;
setFocusedMessageId(null);
setFocusedMessages([]);
setFocusedHasOlder(false);
setFocusedHasNewer(false);
setFirstItemIndex(INITIAL_FIRST_INDEX);
prevMessageCountRef.current = 0;
prevFirstMsgIdRef.current = null;
}
// Intercept slash commands
if (messageContent.startsWith('/') && pendingFiles.length === 0) {
const parts = messageContent.slice(1).split(/\s+/);
@@ -1753,6 +2057,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
}, 300);
} else {
// Message not in current view — enter focused mode
setFocusedMessageId(messageId);
}
}, [decryptedMessages]);
@@ -1761,6 +2068,25 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } });
}, []);
const handleJumpToPresent = useCallback(() => {
setFocusedMode(false);
focusedModeRef.current = false;
setFocusedMessageId(null);
setFocusedMessages([]);
setFocusedHasOlder(false);
setFocusedHasNewer(false);
setFocusedLoading(false);
focusedLoadingMoreRef.current = false;
setDecryptedMessages([]);
decryptionDoneRef.current = false;
isInitialLoadRef.current = true;
initialScrollScheduledRef.current = false;
setFirstItemIndex(INITIAL_FIRST_INDEX);
prevMessageCountRef.current = 0;
prevFirstMsgIdRef.current = null;
userIsScrolledUpRef.current = false;
}, []);
const isDM = channelType === 'dm';
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
@@ -1790,12 +2116,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const renderListHeader = useCallback(() => {
return (
<>
{status === 'LoadingMore' && (
{(status === 'LoadingMore' || (focusedMode && focusedHasOlder)) && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
<div className="loading-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px' }} />
</div>
)}
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
{!focusedMode && status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
<div className="channel-beginning">
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
<h1 className="channel-beginning-title">
@@ -1811,7 +2137,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
)}
</>
);
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName]);
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName, focusedMode, focusedHasOlder]);
// Stable Virtuoso components — avoids remounting Header/Footer every render
const virtuosoComponents = useMemo(() => ({
@@ -1953,7 +2279,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
/>
<div className="messages-list">
{status === 'LoadingFirstPage' ? (
{((!focusedMode && status === 'LoadingFirstPage') || focusedLoading) ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
<div className="loading-spinner" />
</div>
@@ -1962,13 +2288,14 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
ref={virtuosoRef}
scrollerRef={(el) => { scrollerElRef.current = el; }}
firstItemIndex={firstItemIndex}
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
alignToBottom={true}
{...(!focusedMode ? { initialTopMostItemIndex: { index: 'LAST', align: 'end' } } : {})}
alignToBottom={!focusedMode}
atBottomThreshold={20}
data={allDisplayMessages}
startReached={handleStartReached}
followOutput={followOutput}
atBottomStateChange={handleAtBottomStateChange}
endReached={focusedMode ? handleEndReached : undefined}
followOutput={focusedMode ? false : followOutput}
atBottomStateChange={focusedMode ? undefined : handleAtBottomStateChange}
increaseViewportBy={{ top: 400, bottom: 400 }}
defaultItemHeight={60}
computeItemKey={(index, item) => item.id || `idx-${index}`}
@@ -1976,6 +2303,14 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)}
/>
)}
{focusedMode && !focusedLoading && (
<button className="jump-to-present-btn" onClick={handleJumpToPresent}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ marginRight: '6px' }}>
<path d="M8 12l-4.5-4.5 1.06-1.06L8 9.88l3.44-3.44 1.06 1.06z"/>
</svg>
Jump to Present
</button>
)}
</div>
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} isAttachment={contextMenu.isAttachment} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
{reactionPickerMsgId && (