Files
cinny/src/app/utils/threadList.ts
T
jaredandClaude Opus 4.8 d6d1f5a233 feat(threads): room-level Threads list panel
Lotus could view one thread at a time but had no overview of a room's
threads. Add a Threads list side panel, opened from a new Threads toggle
in the room header (mirrors the gallery/widgets toggles).

- Lists every thread with a rich row: root sender + snippet, unread dot,
  "N replies - last reply <time>", and a participant avatar pile.
- Segmented filter (All / Unread / Participating) and sort (Recent /
  Oldest by last-reply time), both persisted in localStorage
  (cinny_threads_filter_v1 / cinny_threads_sort_v1) and normalized via
  type guards.
- Clicking a row opens the existing single-thread ThreadPanel by reusing
  setActiveThreadId; reading it clears the row's unread badge live.
- Stays live via ThreadEvent.New/NewReply/Update/Delete +
  RoomEvent.UnreadNotifications, with a signature guard to avoid churn,
  and is virtualized (@tanstack/react-virtual) for busy rooms.

Reuses room.getThreads()/fetchRoomThreads(), thread.hasCurrentUser-
Participated / lastReply() / length, getThreadUnreadNotificationCount
(muted threads zeroed), useMemberAvatar/StackedAvatar/UserAvatar,
scaleSystemEmoji/trimReplyFromBody, UnreadBadge, and the Bookmarks-panel
segmented-control + localStorage-atom patterns. Filter/sort logic is pure
in utils/threadList.ts with 8 unit tests. New panel is wired into
Room.tsx's mutually-exclusive content-panel switching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:08:34 -04:00

45 lines
1.8 KiB
TypeScript

// Pure filter/sort logic for the Threads list panel. Operates on plain snapshots
// so it's unit-testable without real matrix-js-sdk Thread objects.
export type ThreadFilter = 'all' | 'unread' | 'participating';
export type ThreadSort = 'recent' | 'oldest';
export type ThreadSnapshot = {
id: string;
latestTs: number;
unread: number;
participated: boolean;
};
const THREAD_FILTERS: readonly ThreadFilter[] = ['all', 'unread', 'participating'];
const THREAD_SORTS: readonly ThreadSort[] = ['recent', 'oldest'];
/** Type guard for persisted/untrusted filter values (localStorage can hold junk). */
export function isThreadFilter(value: unknown): value is ThreadFilter {
return typeof value === 'string' && (THREAD_FILTERS as readonly string[]).includes(value);
}
/** Type guard for persisted/untrusted sort values. */
export function isThreadSort(value: unknown): value is ThreadSort {
return typeof value === 'string' && (THREAD_SORTS as readonly string[]).includes(value);
}
/** Keep only the threads matching the filter. Pure — returns a new array. */
export function filterThreads<T extends ThreadSnapshot>(threads: T[], filter: ThreadFilter): T[] {
if (filter === 'unread') return threads.filter((t) => t.unread > 0);
if (filter === 'participating') return threads.filter((t) => t.participated);
return [...threads];
}
/**
* Order threads by last activity. `recent` = newest first, `oldest` = oldest first.
* Ties broken by id for stable, deterministic output. Pure — returns a new array.
*/
export function sortThreads<T extends ThreadSnapshot>(threads: T[], sort: ThreadSort): T[] {
const idCmp = (a: T, b: T): number => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
return [...threads].sort((a, b) => {
const diff = sort === 'oldest' ? a.latestTs - b.latestTs : b.latestTs - a.latestTs;
return diff !== 0 ? diff : idCmp(a, b);
});
}