1.1.00
All checks were successful
Build and Release / build-and-release (push) Successful in 20m36s

This commit is contained in:
Bryan1029384756
2026-04-16 20:14:27 -05:00
parent 56a12fdf3e
commit 6813bb40dc
40 changed files with 2228 additions and 387 deletions

View File

@@ -50,15 +50,34 @@ export interface DecryptedMessage {
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<string, string>();
const MAX_CACHE = 2000;
function cacheSet(id: string, content: string) {
function namespacedKey(userId: string | null, id: string): string {
return `${userId ?? 'anon'}:${id}`;
}
function cacheSet(userId: string | null, id: string, content: string) {
const key = namespacedKey(userId, id);
if (decryptionCache.size >= MAX_CACHE) {
const firstKey = decryptionCache.keys().next().value;
if (firstKey !== undefined) decryptionCache.delete(firstKey);
}
decryptionCache.set(id, content);
decryptionCache.set(key, content);
}
function cacheGet(userId: string | null, id: string): string | undefined {
return decryptionCache.get(namespacedKey(userId, id));
}
// 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 ─────────────────────────────────────────────
@@ -308,6 +327,54 @@ export function Messages({ channelId, onReply }: MessagesProps) {
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<number | null>(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<string, string> | null = null;
for (const msg of pagedMessages as any[]) {
const id = msg.id as string;
if (decryptedMap.has(id)) continue;
const cached = cacheGet(userId, id);
if (cached !== undefined) {
if (!next) next = new Map(decryptedMap);
next.set(id, cached);
changed = true;
}
}
if (changed && next) setDecryptedMap(next);
let replyChanged = false;
let nextReply: Map<string, string> | 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
@@ -369,59 +436,85 @@ export function Messages({ channelId, onReply }: MessagesProps) {
if (channelKeysByVersion.size === 0 || !pagedMessages) return;
let cancelled = false;
(async () => {
const next = new Map(decryptedMap);
let changed = false;
// 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;
};
const jobs: Job[] = [];
for (const msg of pagedMessages as any[]) {
const id = msg.id as string;
if (next.has(id)) continue;
const cached = decryptionCache.get(id);
if (cached) {
next.set(id, cached);
changed = true;
continue;
}
if (decryptedMap.has(id)) continue;
if (cacheGet(userId, id) !== undefined) continue;
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
next.set(id, '[Invalid Encrypted Message]');
changed = true;
jobs.push({ kind: 'sentinel', id, value: '[Invalid Encrypted Message]' });
continue;
}
// Pick the key matching this message's version. Messages
// that predate key rotation default to version 1. If the
// exact version is missing (shouldn't happen normally),
// fall back to the latest key and try that so we never
// lock the user out of their own history.
const msgVersion: number = msg.keyVersion ?? 1;
const keyForVersion =
channelKeysByVersion.get(msgVersion) ?? channelKey;
if (!keyForVersion) {
next.set(id, '[Unable to decrypt]');
changed = true;
jobs.push({ kind: 'sentinel', id, value: '[Unable to decrypt]' });
continue;
}
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH);
try {
const plaintext = await crypto.decryptData(
contentHex,
keyForVersion,
msg.nonce,
tag,
);
if (cancelled) return;
cacheSet(id, plaintext);
next.set(id, plaintext);
changed = true;
} catch {
next.set(id, '[Unable to decrypt]');
changed = true;
}
jobs.push({
kind: 'decrypt',
id,
contentHex: msg.ciphertext.slice(0, -TAG_LENGTH),
tag: msg.ciphertext.slice(-TAG_LENGTH),
nonce: msg.nonce,
key: keyForVersion,
});
}
if (changed && !cancelled) setDecryptedMap(next);
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 };
}
try {
const plaintext = await crypto.decryptData(
j.contentHex,
j.key,
j.nonce,
j.tag,
);
return { id: j.id, value: plaintext, cache: true };
} catch {
return { id: j.id, value: '[Unable to decrypt]', cache: false };
}
}),
);
if (cancelled) return;
const next = new Map(decryptedMap);
for (const r of results) {
if (r.cache) cacheSet(userId, r.id, r.value);
next.set(r.id, r.value);
}
setDecryptedMap(next);
})();
return () => {
cancelled = true;
};
}, [pagedMessages, channelKeysByVersion, channelKey]);
// `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`
@@ -431,11 +524,18 @@ export function Messages({ channelId, onReply }: MessagesProps) {
if (channelKeysByVersion.size === 0 || !pagedMessages) return;
let cancelled = false;
(async () => {
const next = new Map(replyPreviewMap);
let changed = false;
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 (next.has(id)) continue;
if (replyPreviewMap.has(id)) continue;
if (
!msg.replyToContent ||
!msg.replyToNonce ||
@@ -444,60 +544,78 @@ export function Messages({ channelId, onReply }: MessagesProps) {
continue;
}
const cacheKey = `reply:${id}`;
const cached = decryptionCache.get(cacheKey);
if (cached) {
next.set(id, cached);
changed = true;
continue;
}
if (cacheGet(userId, cacheKey) !== undefined) continue;
const replyVersion: number = msg.replyToKeyVersion ?? 1;
const keyForVersion =
channelKeysByVersion.get(replyVersion) ?? channelKey;
if (!keyForVersion) continue;
const tag = msg.replyToContent.slice(-TAG_LENGTH);
const contentHex = msg.replyToContent.slice(0, -TAG_LENGTH);
try {
const plaintext = await crypto.decryptData(
contentHex,
keyForVersion,
msg.replyToNonce,
tag,
);
if (cancelled) return;
// Strip JSON wrappers so the preview shows the
// human-readable text, not raw {"text":"…"} dumps.
let preview = plaintext;
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 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);
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 {
/* plain text */
return null;
}
cacheSet(cacheKey, preview);
next.set(id, preview);
}),
);
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;
} catch {
/* leave unset — UI shows the missing-context fallback */
}
}
if (changed && !cancelled) setReplyPreviewMap(next);
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]);
}, [pagedMessages, channelKeysByVersion, channelKey, userId]);
const decrypted: DecryptedMessage[] = useMemo(() => {
if (!pagedMessages) return [];
@@ -613,15 +731,102 @@ export function Messages({ channelId, onReply }: MessagesProps) {
const scrollAnchorRef = useRef<{ bottomOffset: number } | null>(null);
const restoreActiveRef = useRef(false);
// On channel switch, re-pin and scroll to bottom.
// 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;
const el = scrollerRef.current;
if (el) el.scrollTop = el.scrollHeight;
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
@@ -630,10 +835,14 @@ export function Messages({ channelId, onReply }: MessagesProps) {
const el = scrollerRef.current;
if (!el) return;
const onContentChange = () => {
// Pagination anchor wins over pinned — the user is clearly
// scrolled up and reading older context, so we must NOT
// slam them to the bottom.
// 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
@@ -654,6 +863,10 @@ export function Messages({ channelId, onReply }: MessagesProps) {
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 });
@@ -690,6 +903,10 @@ export function Messages({ channelId, onReply }: MessagesProps) {
);
return () => {
if (rafHandle !== null) {
cancelAnimationFrame(rafHandle);
rafHandle = null;
}
mutationObs.disconnect();
resizeObs?.disconnect();
window.removeEventListener('resize', onWindowResize);
@@ -713,12 +930,18 @@ export function Messages({ channelId, onReply }: MessagesProps) {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
pinnedRef.current = distanceFromBottom < 150;
// If the user has visibly moved away from the loader region
// (far enough down that another paginate-up can't trigger),
// clear the anchor. This lets the next near-top scroll start
// a fresh pagination cycle instead of chaining onto stale
// coords from a previous one.
if (scrollAnchorRef.current && el.scrollTop > 200) {
// 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;
}
@@ -726,7 +949,14 @@ export function Messages({ channelId, onReply }: MessagesProps) {
// 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.
if (el.scrollTop < 80 && status === 'CanLoadMore' && !scrollAnchorRef.current) {
// 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,
};
@@ -795,14 +1025,24 @@ export function Messages({ channelId, onReply }: MessagesProps) {
// 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 newest = items[items.length - 1].ts;
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, scheduleMarkRead]);
}, [items, decryptedMap, scheduleMarkRead]);
// Window visibility → when the tab comes back into focus, flush
// any pending mark-read so the sidebar dot disappears without
@@ -845,7 +1085,18 @@ export function Messages({ channelId, onReply }: MessagesProps) {
return (
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
<div className={styles.scroller}>
<div
className={styles.scroller}
style={{
// Veil: keep the list invisible until `ready` flips
// true (after decryption of the visible tail). The
// scroller is still laid out at opacity 0 so scroll
// math works — the user just doesn't see the pre-
// stabilised state that caused the visible jump.
opacity: ready ? 1 : 0,
transition: ready ? 'opacity 80ms linear' : 'none',
}}
>
{status === 'LoadingMore' && (
<div
style={{