Files
cinny/src/app/features/room/thread/ThreadsListPanel.tsx
T

394 lines
13 KiB
TypeScript
Raw Normal View History

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