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>
This commit is contained in:
2026-07-10 01:08:34 -04:00
co-authored by Claude Opus 4.8
parent 57f21e5cac
commit d6d1f5a233
9 changed files with 693 additions and 2 deletions
+91
View File
@@ -0,0 +1,91 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
filterThreads,
sortThreads,
isThreadFilter,
isThreadSort,
ThreadSnapshot,
} from './threadList';
const t = (
id: string,
latestTs: number,
unread = 0,
participated = false,
): ThreadSnapshot => ({ id, latestTs, unread, participated });
test('filterThreads all returns every thread', () => {
const input = [t('a', 1, 0, false), t('b', 2, 3, true)];
assert.deepEqual(
filterThreads(input, 'all').map((x) => x.id),
['a', 'b'],
);
});
test('filterThreads unread keeps only threads with unread > 0', () => {
const input = [t('a', 1, 0), t('b', 2, 2), t('c', 3, 0)];
assert.deepEqual(
filterThreads(input, 'unread').map((x) => x.id),
['b'],
);
});
test('filterThreads participating keeps only participated threads', () => {
const input = [t('a', 1, 0, true), t('b', 2, 0, false), t('c', 3, 0, true)];
assert.deepEqual(
filterThreads(input, 'participating').map((x) => x.id),
['a', 'c'],
);
});
test('sortThreads recent orders by latestTs descending', () => {
const input = [t('a', 100), t('b', 300), t('c', 200)];
assert.deepEqual(
sortThreads(input, 'recent').map((x) => x.id),
['b', 'c', 'a'],
);
});
test('sortThreads oldest orders by latestTs ascending', () => {
const input = [t('a', 100), t('b', 300), t('c', 200)];
assert.deepEqual(
sortThreads(input, 'oldest').map((x) => x.id),
['a', 'c', 'b'],
);
});
test('sortThreads breaks ties deterministically by id', () => {
const input = [t('z', 100), t('a', 100), t('m', 100)];
assert.deepEqual(
sortThreads(input, 'recent').map((x) => x.id),
['a', 'm', 'z'],
);
assert.deepEqual(
sortThreads(input, 'oldest').map((x) => x.id),
['a', 'm', 'z'],
);
});
test('filterThreads / sortThreads do not mutate input', () => {
const input = [t('a', 100), t('b', 300)];
const before = input.map((x) => x.id);
filterThreads(input, 'unread');
sortThreads(input, 'oldest');
assert.deepEqual(
input.map((x) => x.id),
before,
);
});
test('isThreadFilter / isThreadSort accept valid and reject junk', () => {
assert.equal(isThreadFilter('all'), true);
assert.equal(isThreadFilter('unread'), true);
assert.equal(isThreadFilter('participating'), true);
assert.equal(isThreadFilter('bogus'), false);
assert.equal(isThreadFilter(undefined), false);
assert.equal(isThreadSort('recent'), true);
assert.equal(isThreadSort('oldest'), true);
assert.equal(isThreadSort('nope'), false);
assert.equal(isThreadSort(3), false);
});