import { useAction, useMutation, usePaginatedQuery, useQuery } from 'convex/react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { api } from '../../../../../convex/_generated/api'; import { usePlatform } from '../../platform'; import { MessageGroup } from './MessageGroup'; import { PollCard } from './PollCard'; import { ChannelWelcomeSection } from './ChannelWelcomeSection'; import type { Id } from '../../../../../convex/_generated/dataModel'; import styles from './Messages.module.css'; interface MessagesProps { channelId: string; onReply?: (eventId: string, username: string) => void; } import type { AttachmentMetadata } from './EncryptedAttachment'; export interface ReactionUser { userId: string; username: string; displayName: string | null; } export interface DecryptedMessage { id: string; channelId: string; senderId: string; authorName: string; authorAvatarUrl: string | null; authorRoleColor: string | null; content: string; timestamp: number; editedTimestamp: number | null; replyToId: string | null; replyToAuthorName: string | null; replyToContent: string | null; attachments: AttachmentMetadata[]; reactions: Array<{ emoji: string; count: number; me: boolean; users: ReactionUser[]; }>; pinned: boolean; } // Ciphertext format on disk is `content + tag` as hex, where the tag is // the last 32 hex chars (16 bytes of GCM auth tag). Must be split before // calling crypto.decryptData, which expects them as separate args. const TAG_LENGTH = 32; // Small LRU-ish cache for decrypted messages so re-renders don't redecrypt. // Entries are namespaced by the viewing user's id so decrypted plaintexts // from a previous login can't bleed into a different user on the same // page load (logout normally triggers a hard reload, but hot-reload in // dev and rare reload failures can skip that path). const decryptionCache = new Map(); const MAX_CACHE = 2000; // Cache keys are `user:id[:source]`. Passing `source` (the current // ciphertext) scopes cache hits to a specific version of the // message, so when messages.edit swaps the ciphertext a subsequent // fetch naturally misses and re-decrypts. The orphaned entry under // the old source ages out via MAX_CACHE LRU. function namespacedKey( userId: string | null, id: string, source?: string, ): string { return source ? `${userId ?? 'anon'}:${id}:${source}` : `${userId ?? 'anon'}:${id}`; } function cacheSet( userId: string | null, id: string, content: string, source?: string, ) { const key = namespacedKey(userId, id, source); if (decryptionCache.size >= MAX_CACHE) { const firstKey = decryptionCache.keys().next().value; if (firstKey !== undefined) decryptionCache.delete(firstKey); } decryptionCache.set(key, content); } function cacheGet( userId: string | null, id: string, source?: string, ): string | undefined { return decryptionCache.get(namespacedKey(userId, id, source)); } // Exposed for the logout hook to flush plaintext from memory proactively, // independently of the full-page reload useLogout does after this returns. export function clearDecryptionCache(): void { decryptionCache.clear(); } // ── Day divider helpers ───────────────────────────────────────────── // // Inserts a "Tuesday, April 7, 2026"-style separator between message // groups whenever the calendar day changes. Compares year/month/day // rather than the timestamp delta — two messages 23 hours apart can // still cross midnight. function isSameDay(a: number, b: number): boolean { const da = new Date(a); const db = new Date(b); return ( da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate() ); } const DAY_DIVIDER_FORMATTER = new Intl.DateTimeFormat(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); function formatDayDivider(ts: number): string { return DAY_DIVIDER_FORMATTER.format(new Date(ts)); } function DayDivider({ timestamp }: { timestamp: number }) { return (
{formatDayDivider(timestamp)}
); } /** * Red "NEW" line inserted between the last message the viewer has * already read and the first unseen one. Anchored to a snapshot of * `lastReadTimestamp` taken when the channel opened, so live new * messages arriving during the session don't push the divider * further down. */ function NewMessagesDivider() { return (
New
); } export function Messages({ channelId, onReply }: MessagesProps) { const { crypto } = usePlatform(); const scrollerRef = useRef(null); // Pending jump target — set by the `brycord:scroll-to-message` // listener when the message isn't rendered yet. A second effect // further down reacts to it: if the target is now in the DOM, // scroll to it; otherwise call `loadMore` to fetch another page // of history and try again on the next render. const pendingJumpRef = useRef(null); // channelKeysByVersion holds every key we have for this channel, // keyed by the server-stored keyVersion. A DM that's been rotated // will have multiple entries — old messages decrypt with the // version they were written under, new messages use the latest. const [channelKeysByVersion, setChannelKeysByVersion] = useState< Map >(new Map()); const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; const privateKeyPem = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('privateKey') : null; const allKeys = useQuery( api.channelKeys.getKeysForUser, userId ? { userId: userId as any } : 'skip', ); // ── Read-state + "NEW" divider plumbing ───────────────────────── // // `readState` is the server's live record of the latest message // timestamp this user has acknowledged for the current channel. // `readSnapshot` captures the value at the time the channel was // opened so the divider stays anchored in place even after we // flush a later `markRead` mutation during the session. Stored // as state (not a ref) so the first render after the query // resolves picks it up deterministically. const readState = useQuery( api.readState.getReadState, userId && channelId ? { userId: userId as any, channelId: channelId as any } : 'skip', ); const markRead = useMutation(api.readState.markRead); const [readSnapshot, setReadSnapshot] = useState<{ channelId: string; lastRead: number; } | null>(null); // Tracks the freshest timestamp we've observed in this channel so // the mark-read flush has something to send. Pure ref — updates // should never trigger a re-render. const latestSeenTimestampRef = useRef(0); // Debounce timer for batched mark-read flushes. const markReadTimerRef = useRef(null); // Reset the snapshot whenever the user changes channels. useLayoutEffect(() => { setReadSnapshot(null); latestSeenTimestampRef.current = 0; if (markReadTimerRef.current !== null) { window.clearTimeout(markReadTimerRef.current); markReadTimerRef.current = null; } }, [channelId]); // First time a non-null `readState` arrives for the current // channel, lock it in as the divider anchor. `null` (no stored // read state yet — brand-new channel) is treated as "0" so the // divider appears the moment anyone posts. useEffect(() => { if (!channelId) return; if (readSnapshot?.channelId === channelId) return; if (readState === undefined) return; setReadSnapshot({ channelId, lastRead: readState?.lastReadTimestamp ?? 0, }); }, [readState, channelId, readSnapshot]); /** Fire-and-forget mark-read flush. Gated by: * - an authenticated user * - a channel loaded * - the window being visible (otherwise we keep the snapshot * and the NEW line so the user sees it when they return) * - the user being pinned to the bottom of the scroller * - the server's stored timestamp being strictly older than * the freshest message we've observed */ const flushMarkRead = useCallback(() => { if (!userId || !channelId) return; if ( typeof document !== 'undefined' && document.visibilityState === 'hidden' ) { return; } if (!pinnedRef.current) return; const ts = latestSeenTimestampRef.current; if (!ts) return; const serverTs = readState?.lastReadTimestamp ?? 0; if (ts <= serverTs) return; void markRead({ userId: userId as any, channelId: channelId as any, lastReadTimestamp: ts, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [userId, channelId, readState, markRead]); const scheduleMarkRead = useCallback(() => { if (markReadTimerRef.current !== null) { window.clearTimeout(markReadTimerRef.current); } // 600ms debounce — batches bursts of new messages into a // single mutation without feeling laggy to users who watch // the sidebar unread dot. markReadTimerRef.current = window.setTimeout(() => { markReadTimerRef.current = null; flushMarkRead(); }, 600); }, [flushMarkRead]); // Walk every bundle we have, decrypt the ones tagged for this // channel, and build a {version → keyHex} map. A single bundle's // plaintext is a JSON object mapping channelId → keyHex (legacy // bundles can carry multiple channel ids), so we still merge by // channelId, then bucket by the row's `key_version`. useEffect(() => { let cancelled = false; if (!allKeys || !privateKeyPem) { setChannelKeysByVersion(new Map()); return; } (async () => { const next = new Map(); for (const item of allKeys) { try { const bundleJson = await crypto.privateDecrypt( privateKeyPem, item.encrypted_key_bundle, ); const parsed = JSON.parse(bundleJson) as Record; const keyForThisChannel = parsed[channelId]; if (keyForThisChannel) { next.set(item.key_version ?? 1, keyForThisChannel); } } catch (err) { console.error( `Failed to decrypt key bundle for ${item.channel_id}`, err, ); } } if (cancelled) return; setChannelKeysByVersion(next); })(); return () => { cancelled = true; }; }, [allKeys, privateKeyPem, channelId]); // Fallback "default" key — use the highest version we have. Needed // for legacy messages that were written before `keyVersion` was // tracked on messages (they come back without the field). const channelKey = useMemo(() => { if (channelKeysByVersion.size === 0) return null; let maxVersion = -Infinity; let latest: string | null = null; for (const [ver, key] of channelKeysByVersion) { if (ver > maxVersion) { maxVersion = ver; latest = key; } } return latest; }, [channelKeysByVersion]); const keyBundle = allKeys?.find((k) => k.channel_id === channelId) ?? null; const { results: pagedMessages, status, loadMore, } = usePaginatedQuery( api.messages.list, channelId ? { channelId: channelId as any, userId: (userId as any) ?? undefined } : 'skip', { initialNumItems: 50 }, ); const [decryptedMap, setDecryptedMap] = useState>(new Map()); // Fingerprint of each decrypted entry's source ciphertext. // `messages.edit` changes the ciphertext while keeping the id, // so the decrypt effects below gate on "has id AND source // matches" — without this, the stale plaintext would stay pinned // until the next reload. Populated from cache hits and successful // decrypts. Refs (not state) so staleness checks see the latest // value without a re-render in between. const plaintextSourceRef = useRef>(new Map()); // Reply previews — separate map keyed by the *child* message id so // the per-row render can grab the parent's plaintext without // re-decrypting on every paint. Filled by the effect below. const [replyPreviewMap, setReplyPreviewMap] = useState>( new Map(), ); // Initial-load veil: the scroller starts invisible on every channel // switch and reveals only once the last ~20 messages are decrypted // (or a hard fallback timeout fires). The reveal moment is also // when we perform the authoritative scroll-to-bottom — that way the // user never sees the pre-hydration state that used to cause the // viewport to visibly float upward as message bodies filled in. const [ready, setReady] = useState(false); const readyTimerRef = useRef(null); // Synchronous cache hydration. Runs as a layout effect so the // setState + re-render happens before paint: warm-cache channel // switches (the common case) render with real message text on the // first frame instead of briefly showing empty `content: ''` bodies // that then grow as the async effect below fills them in. That // growth was the primary cause of the "scrolls to bottom, then // jumps up" bug — no hydration wave, no jump. useLayoutEffect(() => { if (!pagedMessages || pagedMessages.length === 0) return; let changed = false; let next: Map | null = null; const sourceRef = plaintextSourceRef.current; for (const msg of pagedMessages as any[]) { const id = msg.id as string; const source = msg.ciphertext as string; // Already decrypted against this exact ciphertext — nothing // to hydrate. If the source has changed (edit), fall through // so we can try the cache keyed by the new source. if (decryptedMap.has(id) && sourceRef.get(id) === source) continue; const cached = cacheGet(userId, id, source); if (cached !== undefined) { if (!next) next = new Map(decryptedMap); next.set(id, cached); sourceRef.set(id, source); changed = true; } } if (changed && next) setDecryptedMap(next); let replyChanged = false; let nextReply: Map | null = null; for (const msg of pagedMessages as any[]) { const id = msg.id as string; if (replyPreviewMap.has(id)) continue; if (!msg.replyToContent) continue; const cached = cacheGet(userId, `reply:${id}`); if (cached !== undefined) { if (!nextReply) nextReply = new Map(replyPreviewMap); nextReply.set(id, cached); replyChanged = true; } } if (replyChanged && nextReply) setReplyPreviewMap(nextReply); // eslint-disable-next-line react-hooks/exhaustive-deps }, [pagedMessages, userId]); // Helper: try to scroll to whatever's in `pendingJumpRef`. If the // target is in the DOM we scroll + flash, clear the pending ref, // and we're done. Otherwise we kick off another paginate-up so // older history loads, then re-poll on the next render via the // effect below. const tryFulfillJump = useCallback(() => { const target = pendingJumpRef.current; if (!target) return; const scroller = scrollerRef.current; if (!scroller) return; const el = scroller.querySelector( `[data-message-id="${CSS.escape(target)}"]`, ); if (el) { pendingJumpRef.current = null; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('messageJumpHighlight'); window.setTimeout(() => { el.classList.remove('messageJumpHighlight'); }, 1600); return; } // Not in the DOM — load more older pages until either the // message appears or the listing is exhausted. if (status === 'CanLoadMore') { loadMore(50); } else if (status === 'Exhausted') { pendingJumpRef.current = null; } }, [status, loadMore]); // Listen for `brycord:scroll-to-message` window events fired by // reply previews, pin entries, and search results. useEffect(() => { const onScrollTo = (e: Event) => { const detail = (e as CustomEvent<{ channelId?: string; messageId?: string; }>).detail; if (!detail?.messageId) return; if (detail.channelId && detail.channelId !== channelId) return; pendingJumpRef.current = detail.messageId; tryFulfillJump(); }; window.addEventListener('brycord:scroll-to-message', onScrollTo); return () => window.removeEventListener('brycord:scroll-to-message', onScrollTo); }, [channelId, tryFulfillJump]); // Re-attempt the jump whenever a new page of messages lands. The // loader call above triggers a re-render with more rows, this // effect runs, and we either find the target or queue the next // paginate-up. useEffect(() => { if (pendingJumpRef.current) tryFulfillJump(); }, [pagedMessages, tryFulfillJump]); useEffect(() => { if (channelKeysByVersion.size === 0 || !pagedMessages) return; let cancelled = false; (async () => { // Walk once, bucket each message into either a synchronous // sentinel (invalid ciphertext, missing key) or an async // decrypt job. The synchronous hits are applied in the same // setState as the async results, so the UI never flashes // through an intermediate "some decrypted, some not" state. type Job = | { kind: 'sentinel'; id: string; value: string } | { kind: 'decrypt'; id: string; contentHex: string; nonce: string; tag: string; key: string; ciphertext: string; }; const jobs: Job[] = []; const sourceRef = plaintextSourceRef.current; for (const msg of pagedMessages as any[]) { const id = msg.id as string; const source = msg.ciphertext as string; // Gate on (has decrypted state) AND (source matches). // The source check is what lets edits re-decrypt: after // `messages.edit` swaps the ciphertext, the entry in // `plaintextSourceRef` still points at the old source, // so this branch falls through to queue a fresh decrypt. if (decryptedMap.has(id) && sourceRef.get(id) === source) continue; if (cacheGet(userId, id, source) !== undefined) continue; if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) { jobs.push({ kind: 'sentinel', id, value: '[Invalid Encrypted Message]' }); continue; } const msgVersion: number = msg.keyVersion ?? 1; const keyForVersion = channelKeysByVersion.get(msgVersion) ?? channelKey; if (!keyForVersion) { jobs.push({ kind: 'sentinel', id, value: '[Unable to decrypt]' }); continue; } jobs.push({ kind: 'decrypt', id, contentHex: msg.ciphertext.slice(0, -TAG_LENGTH), tag: msg.ciphertext.slice(-TAG_LENGTH), nonce: msg.nonce, key: keyForVersion, ciphertext: msg.ciphertext, }); } if (jobs.length === 0) return; // Fan out decrypts in parallel — 50 AES-GCM ops finish in // one round-trip to the crypto worker instead of 50 sequential // awaits. Collapses what used to be N setState waves (one // after each decrypt in the original serial loop) into one. const results = await Promise.all( jobs.map(async (j) => { if (j.kind === 'sentinel') { return { id: j.id, value: j.value, cache: false, source: null as string | null }; } try { const plaintext = await crypto.decryptData( j.contentHex, j.key, j.nonce, j.tag, ); return { id: j.id, value: plaintext, cache: true, source: j.ciphertext }; } catch { return { id: j.id, value: '[Unable to decrypt]', cache: false, source: null }; } }), ); if (cancelled) return; for (const r of results) { if (r.cache && r.source) cacheSet(userId, r.id, r.value, r.source); if (r.source) plaintextSourceRef.current.set(r.id, r.source); } setDecryptedMap((prev) => { const next = new Map(prev); for (const r of results) next.set(r.id, r.value); return next; }); })(); return () => { cancelled = true; }; // `userId` gates the cacheGet/cacheSet namespace below; without // it a quick logout/login in the same tab would surface the // previous user's cached plaintext under the new identity. // eslint-disable-next-line react-hooks/exhaustive-deps }, [pagedMessages, channelKeysByVersion, channelKey, userId]); // Reply preview decryption — same pattern as the main loop but // keyed by child message id and using the parent's `replyToContent` // + `replyToNonce` + `replyToKeyVersion` fields. Cached so a // re-render of pagedMessages doesn't redecrypt every preview. useEffect(() => { if (channelKeysByVersion.size === 0 || !pagedMessages) return; let cancelled = false; (async () => { type Job = { id: string; cacheKey: string; contentHex: string; nonce: string; tag: string; key: string; }; const jobs: Job[] = []; for (const msg of pagedMessages as any[]) { const id = msg.id as string; if (replyPreviewMap.has(id)) continue; if ( !msg.replyToContent || !msg.replyToNonce || msg.replyToContent.length < TAG_LENGTH ) { continue; } const cacheKey = `reply:${id}`; if (cacheGet(userId, cacheKey) !== undefined) continue; const replyVersion: number = msg.replyToKeyVersion ?? 1; const keyForVersion = channelKeysByVersion.get(replyVersion) ?? channelKey; if (!keyForVersion) continue; jobs.push({ id, cacheKey, contentHex: msg.replyToContent.slice(0, -TAG_LENGTH), tag: msg.replyToContent.slice(-TAG_LENGTH), nonce: msg.replyToNonce, key: keyForVersion, }); } if (jobs.length === 0) return; // Parallel decrypt — collapses the per-message setState waves // of the original serial loop into one, so the reply-preview // layer can't produce a second reflow after the main body // decryption already shifted heights. const results = await Promise.all( jobs.map(async (j) => { try { const plaintext = await crypto.decryptData( j.contentHex, j.key, j.nonce, j.tag, ); // Strip JSON wrappers so the preview shows the // human-readable text, not raw {"text":"…"} dumps. let preview = plaintext; try { const parsed = JSON.parse(plaintext); if (parsed && typeof parsed === 'object') { if (Array.isArray(parsed)) { preview = parsed .filter((p: any) => p?.type === 'attachment') .map((p: any) => p?.filename || 'attachment') .join(', '); } else if (parsed.type === 'attachment') { preview = parsed.filename || parsed.mimeType || 'Attachment'; } else if (parsed.text !== undefined) { preview = String(parsed.text); } } } catch { /* plain text */ } cacheSet(userId, j.cacheKey, preview); return { id: j.id, preview }; } catch { return null; } }), ); if (cancelled) return; const next = new Map(replyPreviewMap); let changed = false; for (const r of results) { if (r) { next.set(r.id, r.preview); changed = true; } } if (changed) setReplyPreviewMap(next); })(); return () => { cancelled = true; }; // Same userId cache-namespace concern as the main loop. // eslint-disable-next-line react-hooks/exhaustive-deps }, [pagedMessages, channelKeysByVersion, channelKey, userId]); const decrypted: DecryptedMessage[] = useMemo(() => { if (!pagedMessages) return []; return (pagedMessages as any[]) .slice() .reverse() .map((msg) => { const id = msg.id as string; const content = decryptedMap.get(id) ?? ''; const raw = (() => { try { return JSON.parse(content); } catch { return null; } })(); let text = content; const attachments: AttachmentMetadata[] = []; // Two legacy formats: // 1. Single object: { type: 'attachment', url, key, iv, ... } // 2. Array of attachment objects // 3. { text: '...' } wrapper from newer sends if (raw) { if (Array.isArray(raw)) { for (const item of raw) { if (item?.type === 'attachment' && item.url && item.key && item.iv) { attachments.push(item as AttachmentMetadata); } } text = ''; } else if (raw.type === 'attachment' && raw.url && raw.key && raw.iv) { attachments.push(raw as AttachmentMetadata); text = ''; } else if (raw.text !== undefined) { text = String(raw.text); } } // Reply preview comes from `replyPreviewMap`, populated // asynchronously by the dedicated decryption effect above. const replyPreview = replyPreviewMap.get(id) ?? null; // `msg.reactions` is either an array of {emoji,count,me,users} // (new server shape) or null. Older cached rows can still // surface a Record without `users` — fall back to an // empty users list in that case so the client never // crashes on forward-compat. const reactionsArray: DecryptedMessage['reactions'] = Array.isArray( msg.reactions, ) ? msg.reactions.map((r: any) => ({ emoji: String(r.emoji ?? ''), count: Number(r.count ?? 0), me: Boolean(r.me ?? false), users: Array.isArray(r.users) ? r.users.map((u: any) => ({ userId: String(u.userId ?? ''), username: String(u.username ?? 'Unknown'), displayName: u.displayName ?? null, })) : [], })) : msg.reactions && typeof msg.reactions === 'object' ? Object.entries(msg.reactions).map(([emoji, info]) => ({ emoji, count: (info as any).count ?? 0, me: (info as any).me ?? false, users: [], })) : []; return { id, channelId, senderId: msg.sender_id as string, authorName: msg.displayName || msg.username || 'User', authorAvatarUrl: msg.avatarUrl ?? null, authorRoleColor: msg.senderRoleColor ?? null, content: text, timestamp: msg.created_at ? new Date(msg.created_at).getTime() : Date.now(), editedTimestamp: msg.editedAt ?? null, replyToId: msg.replyToId ?? null, replyToAuthorName: msg.replyToDisplayName || msg.replyToUsername || null, replyToContent: replyPreview, attachments, reactions: reactionsArray, pinned: msg.pinned ?? false, } as DecryptedMessage; }); }, [pagedMessages, decryptedMap, replyPreviewMap, channelId]); // ── Edit flow ─────────────────────────────────────────────── // Mirrors the send path: we re-encrypt the new plaintext under // the message's *original* keyVersion key (so the server's // unchanged `keyVersion` still decrypts), re-sign the ciphertext, // and produce an `edit:...` auth signature for the action guard. // Bails silently if we no longer have the key for that version — // shouldn't happen in practice (rotations keep old entries) but // we'd rather cancel than corrupt a message. const editMessageAction = useAction(api.messageActions.edit); const handleEditMessage = useCallback( async (messageId: string, newText: string) => { if (!pagedMessages || !userId) throw new Error('Not ready.'); const signingKey = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('signingKey') : null; if (!signingKey) throw new Error('No signing key in session.'); const raw = (pagedMessages as any[]).find((m: any) => m.id === messageId); if (!raw) throw new Error('Message not in current page.'); const msgKeyVersion = Number(raw.key_version ?? 1); const key = channelKeysByVersion.get(msgKeyVersion); if (!key) throw new Error('Missing channel key for this message.'); // Messages with attachments wrap the caption as { text } — preserve // that envelope so the attachment list doesn't get dropped. Pure // text messages stay plain strings for forward-compat with older // decryption paths that don't `JSON.parse`. const hadAttachments = Array.isArray(raw?.attachments) && raw.attachments.length > 0; const payload = hadAttachments ? JSON.stringify({ text: newText }) : newText; const { content, iv, tag } = await crypto.encryptData(payload, key); const ciphertext = content + tag; const signature = await crypto.signMessage(signingKey, ciphertext); const authTimestamp = Date.now(); const authSignature = await crypto.signMessage( signingKey, `edit:${messageId}:${userId}:${authTimestamp}`, ); await editMessageAction({ id: messageId as any, userId: userId as any, ciphertext, nonce: iv, signature, authTimestamp, authSignature, }); }, [pagedMessages, userId, channelKeysByVersion, crypto, editMessageAction], ); // Pinned-to-bottom tracking. Matches the new UI's approach: a single // boolean in a ref, updated on every onScroll event via isNearBottom. // MutationObserver + ResizeObserver below drive all auto-scroll // behaviour based on this one bit. const pinnedRef = useRef(true); // Anchor for scroll-preservation on pagination. Instead of tracking // an absolute scrollTop we remember the distance between the top of // the oldest visible message and the bottom of the scroller (i.e. // `scrollHeight - scrollTop`). As long as this anchor is set, every // observed mutation re-pins scrollTop so that the same "bottom // offset" is preserved — the reader stays glued to whichever // message they were looking at when they scrolled into the loader // region, regardless of how many growth events the decryption // pipeline produces. // // The anchor is cleared when: // - The query reports no more pages (`status !== 'CanLoadMore'`) // - OR the next onScroll fires AFTER the user actually moves // the scroller (tracked via a "recently restored" flag) const scrollAnchorRef = useRef<{ bottomOffset: number } | null>(null); const restoreActiveRef = useRef(false); // On channel switch, reset all scroll-state refs and drop the veil // (reveal gate). The actual scroll-to-bottom is deferred to a // separate effect that runs once the tail of the list is decrypted, // so we pin against the final rendered height instead of the // empty-content height that used to cause the visible jump. useLayoutEffect(() => { pinnedRef.current = true; scrollAnchorRef.current = null; restoreActiveRef.current = false; setReady(false); if (readyTimerRef.current !== null) { window.clearTimeout(readyTimerRef.current); } // Hard safety net: even if decryption stalls (missing key, // network flake), drop the veil so the user always sees *some* // channel state within ~400ms of switching. `[Unable to decrypt]` // sentinels render at stable height too, so this fallback is // safe — we aren't waiting for real text, we're waiting for // *anything* to be populated so heights are final. readyTimerRef.current = window.setTimeout(() => { readyTimerRef.current = null; setReady(true); }, 400); return () => { if (readyTimerRef.current !== null) { window.clearTimeout(readyTimerRef.current); readyTimerRef.current = null; } }; }, [channelId]); // Tail-ready detection: drop the veil once the newest ~20 messages // (the ones visible on the first screen) have entries in // `decryptedMap`. A message with an error sentinel (e.g. // `[Unable to decrypt]`) counts as ready because its height is // stable — we only care that heights won't grow after reveal. useLayoutEffect(() => { if (ready) return; if (!pagedMessages) return; const msgs = pagedMessages as any[]; if (msgs.length === 0) { setReady(true); return; } // pagedMessages is newest-first (the `decrypted` memo reverses // it for render). The visible tail is the first ~20 entries. const tailCount = Math.min(20, msgs.length); for (let i = 0; i < tailCount; i++) { if (!decryptedMap.has(msgs[i].id)) return; } setReady(true); }, [ready, pagedMessages, decryptedMap]); // Scroll-to-bottom + stubborn-bottom settle. Runs once `ready` flips // true on the current channel. The initial rAF-delayed pin absorbs // the reveal-frame layout; the later timeouts form a "stubborn // bottom" window that keeps re-pinning for 500ms after reveal to // catch late-loading content (OG images, cross-origin canvas taints // in PausedGif, anything the observer's ResizeObserver misses // because the placeholder and final content have identical boxes). // The `pinnedRef` guard means a user who scrolls up during the // settle window is never dragged back down. useLayoutEffect(() => { if (!ready) return; const el = scrollerRef.current; if (!el) return; // Immediate pre-paint pin so the first painted frame is at the // bottom. Everything after this is belt-and-suspenders. el.scrollTop = el.scrollHeight; const pin = () => { if (!pinnedRef.current) return; const cur = scrollerRef.current; if (!cur) return; restoreActiveRef.current = true; cur.scrollTop = cur.scrollHeight; requestAnimationFrame(() => { restoreActiveRef.current = false; }); }; const rafs: number[] = []; const timers: number[] = []; rafs.push( requestAnimationFrame(() => { pin(); rafs.push(requestAnimationFrame(pin)); }), ); timers.push(window.setTimeout(pin, 80)); timers.push(window.setTimeout(pin, 250)); timers.push(window.setTimeout(pin, 500)); return () => { rafs.forEach((h) => cancelAnimationFrame(h)); timers.forEach((h) => window.clearTimeout(h)); }; }, [ready, channelId]); // Observe DOM mutations + resizes inside the scroller. If the user // is pinned to bottom, snap to bottom on every content change. If // the user triggered a paginate-up, preserve their position by // adjusting scrollTop against the height delta. useEffect(() => { const el = scrollerRef.current; if (!el) return; // Single rAF-batched handler so MutationObserver + ResizeObserver + // window resize + attachment-loaded events all collapse into one // scroll write per frame. Without batching, an image loading could // fire both observers in the same tick and cause a double // `scrollTop = scrollHeight`. let rafHandle: number | null = null; const runContentChange = () => { rafHandle = null; const anchor = scrollAnchorRef.current; if (anchor) { // Restore the same distance-from-bottom on every // mutation until the anchor is cleared. This handles // the case where decryption triggers multiple rounds // of height growth after a single loadMore call. restoreActiveRef.current = true; el.scrollTop = Math.max(0, el.scrollHeight - anchor.bottomOffset); // Flip the flag off on the next tick so a real user // scroll after this restore can still update pinned // tracking without being mistaken for our own restore. requestAnimationFrame(() => { restoreActiveRef.current = false; }); return; } if (pinnedRef.current) { el.scrollTop = el.scrollHeight; } }; const onContentChange = () => { if (rafHandle !== null) return; rafHandle = requestAnimationFrame(runContentChange); }; const mutationObs = new MutationObserver(onContentChange); mutationObs.observe(el, { childList: true, subtree: true }); // ResizeObserver catches async content like attachments loading // after the DOM is already in place (images decoding, blob URLs // resolving, emoji images flushing). Also observes the scroller // itself so window resizes (e.g. dragging the bottom edge up) // keep the bottom-pinned position locked instead of clipping // the latest messages. const content = el.firstElementChild; let resizeObs: ResizeObserver | undefined; if (content) { resizeObs = new ResizeObserver(onContentChange); resizeObs.observe(content); resizeObs.observe(el); } // Final safety net for non-ResizeObserver-friendly resizes // (split-pane drags that don't trigger an element resize) — // listen for window resize and re-run the pin check too. const onWindowResize = () => onContentChange(); window.addEventListener('resize', onWindowResize); // Image / video / audio attachments fire this once their // decoded bytes are painted. Re-runs the same pin-to-bottom // path so the scroll anchor catches the late layout shift // even when the placeholder + final image have identical // box dimensions (the ResizeObserver wouldn't fire then). const onAttachmentLoaded = () => onContentChange(); window.addEventListener( 'brycord:attachment-loaded', onAttachmentLoaded, ); return () => { if (rafHandle !== null) { cancelAnimationFrame(rafHandle); rafHandle = null; } mutationObs.disconnect(); resizeObs?.disconnect(); window.removeEventListener('resize', onWindowResize); window.removeEventListener( 'brycord:attachment-loaded', onAttachmentLoaded, ); }; }, [channelId]); const handleScroll = useCallback(() => { const el = scrollerRef.current; if (!el) return; // Ignore scroll events triggered by our own anchor-restore in // the observer. Without this guard, the restore would look // like a user scroll and nuke the anchor before the next // mutation arrives. if (restoreActiveRef.current) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; pinnedRef.current = distanceFromBottom < 150; // Clear the anchor in two cases: // 1. User scrolled well past the loader region (>200px) — a // fresh near-top scroll should start a new pagination cycle. // 2. Pagination is no longer loading-more (either CanLoadMore // has been re-armed or we've Exhausted). Without this, // decryption-driven reflows in the 80-200px band would keep // firing anchor-restore and make the scroll feel sticky // after older history loaded. if ( scrollAnchorRef.current && (el.scrollTop > 200 || status !== 'LoadingMore') ) { scrollAnchorRef.current = null; } // Load older messages when the user nears the top. Capture // the current distance-from-bottom into the anchor ref BEFORE // firing loadMore so the content-change observer can pin the // viewport to the same message the reader was looking at. // Guard against duplicate loadMore calls while an earlier one // is still in flight (`LoadingMore`) — Convex would de-dupe but // it's wasted traffic. if ( el.scrollTop < 80 && status === 'CanLoadMore' && !scrollAnchorRef.current ) { scrollAnchorRef.current = { bottomOffset: el.scrollHeight - el.scrollTop, }; loadMore(50); } }, [status, loadMore]); const groups = useMemo(() => { const result: DecryptedMessage[][] = []; for (const msg of decrypted) { const last = result[result.length - 1]; if (last && last[last.length - 1].senderId === msg.senderId) { const gap = msg.timestamp - last[last.length - 1].timestamp; if (gap < 7 * 60 * 1000 && !msg.replyToId) { last.push(msg); continue; } } result.push([msg]); } return result; }, [decrypted]); // Channel info — used by the welcome header at the top of the // scroller so we can render either a DM intro or a "Welcome to // #name" block. Cheap reactive query, same as ChannelView. const channelDoc = useQuery( api.channels.get, channelId ? { id: channelId as Id<'channels'> } : 'skip', ); // Polls are independent Convex documents — fetch every poll in // this channel and interleave them with the message groups by // creation timestamp. Poll counts are small per channel so // collecting them in one query is fine. const pollsInChannel = useQuery( api.polls.listByChannel, channelId ? { channelId: channelId as Id<'channels'> } : 'skip', ) ?? []; type TimelineItem = | { kind: 'group'; key: string; ts: number; group: DecryptedMessage[] } | { kind: 'poll'; key: string; ts: number; pollId: Id<'polls'> }; const items = useMemo(() => { const list: TimelineItem[] = groups.map((group) => ({ kind: 'group', key: `g-${group[0].id}`, ts: group[0].timestamp, group, })); for (const poll of pollsInChannel) { list.push({ kind: 'poll', key: `p-${poll._id}`, ts: poll.createdAt, pollId: poll._id, }); } list.sort((a, b) => a.ts - b.ts); return list; }, [groups, pollsInChannel]); // Whenever the timeline grows past the freshest timestamp we've // seen, update the ref and schedule a debounced mark-read. The // ref-only update doesn't cause re-renders — it just feeds the // mark-read flush with an up-to-date target. // // Only advance the "seen" mark once the newest message has actually // decrypted — otherwise the channel's unread dot + NEW divider // clear while the body is still rendering as blank, and users miss // new messages they never actually saw. useEffect(() => { if (items.length === 0) return; const last = items[items.length - 1]; if (last.kind === 'group') { const tail = last.group[last.group.length - 1]; if (!tail || !decryptedMap.has(tail.id)) return; } const newest = last.ts; if (newest > latestSeenTimestampRef.current) { latestSeenTimestampRef.current = newest; scheduleMarkRead(); } }, [items, decryptedMap, scheduleMarkRead]); // Window visibility → when the tab comes back into focus, flush // any pending mark-read so the sidebar dot disappears without // needing a new message to land. useEffect(() => { const onVisibility = () => { if (document.visibilityState === 'visible') { scheduleMarkRead(); } }; document.addEventListener('visibilitychange', onVisibility); window.addEventListener('focus', onVisibility); return () => { document.removeEventListener('visibilitychange', onVisibility); window.removeEventListener('focus', onVisibility); }; }, [scheduleMarkRead]); // Channel unmount (navigated elsewhere / logged out) → fire a // final mark-read with whatever the latest observed timestamp // is, bypassing the debounce. Matches Fluxer's "leaving a // channel marks it read" UX so the sidebar dot doesn't linger. useEffect(() => { return () => { if (markReadTimerRef.current !== null) { window.clearTimeout(markReadTimerRef.current); markReadTimerRef.current = null; } if (!userId || !channelId) return; const ts = latestSeenTimestampRef.current; if (!ts) return; void markRead({ userId: userId as any, channelId: channelId as any, lastReadTimestamp: ts, }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [channelId]); return (
{status === 'LoadingMore' && (
Loading older messages…
)} {/* Welcome header — only shown once we've loaded every page so the user actually sees the start of the history. While there are still older messages to fetch, the loading row above takes its place. */} {status === 'Exhausted' && ( )} {(() => { // Walk the timeline once, inserting a `DayDivider` // whenever the calendar day changes and a single // `NewMessagesDivider` before the first item whose // timestamp is strictly greater than the snapshot // of `lastReadTimestamp` taken when the channel // opened. `lastTs` tracks the most-recent rendered // item so two consecutive items on the same day // skip the date divider. // // The NEW line never shows for the viewer's own // messages — if you're the one that sent it, you // obviously already "saw" it. A message group // authored entirely by the current user is skipped // when looking for the divider boundary, so the // line stays anchored at the first message from // somebody else. let lastTs: number | null = null; const out: React.ReactNode[] = []; const snapshot = readSnapshot?.channelId === channelId ? readSnapshot.lastRead : null; let newLinePlaced = snapshot === null; const isOwnGroup = (item: TimelineItem): boolean => { if (item.kind !== 'group') return false; if (!userId) return false; return item.group.every((m) => m.senderId === userId); }; for (const item of items) { if (lastTs === null || !isSameDay(lastTs, item.ts)) { out.push( , ); } if ( !newLinePlaced && snapshot !== null && item.ts > snapshot && !isOwnGroup(item) ) { out.push(); newLinePlaced = true; } lastTs = item.ts; if (item.kind === 'group') { out.push( , ); } else { out.push(
, ); } } return out; })()}
); }