385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
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,
|
|||
|
|
Badge,
|
|||
|
|
Box,
|
|||
|
|
Button,
|
|||
|
|
Header,
|
|||
|
|
Icon,
|
|||
|
|
IconButton,
|
|||
|
|
Icons,
|
|||
|
|
Scroll,
|
|||
|
|
Text,
|
|||
|
|
color,
|
|||
|
|
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 { 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;
|
|||
|
|
participants: string[];
|
|||
|
|
onOpen: (threadId: string) => void;
|
|||
|
|
};
|
|||
|
|
function ThreadRow({ room, thread, unread, 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.lastReply()?.getTs() ?? rootEvent?.getTs();
|
|||
|
|
const extra = participants.length - MAX_PARTICIPANTS;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Box
|
|||
|
|
as="button"
|
|||
|
|
direction="Column"
|
|||
|
|
gap="100"
|
|||
|
|
onClick={() => onOpen(thread.id)}
|
|||
|
|
aria-label={`Open thread by ${rootName}`}
|
|||
|
|
style={{
|
|||
|
|
width: '100%',
|
|||
|
|
textAlign: 'left',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
padding: config.space.S300,
|
|||
|
|
borderRadius: config.radii.R300,
|
|||
|
|
background: color.SurfaceVariant.Container,
|
|||
|
|
border: 'none',
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<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 && (
|
|||
|
|
<Badge variant="Success" fill="Solid" radii="Pill" size="200" style={{ flexShrink: 0 }} />
|
|||
|
|
)}
|
|||
|
|
</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, participantsById, threadById } = useMemo(() => {
|
|||
|
|
const mutedSet = getMutedThreads(threadNotifications, room.roomId);
|
|||
|
|
const unreadMap = 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 rawUnread =
|
|||
|
|
room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Total) ?? 0;
|
|||
|
|
const unread = mutedSet.has(t.id) ? 0 : rawUnread;
|
|||
|
|
unreadMap.set(t.id, unread);
|
|||
|
|
|
|||
|
|
// Participant senders: root first, then timeline order, deduped.
|
|||
|
|
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()));
|
|||
|
|
partsMap.set(t.id, parts);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
id: t.id,
|
|||
|
|
latestTs: t.lastReply()?.getTs() ?? t.rootEvent?.getTs() ?? 0,
|
|||
|
|
unread,
|
|||
|
|
participated: t.hasCurrentUserParticipated,
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
const visibleSnaps = sortThreads(filterThreads(snaps, filter), sort);
|
|||
|
|
return {
|
|||
|
|
visible: visibleSnaps,
|
|||
|
|
unreadById: unreadMap,
|
|||
|
|
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 message’s “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}
|
|||
|
|
participants={participantsById.get(snap.id) ?? []}
|
|||
|
|
onOpen={onOpenThread}
|
|||
|
|
/>
|
|||
|
|
</VirtualTile>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</Box>
|
|||
|
|
)}
|
|||
|
|
</Scroll>
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|