Files
cinny/src/app/features/room/thread/ThreadsListPanel.tsx
T
jaredandClaude Opus 4.8 85ac8de5d9 style: apply prettier across fork files
check:prettier was not part of my gate routine, so formatting drift accumulated
across the session's touched files (and a few older ones). Run prettier --write
to bring the repo back to 'All matched files use Prettier code style!'.
Formatting only — no logic changes. tsc/tests/build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:36 -04:00

383 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import { NotificationCountType, Room, Thread } from 'matrix-js-sdk';
import { Avatar, Box, Button, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
import classNames from 'classnames';
import { useVirtualizer } from '@tanstack/react-virtual';
import * as css from './ThreadsListPanel.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { VirtualTile } from '../../../components/virtualizer';
import { UserAvatar } from '../../../components/user-avatar';
import { StackedAvatar } from '../../../components/stacked-avatar';
import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge';
import { useMemberAvatar } from '../../../hooks/useMemberAvatar';
import { trimReplyFromBody } from '../../../utils/room';
import { scaleSystemEmoji } from '../../../plugins/react-custom-html-parser';
import { threadNotificationsAtom } from '../../../state/threadNotifications';
import { getMutedThreads } from '../../../utils/threadNotifications';
import { nameInitials } from '../../../utils/common';
import {
ThreadFilter,
ThreadSort,
ThreadSnapshot,
filterThreads,
sortThreads,
isThreadFilter,
isThreadSort,
} from '../../../utils/threadList';
import { useRoomThreads } from '../../../hooks/useRoomThreads';
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
// localStorage synchronously so the chosen filter/sort apply on first render.
const threadFilterAtom = atomWithStorage<ThreadFilter>(
'cinny_threads_filter_v1',
'all',
createJSONStorage(() => localStorage),
{ getOnInit: true },
);
const threadSortAtom = atomWithStorage<ThreadSort>(
'cinny_threads_sort_v1',
'recent',
createJSONStorage(() => localStorage),
{ getOnInit: true },
);
const FILTER_OPTIONS: { value: ThreadFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'unread', label: 'Unread' },
{ value: 'participating', label: 'Participating' },
];
const SORT_OPTIONS: { value: ThreadSort; label: string }[] = [
{ value: 'recent', label: 'Recent' },
{ value: 'oldest', label: 'Oldest' },
];
const MAX_PARTICIPANTS = 5;
function formatTimeAgo(ts: number): string {
const diff = Date.now() - ts;
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days === 1) return 'yesterday';
if (days < 7) return `${days}d ago`;
return new Date(ts).toLocaleDateString();
}
// Segmented button, mirroring the Bookmarks panel sort control for consistency.
function SegButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<Button
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Solid' : 'Soft'}
radii="300"
aria-pressed={active}
onClick={onClick}
>
<Text size="B300">{label}</Text>
</Button>
);
}
function ParticipantAvatar({ room, userId }: { room: Room; userId: string }) {
const { name, avatarUrl } = useMemberAvatar(room, userId);
return (
<StackedAvatar title={name} variant="SurfaceVariant" size="200" radii="Pill">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
}
type ThreadRowProps = {
room: Room;
thread: Thread;
unread: number;
highlight: number;
participants: string[];
onOpen: (threadId: string) => void;
};
function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: ThreadRowProps) {
const rootEvent = thread.rootEvent;
const rootSender = rootEvent?.getSender() ?? '';
const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender);
const bodyRaw =
typeof rootEvent?.getContent().body === 'string' ? (rootEvent.getContent().body as string) : '';
const snippet = bodyRaw ? scaleSystemEmoji(trimReplyFromBody(bodyRaw)) : '(no preview)';
const count = thread.length;
const lastTs = thread.replyToEvent?.getTs() ?? rootEvent?.getTs();
const extra = participants.length - MAX_PARTICIPANTS;
const replyLabel = `${count} ${count === 1 ? 'reply' : 'replies'}`;
const ariaLabel = `Open thread by ${rootName}${unread > 0 ? ', unread' : ''}, ${replyLabel}${
typeof lastTs === 'number' ? `, last reply ${formatTimeAgo(lastTs)}` : ''
}`;
return (
<Box
as="button"
direction="Column"
gap="100"
className={css.ThreadRow}
onClick={() => onOpen(thread.id)}
aria-label={ariaLabel}
>
<Box alignItems="Center" gap="200">
<Avatar size="200" radii="300">
<UserAvatar
userId={rootSender}
src={avatarUrl}
alt={rootName}
renderFallback={() => <Text size="H6">{nameInitials(rootName)}</Text>}
/>
</Avatar>
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
{rootName}
</Text>
{unread > 0 && (
<UnreadBadgeCenter>
<UnreadBadge highlight={highlight > 0} count={unread} />
</UnreadBadgeCenter>
)}
</Box>
<Text
size="T200"
priority="400"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
}}
>
{snippet}
</Text>
<Box alignItems="Center" gap="200">
<Icon size="50" src={Icons.Thread} />
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
{count} {count === 1 ? 'reply' : 'replies'}
{typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''}
</Text>
<Box shrink="No" alignItems="Center">
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
<ParticipantAvatar key={userId} room={room} userId={userId} />
))}
{extra > 0 && (
<Text size="T200" priority="300" style={{ marginLeft: config.space.S100 }}>
+{extra}
</Text>
)}
</Box>
</Box>
</Box>
);
}
export type ThreadsListPanelProps = {
room: Room;
onClose: () => void;
onOpenThread: (threadId: string) => void;
};
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
const threads = useRoomThreads(room);
const threadNotifications = useAtomValue(threadNotificationsAtom);
const [storedFilter, setFilter] = useAtom(threadFilterAtom);
const [storedSort, setSort] = useAtom(threadSortAtom);
const filter: ThreadFilter = isThreadFilter(storedFilter) ? storedFilter : 'all';
const sort: ThreadSort = isThreadSort(storedSort) ? storedSort : 'recent';
// Escape closes the panel (parity with the app's other drawers).
useEffect(() => {
const handleKeyDown = (evt: KeyboardEvent) => {
if (evt.key === 'Escape' && !evt.defaultPrevented) {
evt.preventDefault();
evt.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const { visible, unreadById, highlightById, participantsById, threadById } = useMemo(() => {
const mutedSet = getMutedThreads(threadNotifications, room.roomId);
const unreadMap = new Map<string, number>();
const highlightMap = new Map<string, number>();
const partsMap = new Map<string, string[]>();
const byId = new Map<string, Thread>();
const snaps: ThreadSnapshot[] = threads.map((t) => {
byId.set(t.id, t);
const muted = mutedSet.has(t.id);
const unread = muted
? 0
: (room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Total) ?? 0);
const highlight = muted
? 0
: (room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Highlight) ?? 0);
unreadMap.set(t.id, unread);
highlightMap.set(t.id, highlight);
// Participant senders: root first, then timeline order, plus the last
// replier (from the server bundle) so it's present even if not paginated
// into the loaded timeline yet. Deduped, order preserved.
const seen = new Set<string>();
const parts: string[] = [];
const push = (s?: string | null) => {
if (s && !seen.has(s)) {
seen.add(s);
parts.push(s);
}
};
push(t.rootEvent?.getSender());
t.timeline.forEach((e) => push(e.getSender()));
push(t.replyToEvent?.getSender());
partsMap.set(t.id, parts);
return {
id: t.id,
latestTs: t.replyToEvent?.getTs() ?? t.rootEvent?.getTs() ?? 0,
unread,
participated: t.hasCurrentUserParticipated,
};
});
const visibleSnaps = sortThreads(filterThreads(snaps, filter), sort);
return {
visible: visibleSnaps,
unreadById: unreadMap,
highlightById: highlightMap,
participantsById: partsMap,
threadById: byId,
};
}, [threads, room, filter, sort, threadNotifications]);
const scrollRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
const virtualizer = useVirtualizer({
count: visible.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 96,
overscan: 8,
getItemKey: (index) => visible[index].id,
});
const totalThreads = threads.length;
const emptyMessage =
totalThreads === 0
? 'No threads in this room yet. Start one from a messages “Reply in Thread”.'
: 'No threads match this filter.';
return (
<Box
className={classNames(css.ThreadsListPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
>
<Header className={css.ThreadsListHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Thread} size="200" />
<Box grow="Yes" direction="Column">
<Text size="H4" truncate>
Threads
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
</Text>
</Box>
<IconButton size="300" radii="300" aria-label="Close threads" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Box className={css.ThreadsListToolbar} direction="Column" gap="200">
<Box role="group" aria-label="Filter threads" gap="100" wrap="Wrap">
{FILTER_OPTIONS.map((opt) => (
<SegButton
key={opt.value}
label={opt.label}
active={filter === opt.value}
onClick={() => setFilter(opt.value)}
/>
))}
</Box>
<Box role="group" aria-label="Sort threads" gap="100">
{SORT_OPTIONS.map((opt) => (
<SegButton
key={opt.value}
label={opt.label}
active={sort === opt.value}
onClick={() => setSort(opt.value)}
/>
))}
</Box>
</Box>
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
{visible.length === 0 ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
style={{ padding: config.space.S700, textAlign: 'center' }}
>
<Icon size="600" src={Icons.Thread} style={{ opacity: config.opacity.Disabled }} />
<Text size="T300" priority="300" align="Center">
{emptyMessage}
</Text>
</Box>
) : (
<Box className={css.ThreadsListContent} direction="Column">
<div
style={{ position: 'relative', height: virtualizer.getTotalSize(), width: '100%' }}
>
{virtualizer.getVirtualItems().map((vItem) => {
const snap = visible[vItem.index];
const thread = threadById.get(snap.id);
if (!thread) return null;
return (
<VirtualTile
key={snap.id}
virtualItem={vItem}
ref={virtualizer.measureElement}
style={{ width: '100%', paddingBottom: config.space.S200 }}
>
<ThreadRow
room={room}
thread={thread}
unread={unreadById.get(snap.id) ?? 0}
highlight={highlightById.get(snap.id) ?? 0}
participants={participantsById.get(snap.id) ?? []}
onOpen={onOpenThread}
/>
</VirtualTile>
);
})}
</div>
</Box>
)}
</Scroll>
</Box>
</Box>
);
}