This commit is contained in:
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user