atagen

malice

public
Star0
Watch0
b39ecae834a0cd6670068d28a7b4afd0498f7964
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/thread-index.tsTypescript78 lines3.0 KB
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
    found.set(listedEntry.root_event_id, { ...entry, reply_count: listedEntry.reply_count, latest_ms: listedEntry.latest_activity_ms });
62
  }
63
  for (const root of new Set([...pinnedRoots, ...Object.keys(unreads)])) {
64
    if (!found.has(root)) {
65
      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 });
66
    }
67
  }
68
  for (const [root, unread] of Object.entries(unreads)) {
69
    const entry = found.get(root);
70
    if (entry) found.set(root, { ...entry, unread: unread.messages, mentions: unread.mentions });
71
  }
72
  return [...found.values()].sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.latest_ms - a.latest_ms);
73
}
74
75
export function threadMatches(entry: ThreadEntry, query: string): boolean {
76
  if (!query) return true;
77
  return entry.preview.toLowerCase().includes(query) || entry.sender.toLowerCase().includes(query);
78
}