| 1 | import type { ThreadListEntry, ThreadUnread, TimelineEvent, TimelineItem } from './types'; |
| 2 | |
| 3 | export interface ThreadEntry { |
| 4 | room_id: string; |
| 5 | root_event_id: string; |
| 6 | preview: string; |
| 7 | sender: string; |
| 8 | reply_count: number; |
| 9 | latest_ms: number; |
| 10 | pinned: boolean; |
| 11 | unread: number; |
| 12 | mentions: number; |
| 13 | } |
| 14 | |
| 15 | function previewOf(event: TimelineEvent): string { |
| 16 | const content = event.content; |
| 17 | if (content.kind === 'Message') return content.message.media?.caption ?? content.message.body; |
| 18 | if (content.kind === 'Sticker') return content.body || 'Sticker'; |
| 19 | if (content.kind === 'Poll') return content.question; |
| 20 | return ''; |
| 21 | } |
| 22 | |
| 23 | function entryFor(roomId: string, event: TimelineEvent, pinned: boolean): ThreadEntry | null { |
| 24 | if (!event.event_id || event.thread_root) return null; |
| 25 | return { |
| 26 | room_id: roomId, |
| 27 | root_event_id: event.event_id, |
| 28 | preview: previewOf(event), |
| 29 | sender: event.sender.display_name ?? event.sender.user_id, |
| 30 | reply_count: event.thread_summary?.reply_count ?? 0, |
| 31 | latest_ms: event.thread_summary?.latest_event_timestamp_ms ?? event.timestamp_ms, |
| 32 | pinned, |
| 33 | unread: 0, |
| 34 | mentions: 0 |
| 35 | }; |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Pinned threads first, then by latest activity. The server's thread list wins over roots seen in the |
| 40 | * loaded timeline, and pins or unread roots that neither has loaded still get a row. |
| 41 | */ |
| 42 | export function roomThreads( |
| 43 | roomId: string, |
| 44 | items: TimelineItem[], |
| 45 | pinnedRoots: string[], |
| 46 | listed: ThreadListEntry[] = [], |
| 47 | unreads: Record<string, ThreadUnread> = {} |
| 48 | ): ThreadEntry[] { |
| 49 | const pinned = new Set(pinnedRoots); |
| 50 | const found = new Map<string, ThreadEntry>(); |
| 51 | for (const item of items) { |
| 52 | if (item.kind !== 'event') continue; |
| 53 | const id = item.event.event_id; |
| 54 | if (!id || (!pinned.has(id) && (item.event.thread_summary?.reply_count ?? 0) === 0)) continue; |
| 55 | const entry = entryFor(roomId, item.event, pinned.has(id)); |
| 56 | if (entry) found.set(id, entry); |
| 57 | } |
| 58 | for (const listedEntry of listed) { |
| 59 | const entry = entryFor(roomId, listedEntry.root, pinned.has(listedEntry.root_event_id)); |
| 60 | if (!entry) continue; |
| 61 | const loaded = found.get(listedEntry.root_event_id); |
| 62 | found.set(listedEntry.root_event_id, { |
| 63 | ...entry, |
| 64 | reply_count: loaded?.reply_count || listedEntry.reply_count, |
| 65 | latest_ms: Math.max(loaded?.latest_ms ?? 0, listedEntry.latest_activity_ms) |
| 66 | }); |
| 67 | } |
| 68 | for (const root of new Set([...pinnedRoots, ...Object.keys(unreads)])) { |
| 69 | if (!found.has(root)) { |
| 70 | found.set(root, { room_id: roomId, root_event_id: root, preview: '', sender: '', reply_count: 0, latest_ms: 0, pinned: pinned.has(root), unread: 0, mentions: 0 }); |
| 71 | } |
| 72 | } |
| 73 | for (const [root, unread] of Object.entries(unreads)) { |
| 74 | const entry = found.get(root); |
| 75 | if (entry) found.set(root, { ...entry, unread: unread.messages, mentions: unread.mentions }); |
| 76 | } |
| 77 | return [...found.values()].sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.latest_ms - a.latest_ms); |
| 78 | } |
| 79 | |
| 80 | export function threadMatches(entry: ThreadEntry, query: string): boolean { |
| 81 | if (!query) return true; |
| 82 | return entry.preview.toLowerCase().includes(query) || entry.sender.toLowerCase().includes(query); |
| 83 | } |