feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
This commit is contained in:
748
packages/shared/src/components/channel/Messages.tsx
Normal file
748
packages/shared/src/components/channel/Messages.tsx
Normal file
@@ -0,0 +1,748 @@
|
||||
import { 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.
|
||||
const decryptionCache = new Map<string, string>();
|
||||
const MAX_CACHE = 2000;
|
||||
|
||||
function cacheSet(id: string, content: string) {
|
||||
if (decryptionCache.size >= MAX_CACHE) {
|
||||
const firstKey = decryptionCache.keys().next().value;
|
||||
if (firstKey !== undefined) decryptionCache.delete(firstKey);
|
||||
}
|
||||
decryptionCache.set(id, content);
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
// 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());
|
||||
|
||||
// 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(),
|
||||
);
|
||||
|
||||
// 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 () => {
|
||||
const next = new Map(decryptedMap);
|
||||
let changed = false;
|
||||
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 (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
|
||||
next.set(id, '[Invalid Encrypted Message]');
|
||||
changed = true;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (changed && !cancelled) setDecryptedMap(next);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pagedMessages, channelKeysByVersion, channelKey]);
|
||||
|
||||
// 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 () => {
|
||||
const next = new Map(replyPreviewMap);
|
||||
let changed = false;
|
||||
for (const msg of pagedMessages as any[]) {
|
||||
const id = msg.id as string;
|
||||
if (next.has(id)) continue;
|
||||
if (
|
||||
!msg.replyToContent ||
|
||||
!msg.replyToNonce ||
|
||||
msg.replyToContent.length < TAG_LENGTH
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const cacheKey = `reply:${id}`;
|
||||
const cached = decryptionCache.get(cacheKey);
|
||||
if (cached) {
|
||||
next.set(id, cached);
|
||||
changed = true;
|
||||
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;
|
||||
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(cacheKey, preview);
|
||||
next.set(id, preview);
|
||||
changed = true;
|
||||
} catch {
|
||||
/* leave unset — UI shows the missing-context fallback */
|
||||
}
|
||||
}
|
||||
if (changed && !cancelled) setReplyPreviewMap(next);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pagedMessages, channelKeysByVersion, channelKey]);
|
||||
|
||||
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]);
|
||||
|
||||
// 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, re-pin and scroll to bottom.
|
||||
useLayoutEffect(() => {
|
||||
pinnedRef.current = true;
|
||||
scrollAnchorRef.current = null;
|
||||
restoreActiveRef.current = false;
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [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;
|
||||
|
||||
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.
|
||||
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 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 () => {
|
||||
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;
|
||||
|
||||
// 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) {
|
||||
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.
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
|
||||
<div className={styles.scroller}>
|
||||
{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. `lastTs` tracks
|
||||
// the most-recent rendered item so two consecutive
|
||||
// items on the same day skip the divider.
|
||||
let lastTs: number | null = null;
|
||||
const out: React.ReactNode[] = [];
|
||||
for (const item of items) {
|
||||
if (lastTs === null || !isSameDay(lastTs, item.ts)) {
|
||||
out.push(
|
||||
<DayDivider key={`day-${item.ts}`} timestamp={item.ts} />,
|
||||
);
|
||||
}
|
||||
lastTs = item.ts;
|
||||
if (item.kind === 'group') {
|
||||
out.push(
|
||||
<MessageGroup
|
||||
key={item.key}
|
||||
messages={item.group as any}
|
||||
channelId={channelId}
|
||||
onReply={onReply}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
<div key={item.key} className={styles.pollWrapper}>
|
||||
<PollCard pollId={item.pollId} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user