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

- 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:
Bryan1029384756
2026-04-14 09:02:14 -05:00
parent 9ef839938e
commit b7a4cf4ce8
376 changed files with 52619 additions and 167641 deletions

View 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>
);
}