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( 'cinny_threads_filter_v1', 'all', createJSONStorage(() => localStorage), { getOnInit: true }, ); const threadSortAtom = atomWithStorage( '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 ( ); } function ParticipantAvatar({ room, userId }: { room: Room; userId: string }) { const { name, avatarUrl } = useMemberAvatar(room, userId); return ( } /> ); } 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 ( onOpen(thread.id)} aria-label={ariaLabel} > {nameInitials(rootName)}} /> {rootName} {unread > 0 && ( 0} count={unread} /> )} {snippet} {count} {count === 1 ? 'reply' : 'replies'} {typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''} {participants.slice(0, MAX_PARTICIPANTS).map((userId) => ( ))} {extra > 0 && ( +{extra} )} ); } 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(); const highlightMap = new Map(); const partsMap = new Map(); const byId = new Map(); 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(); 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(null) as React.RefObject; 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 (
Threads {room.name}
{FILTER_OPTIONS.map((opt) => ( setFilter(opt.value)} /> ))} {SORT_OPTIONS.map((opt) => ( setSort(opt.value)} /> ))} {visible.length === 0 ? ( {emptyMessage} ) : (
{virtualizer.getVirtualItems().map((vItem) => { const snap = visible[vItem.index]; const thread = threadById.get(snap.id); if (!thread) return null; return ( ); })}
)}
); }