1286 lines
45 KiB
TypeScript
1286 lines
45 KiB
TypeScript
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<string, string>();
|
|
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 (
|
|
<div className={styles.dayDivider} role="separator">
|
|
{formatDayDivider(timestamp)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className={styles.newDivider} role="separator">
|
|
<span className={styles.newDividerBadge}>New</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Messages({ channelId, onReply }: MessagesProps) {
|
|
const { crypto } = usePlatform();
|
|
const scrollerRef = useRef<HTMLDivElement>(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<string | null>(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<number, string>
|
|
>(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<number>(0);
|
|
// Debounce timer for batched mark-read flushes.
|
|
const markReadTimerRef = useRef<number | null>(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<number, string>();
|
|
for (const item of allKeys) {
|
|
try {
|
|
const bundleJson = await crypto.privateDecrypt(
|
|
privateKeyPem,
|
|
item.encrypted_key_bundle,
|
|
);
|
|
const parsed = JSON.parse(bundleJson) as Record<string, string>;
|
|
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<Map<string, string>>(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<Map<string, string>>(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<Map<string, string>>(
|
|
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;
|
|
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<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
|
|
// 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<HTMLElement>(
|
|
`[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<TimelineItem[]>(() => {
|
|
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 (
|
|
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
|
|
<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={{
|
|
padding: '8px 16px',
|
|
color: 'var(--text-tertiary)',
|
|
fontSize: 13,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Loading older messages…
|
|
</div>
|
|
)}
|
|
{/* 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' && (
|
|
<ChannelWelcomeSection
|
|
channelId={channelId}
|
|
channelName={channelDoc?.name}
|
|
channelType={channelDoc?.type}
|
|
/>
|
|
)}
|
|
{(() => {
|
|
// 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(
|
|
<DayDivider key={`day-${item.ts}`} timestamp={item.ts} />,
|
|
);
|
|
}
|
|
if (
|
|
!newLinePlaced &&
|
|
snapshot !== null &&
|
|
item.ts > snapshot &&
|
|
!isOwnGroup(item)
|
|
) {
|
|
out.push(<NewMessagesDivider key={`new-${item.key}`} />);
|
|
newLinePlaced = true;
|
|
}
|
|
lastTs = item.ts;
|
|
if (item.kind === 'group') {
|
|
out.push(
|
|
<MessageGroup
|
|
key={item.key}
|
|
messages={item.group as any}
|
|
channelId={channelId}
|
|
onReply={onReply}
|
|
onEditMessage={handleEditMessage}
|
|
/>,
|
|
);
|
|
} else {
|
|
out.push(
|
|
<div key={item.key} className={styles.pollWrapper}>
|
|
<PollCard pollId={item.pollId} />
|
|
</div>,
|
|
);
|
|
}
|
|
}
|
|
return out;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|