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:
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user