atagen

malice

public
Star0
Watch0

Commit 668ab5c30531

tarball

atagen <boss@atagen.co>2026-10-25 03:17 UTC

10 files changed+927-10
mentions: add the Home inbox and saved context reader
Modified file: web/src/App.svelte+49 additions−5 deletions
@@ -3,6 +3,7 @@
33 import { fade, fly } from 'svelte/transition';
44 import LoginPanel from './lib/LoginPanel.svelte';
55 import MessageRow from './lib/MessageRow.svelte';
6+ import MentionsInbox from './lib/MentionsInbox.svelte';
67 import MxcAvatar from './lib/MxcAvatar.svelte';
78 import PinButton from './lib/PinButton.svelte';
89 import ContextMenu, { type MenuItem } from './lib/ContextMenu.svelte';
@@ -56,6 +57,7 @@
5657 encryption,
5758 membersByRoom,
5859 membersLoading,
60+ mentionsSnapshot,
5961 pushBanner,
6062 imagePacks,
6163 resetClientState,
@@ -157,6 +159,7 @@
157159 let profile: OwnProfile | null = null;
158160 let profileMxc: string | null = null;
159161 let selectedSpaceId: string | null = null;
162+ let mentionsOpen = false;
160163 let spaceChildIds: string[] | null = null;
161164 let spaceChildrenLoading = false;
162165 let spaceChildrenError: string | null = null;
@@ -300,11 +303,13 @@
300303 return r.unread_messages + r.unread_mentions;
301304 }
302305
303- function recordRoute(spaceId: string | null, roomId: string | null): void {
306+ function recordRoute(spaceId: string | null, roomId: string | null, view?: string): void {
304307 if (restoringRoute) return;
305308 const url = new URL(window.location.href);
306309 url.searchParams.delete('space');
307310 url.searchParams.delete('room');
311+ url.searchParams.delete('view');
312+ if (view) url.searchParams.set('view', view);
308313 if (spaceId) url.searchParams.set('space', spaceId);
309314 if (roomId) url.searchParams.set('room', roomId);
310315 const next = `${url.pathname}${url.search}${url.hash}`;
@@ -318,7 +323,9 @@
318323 const spaceId = url.searchParams.get('space');
319324 restoringRoute = true;
320325 try {
321- if (roomId && $rooms.some((room) => room.room_id === roomId && !room.space)) {
326+ if (url.searchParams.get('view') === 'mentions') {
327+ selectMentions();
328+ } else if (roomId && $rooms.some((room) => room.room_id === roomId && !room.space)) {
322329 selectedSpaceId = spaceId && $rooms.some((room) => room.room_id === spaceId && room.space) ? spaceId : null;
323330 spaceChildrenError = null;
324331 spaceChildIds = selectedSpaceId ? (await getSpaceChildren(selectedSpaceId)).room_ids ?? [] : null;
@@ -440,6 +447,7 @@
440447 sidebarOpen = false;
441448 profile = null;
442449 profileMxc = null;
450+ mentionsOpen = false;
443451 selectedSpaceId = null;
444452 spaceChildIds = null;
445453 spaceChildrenError = null;
@@ -494,6 +502,7 @@
494502 }
495503
496504 async function selectHome(): Promise<void> {
505+ mentionsOpen = false;
497506 selectedSpaceId = null;
498507 spaceChildIds = null;
499508 spaceChildrenError = null;
@@ -502,12 +511,31 @@
502511 recordRoute(null, null);
503512 }
504513
514+ function selectMentions(): void {
515+ if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
516+ stopTyping();
517+ selectedSpaceId = null;
518+ spaceChildIds = null;
519+ spaceChildrenError = null;
520+ spaceChildrenLoading = false;
521+ selectedRoomId.set(null);
522+ replyingTo = null;
523+ editing = null;
524+ mentionQuery = null;
525+ peeking = null;
526+ membersOpen = false;
527+ sidebarOpen = false;
528+ mentionsOpen = true;
529+ recordRoute(null, null, 'mentions');
530+ }
531+
505532 function toggleChannelsPane(): void {
506533 if (window.innerWidth <= 640) sidebarOpen = !sidebarOpen;
507534 else navFolded.update((v) => !v);
508535 }
509536
510537 async function selectSpace(spaceId: string): Promise<void> {
538+ mentionsOpen = false;
511539 if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
512540 stopTyping();
513541 selectedSpaceId = spaceId;
@@ -543,6 +571,7 @@
543571 }
544572
545573 async function selectRoom(roomId: string, peek = false): Promise<void> {
574+ mentionsOpen = false;
546575 peeking = peek ? roomId : null;
547576 if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
548577 if (roomId === $selectedRoomId && openReady[roomId]) {
@@ -1894,7 +1923,7 @@
18941923 <MxcAvatar mxc={selectedSpace.avatar} label={selectedSpace.name} size={36} shape="rounded" />
18951924 {/if}
18961925 <div class="head-room-text">
1897- <h1><EmojiText text={selectedSpace?.name ?? 'Home'} /></h1>
1926+ <h1><EmojiText text={mentionsOpen ? 'Mentions' : selectedSpace?.name ?? 'Home'} /></h1>
18981927 </div>
18991928 </div>
19001929 </div>
@@ -2068,6 +2097,19 @@
20682097 <span class="room-name"><EmojiText text={$rooms.find((r) => r.room_id === parentId)?.name ?? 'Parent space'} /></span>
20692098 </button>
20702099 {/if}
2100+ {#if !selectedSpaceId}
2101+ <ul class="room-list">
2102+ <li>
2103+ <button type="button" class="room-row" class:active={mentionsOpen} on:click={selectMentions}>
2104+ <svg width="26" height="26" viewBox="0 0 26 26" aria-hidden="true"><text x="13" y="19" text-anchor="middle" fill="currentColor" font-size="22">@</text></svg>
2105+ <span class="room-name">Mentions</span>
2106+ {#if $mentionsSnapshot && $mentionsSnapshot.unread_count > 0}
2107+ <span class="badge mention" aria-label={`${$mentionsSnapshot.unread_count} unread mentions`}>{$mentionsSnapshot.unread_count > 99 ? '99+' : $mentionsSnapshot.unread_count}</span>
2108+ {/if}
2109+ </button>
2110+ </li>
2111+ </ul>
2112+ {/if}
20712113 {#if roomsLoading && $rooms.length === 0}
20722114 <p class="side-note">Loading rooms…</p>
20732115 {:else if $rooms.length === 0}
@@ -2264,9 +2306,11 @@
22642306 {/each}
22652307 </div>
22662308 {/if}
2267- {#key $selectedRoomId ?? 'home'}
2309+ {#key mentionsOpen ? 'mentions' : $selectedRoomId ?? 'home'}
22682310 <div class="chat-view">
2269- {#if !selectedRoom}
2311+ {#if mentionsOpen && $sessionUser}
2312+ <MentionsInbox self={$sessionUser} onLive={(roomId) => void selectRoom(roomId)} />
2313+ {:else if !selectedRoom}
22702314 <div class="empty" in:fade={{ duration: motionDuration(180) }}>
22712315 <div class="empty-mark" aria-hidden="true">◈</div>
22722316 {#if selectedSpace}
Added file: web/src/lib/ContextTimeline.svelte+226 additions
@@ -0,0 +1,226 @@
1+<script lang="ts">
2+ import { onDestroy, onMount, tick } from 'svelte';
3+ import { eventContextAfter, eventContextBefore } from './api';
4+ import EmojiText from './EmojiText.svelte';
5+ import MessageRow from './MessageRow.svelte';
6+ import { formatDay } from './format';
7+ import { TimelineViewport } from './timeline-viewport';
8+ import type { EventContext, TimelineEvent, TimelineItem } from './types';
9+
10+ export let context: EventContext;
11+ export let roomId: string;
12+ export let eventId: string;
13+ export let roomName: string;
14+ export let self: string | null = null;
15+ export let canPaginate = true;
16+ export let onClose: () => void;
17+ export let onLive: (roomId: string) => void;
18+ export let closeLabel = 'Back to mentions';
19+
20+ let rows: TimelineItem[] = [];
21+ let beforeToken: string | null = null;
22+ let afterToken: string | null = null;
23+ let beforeEnded = false;
24+ let afterEnded = false;
25+ let beforeExtended = false;
26+ let afterExtended = false;
27+ let beforeBusy = false;
28+ let afterBusy = false;
29+ let error = '';
30+ let list: HTMLDivElement;
31+ let contentNode: HTMLDivElement;
32+ let backButton: HTMLButtonElement;
33+ let viewport: TimelineViewport | null = null;
34+ let observer: ResizeObserver | null = null;
35+ let generation = 0;
36+ let mounted = false;
37+ let alive = true;
38+ let identity = '';
39+ let centered = false;
40+
41+ const stable = (event: TimelineEvent) => event.event_id || event.item_id;
42+ const key = (item: TimelineItem) => item.kind === 'event'
43+ ? `event-${stable(item.event)}`
44+ : item.kind === 'day' ? `day-${item.timestamp_ms}` : item.kind;
45+ function normalize(items: TimelineItem[]): TimelineItem[] {
46+ const seen = new Set<string>();
47+ const result: TimelineItem[] = [];
48+ for (const item of items) {
49+ if (item.kind === 'readMarker' || item.kind === 'start' || item.kind === 'unknown') continue;
50+ if (item.kind === 'event') {
51+ const eventKey = stable(item.event);
52+ if (seen.has(eventKey)) continue;
53+ seen.add(eventKey);
54+ }
55+ result.push(item);
56+ }
57+ return result;
58+ }
59+ function merge(base: TimelineItem[], incoming: TimelineItem[]): TimelineItem[] {
60+ const events = new Map<string, TimelineItem>();
61+ const days = new Map<number, TimelineItem>();
62+ for (const item of [...base, ...incoming]) {
63+ if (item.kind === 'event') events.set(stable(item.event), item);
64+ else if (item.kind === 'day') days.set(item.timestamp_ms, item);
65+ }
66+ return [...events.values(), ...days.values()].sort((a, b) => timestamp(a) - timestamp(b));
67+ }
68+ function timestamp(item: TimelineItem): number {
69+ if (item.kind === 'event') return item.event.timestamp_ms;
70+ if (item.kind === 'day') return item.timestamp_ms;
71+ return 0;
72+ }
73+ function refresh(next: EventContext): void {
74+ const nextIdentity = `${roomId}\u0000${eventId}`;
75+ if (identity !== nextIdentity) {
76+ generation++;
77+ identity = nextIdentity;
78+ rows = [];
79+ beforeToken = null;
80+ afterToken = null;
81+ beforeEnded = false;
82+ afterEnded = false;
83+ beforeExtended = false;
84+ afterExtended = false;
85+ beforeBusy = false;
86+ afterBusy = false;
87+ error = '';
88+ centered = false;
89+ }
90+ const request = generation;
91+ viewport?.capture();
92+ const initial = normalize([...next.events_before, ...(next.event ? [next.event] : []), ...next.events_after]);
93+ rows = rows.length ? merge(rows, initial) : initial;
94+ if (!beforeExtended) {
95+ beforeToken = next.start_token;
96+ beforeEnded = !beforeToken;
97+ }
98+ if (!afterExtended) {
99+ afterToken = next.end_token;
100+ afterEnded = !afterToken;
101+ }
102+ tick().then(() => {
103+ if (!alive || request !== generation) return;
104+ viewport?.restore();
105+ });
106+ }
107+ $: if (context) {
108+ roomId;
109+ eventId;
110+ refresh(context);
111+ }
112+ $: if (mounted && list) {
113+ context;
114+ const anchorRoom = roomId;
115+ const anchorEvent = eventId;
116+ tick().then(() => {
117+ if (!alive || anchorRoom !== roomId || anchorEvent !== eventId || !context) return;
118+ if (!centered) {
119+ const target = list.querySelector<HTMLElement>('[data-target="true"]');
120+ target?.scrollIntoView({ block: 'center', behavior: 'auto' });
121+ centered = true;
122+ viewport?.capture();
123+ }
124+ });
125+ }
126+
127+ async function load(direction: 'before' | 'after'): Promise<void> {
128+ const token = direction === 'before' ? beforeToken : afterToken;
129+ if (!token || !canPaginate || (direction === 'before' ? beforeBusy : afterBusy)) return;
130+ const request = generation;
131+ if (direction === 'before') beforeBusy = true;
132+ else afterBusy = true;
133+ error = '';
134+ try {
135+ const requestedRoom = roomId;
136+ const requestedEvent = eventId;
137+ const page = direction === 'before'
138+ ? await eventContextBefore(requestedRoom, requestedEvent, token, 20)
139+ : await eventContextAfter(requestedRoom, requestedEvent, token, 20);
140+ if (!alive || request !== generation || requestedRoom !== roomId || requestedEvent !== eventId) return;
141+ viewport?.capture();
142+ rows = merge(rows, page.events);
143+ if (direction === 'before') {
144+ beforeExtended = true;
145+ beforeToken = page.next_token;
146+ beforeEnded = page.end_reached || !page.next_token;
147+ } else {
148+ afterExtended = true;
149+ afterToken = page.next_token;
150+ afterEnded = page.end_reached || !page.next_token;
151+ }
152+ await tick();
153+ if (alive && request === generation) viewport?.restore();
154+ } catch (cause) {
155+ if (alive && request === generation) error = cause instanceof Error ? cause.message : 'Could not load messages';
156+ } finally {
157+ if (alive && request === generation) {
158+ if (direction === 'before') beforeBusy = false;
159+ else afterBusy = false;
160+ }
161+ }
162+ }
163+
164+ function handleScroll(): void {
165+ if (!viewport?.consumeScroll()) viewport?.capture();
166+ }
167+ onDestroy(() => {
168+ alive = false;
169+ generation++;
170+ observer?.disconnect();
171+ viewport?.releaseScroll();
172+ });
173+ onMount(() => {
174+ mounted = true;
175+ tick().then(() => {
176+ if (!alive || !list) return;
177+ viewport = new TimelineViewport(list, roomId);
178+ observer = new ResizeObserver(() => viewport?.restore());
179+ if (contentNode) observer.observe(contentNode);
180+ backButton?.focus();
181+ });
182+ });
183+</script>
184+
185+<section class="context-timeline">
186+ <header>
187+ <button bind:this={backButton} onclick={onClose}><EmojiText text={closeLabel} /></button>
188+ <strong><EmojiText text={roomName} /></strong>
189+ <button disabled={!canPaginate} onclick={() => onLive(roomId)}>Go to current messages</button>
190+ </header>
191+ {#if error}<div class="error" role="alert"><EmojiText text={error} /></div>{/if}
192+ <button class="page-control" disabled={!beforeToken || beforeEnded || beforeBusy || !canPaginate} onclick={() => load('before')}>
193+ {beforeBusy ? 'Loading…' : 'Load earlier messages'}
194+ </button>
195+ <div class="messages" bind:this={list} onscroll={handleScroll}>
196+ <div class="content" bind:this={contentNode}>
197+ {#each rows as item (key(item))}
198+ {#if item.kind === 'day'}
199+ <div class="day"><span><EmojiText text={formatDay(item.timestamp_ms)} /></span></div>
200+ {:else if item.kind === 'event'}
201+ <div class:mentioned={item.event.event_id === eventId} aria-label={item.event.event_id === eventId ? 'Mentioned message' : undefined} data-target={item.event.event_id === eventId ? 'true' : undefined}>
202+ <MessageRow itemId={stable(item.event)} event={item.event} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} senderAvatar={item.event.sender.avatar} />
203+ </div>
204+ {/if}
205+ {/each}
206+ </div>
207+ </div>
208+ <button class="page-control" disabled={!afterToken || afterEnded || afterBusy || !canPaginate} onclick={() => load('after')}>
209+ {afterBusy ? 'Loading…' : 'Load later messages'}
210+ </button>
211+</section>
212+
213+<style>
214+ .context-timeline { display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; }
215+ header { display:flex; align-items:center; gap:.6rem; padding:.5rem; border-bottom:1px solid var(--border); flex-wrap:wrap; }
216+ header strong { flex:1; min-width:0; overflow-wrap:anywhere; }
217+ button { color:var(--text); background:var(--panel); border:1px solid var(--border); border-radius:.4rem; padding:.4rem .65rem; cursor:pointer; }
218+ button:disabled { opacity:.55; cursor:default; }
219+ .messages { flex:1; min-height:0; overflow:auto; overflow-anchor:none; padding:.5rem; }
220+ .content { min-width:0; }
221+ .mentioned { outline:2px solid var(--accent); border-radius:.5rem; }
222+ .day { display:flex; align-items:center; gap:.6rem; color:var(--muted); font-size:.8rem; margin:.75rem 0; }
223+ .day::before,.day::after { content:''; height:1px; background:var(--border); flex:1; }
224+ .page-control { align-self:center; margin:.4rem; }
225+ .error { color:var(--danger); padding:.5rem; overflow-wrap:anywhere; }
226+</style>
Added file: web/src/lib/MentionsInbox.svelte+484 additions
@@ -0,0 +1,484 @@
1+<script lang="ts">
2+ import { get } from 'svelte/store';
3+ import { onDestroy, onMount, tick } from 'svelte';
4+ import { mentionContext, mentionsPage, recoverMentions, retryMentionContext, setMentionsRead, markMentionsReadThrough } from './api';
5+ import ContextTimeline from './ContextTimeline.svelte';
6+ import EmojiText from './EmojiText.svelte';
7+ import MessageRow from './MessageRow.svelte';
8+ import { mentionsSnapshot, publishMentionsPage, rooms, sessionUser } from './store';
9+ import { TimelineViewport } from './timeline-viewport';
10+ import { ApiError } from './transport';
11+ import type { TimelineItem } from './types';
12+ import { mentionKey } from './mentions-model';
13+ import type { MentionEntry, MentionsPage, SavedMentionContext } from './mentions-model';
14+
15+ export let self: string;
16+ export let onLive: (roomId: string) => void;
17+
18+ let page: MentionsPage | null = null;
19+ let entries: MentionEntry[] = [];
20+ let selected: MentionEntry | null = null;
21+ let saved: SavedMentionContext | null = null;
22+ let list: HTMLDivElement;
23+ let viewport: TimelineViewport | null = null;
24+ let observer: ResizeObserver | null = null;
25+ let error = '';
26+ let busy = false;
27+ let actionBusy = false;
28+ let loadingContext = false;
29+ let mounted = false;
30+ let alive = true;
31+ let generation = 0;
32+ let contextGeneration = 0;
33+ let depth = 1;
34+ let pendingRevision = 0;
35+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
36+ let renderedAccount = '';
37+ let selectedRevision = -1;
38+ let contextKey = '';
39+ let contextRequest: { key: string; revision: number } | null = null;
40+ let queuedContext: MentionEntry | null = null;
41+ let pendingExplicitOpen = false;
42+ let savedListTop = 0;
43+ $: unreadCount = $mentionsSnapshot?.boundary.account === self ? $mentionsSnapshot.unread_count : page?.unread_count ?? 0;
44+ $: joinedRooms = new Map($rooms.filter((room) => room.membership === 'Joined').map((room) => [room.room_id, room]));
45+ $: selectedRoom = selected ? joinedRooms.get(selected.key.room_id) : undefined;
46+ $: contextCanPaginate = !!selectedRoom;
47+
48+ function itemEvent(item: TimelineItem) {
49+ return item.kind === 'event' ? item.event : null;
50+ }
51+ function roomName(entry: MentionEntry): string {
52+ return entry.room_name || joinedRooms.get(entry.key.room_id)?.name || entry.key.room_id;
53+ }
54+ function reasonLabel(reason: string): string {
55+ return reason === 'Direct' ? 'Direct mention' : reason === 'Reply' ? 'Reply' : 'Room mention';
56+ }
57+ function captureLabel(state: MentionEntry['capture']): string {
58+ if (state.kind === 'Failed') return state.message;
59+ return state.kind === 'Ready' ? 'Context saved' : state.kind === 'Partial' ? 'Partial context saved' : state.kind === 'Pending' ? 'Saving context' : 'Context unavailable';
60+ }
61+ function keyOf(entry: MentionEntry): string { return mentionKey(entry.key); }
62+ function bindViewport(node: HTMLDivElement) {
63+ list = node;
64+ viewport?.releaseScroll();
65+ observer?.disconnect();
66+ node.scrollTop = savedListTop;
67+ viewport = new TimelineViewport(node, null);
68+ const content = node.querySelector<HTMLElement>('.entry-content');
69+ observer = new ResizeObserver(() => viewport?.restore());
70+ if (content) observer.observe(content);
71+ void tick().then(() => {
72+ if (!alive || selected || list !== node) return;
73+ node.scrollTop = savedListTop;
74+ viewport?.capture();
75+ });
76+ return {
77+ destroy() {
78+ if (!selected) savedListTop = node.scrollTop;
79+ observer?.disconnect();
80+ observer = null;
81+ viewport?.releaseScroll();
82+ viewport = null;
83+ }
84+ };
85+ }
86+ function openContext(entry: MentionEntry): void {
87+ if (!selected) savedListTop = list?.scrollTop ?? savedListTop;
88+ void loadContext(entry);
89+ }
90+ function currentAccount(account: string): boolean {
91+ return alive && account === self && get(sessionUser) === account;
92+ }
93+ function captureViewport(): void { viewport?.capture(); }
94+ async function restoreViewport(): Promise<void> {
95+ await tick();
96+ if (alive) viewport?.restore();
97+ }
98+ async function fetchPage(cursor: string | null): Promise<MentionsPage> {
99+ return mentionsPage(cursor, 50);
100+ }
101+ async function fetchRange(targetDepth: number, request: number, account: string): Promise<void> {
102+ busy = true;
103+ error = '';
104+ const anchorPage = page;
105+ const anchorRevision = anchorPage?.revision ?? 0;
106+ let succeeded = false;
107+ try {
108+ const first = await fetchPage(null);
109+ if (!currentAccount(account) || request !== generation || first.boundary.account !== account) return;
110+ const firstSnapshot = first;
111+ const gathered = [...first.entries];
112+ let loadedDepth = 1;
113+ let cursor = first.next_cursor;
114+ let last = first;
115+ for (let index = 1; index < targetDepth && cursor; index++) {
116+ last = await fetchPage(cursor);
117+ if (!currentAccount(account) || request !== generation || last.boundary.account !== account) return;
118+ gathered.push(...last.entries);
119+ loadedDepth++;
120+ cursor = last.next_cursor;
121+ }
122+ const mirror = get(mentionsSnapshot);
123+ if (mirror?.boundary.account === account && mirror.revision > firstSnapshot.revision) {
124+ pendingRevision = Math.max(pendingRevision, mirror.revision);
125+ }
126+ if (firstSnapshot.revision < anchorRevision) return;
127+ captureViewport();
128+ const unique = new Map<string, MentionEntry>();
129+ for (const entry of gathered) unique.set(keyOf(entry), entry);
130+ if (mirror?.boundary.account === account) {
131+ for (const update of mirror.entries) {
132+ const key = keyOf(update);
133+ const previous = unique.get(key);
134+ if (previous && update.revision >= previous.revision) unique.set(key, update);
135+ }
136+ }
137+ const replacement = [...unique.values()];
138+ page = { ...firstSnapshot, next_cursor: last.next_cursor, end_reached: last.end_reached, entries: replacement };
139+ entries = replacement;
140+ if (selected) {
141+ const latestSelected = replacement.find((entry) => keyOf(entry) === keyOf(selected!));
142+ if (latestSelected && latestSelected.revision > selected.revision) selected = latestSelected;
143+ void loadContext(selected, false);
144+ }
145+ publishMentionsPage(firstSnapshot);
146+ if (pendingRevision <= firstSnapshot.revision) pendingRevision = 0;
147+ depth = loadedDepth;
148+ succeeded = true;
149+ await restoreViewport();
150+ } catch (cause) {
151+ if (currentAccount(account) && request === generation) error = cause instanceof Error ? cause.message : 'Could not load mentions';
152+ } finally {
153+ if (currentAccount(account) && request === generation) {
154+ busy = false;
155+ if (succeeded && pendingRevision > (page?.revision ?? 0)) scheduleRefresh(pendingRevision);
156+ }
157+ }
158+ }
159+ function scheduleRefresh(revision: number): void {
160+ pendingRevision = Math.max(pendingRevision, revision);
161+ if (busy || !mounted) return;
162+ if (refreshTimer) clearTimeout(refreshTimer);
163+ refreshTimer = setTimeout(() => {
164+ refreshTimer = null;
165+ if (!alive || busy) return;
166+ const request = ++generation;
167+ void fetchRange(Math.max(1, depth), request, self);
168+ }, 200);
169+ }
170+ async function loadMore(): Promise<void> {
171+ if (!page?.next_cursor || page.end_reached || busy) return;
172+ const cursor = page.next_cursor;
173+ const request = ++generation;
174+ const account = self;
175+ busy = true;
176+ error = '';
177+ let succeeded = false;
178+ try {
179+ const next = await fetchPage(cursor);
180+ if (!currentAccount(account) || request !== generation || next.boundary.account !== account) return;
181+ const current = page;
182+ if (!current || next.revision < current.revision) return;
183+ captureViewport();
184+ const unique = new Map(entries.map((entry) => [keyOf(entry), entry]));
185+ for (const entry of next.entries) unique.set(keyOf(entry), entry);
186+ page = { ...current, next_cursor: next.next_cursor, end_reached: next.end_reached, entries: [...unique.values()] };
187+ entries = page.entries;
188+ const mirror = get(mentionsSnapshot);
189+ if (mirror?.boundary.account === account) {
190+ for (const update of mirror.entries) {
191+ const key = keyOf(update);
192+ const previous = unique.get(key);
193+ if (previous && update.revision >= previous.revision) unique.set(key, update);
194+ }
195+ entries = [...unique.values()];
196+ page = { ...page, entries };
197+ }
198+ depth++;
199+ succeeded = true;
200+ await restoreViewport();
201+ } catch (cause) {
202+ if (currentAccount(account) && request === generation) error = cause instanceof Error ? cause.message : 'Could not load mentions';
203+ } finally {
204+ if (currentAccount(account) && request === generation) {
205+ busy = false;
206+ if (succeeded && pendingRevision > (page?.revision ?? 0)) scheduleRefresh(pendingRevision);
207+ }
208+ }
209+ }
210+ async function loadContext(entry: MentionEntry, explicit = true): Promise<void> {
211+ const id = keyOf(entry);
212+ if (explicit) pendingExplicitOpen = true;
213+ if (contextRequest?.key === id) {
214+ if (entry.revision > contextRequest.revision) queuedContext = entry;
215+ return;
216+ }
217+ if (contextKey !== id) {
218+ if (!selected) savedListTop = list?.scrollTop ?? savedListTop;
219+ saved = null;
220+ selectedRevision = -1;
221+ }
222+ contextKey = id;
223+ selectedRevision = entry.revision;
224+ selected = entry;
225+ loadingContext = true;
226+ error = '';
227+ const account = self;
228+ const request = ++contextGeneration;
229+ contextRequest = { key: id, revision: entry.revision };
230+ try {
231+ const result = await mentionContext(entry.key.room_id, entry.key.event_id);
232+ if (!currentAccount(account) || request !== contextGeneration || contextKey !== id || mentionKey(result.key) !== id) return;
233+ saved = result;
234+ if (pendingExplicitOpen) {
235+ pendingExplicitOpen = false;
236+ const current = entries.find((item) => keyOf(item) === id);
237+ if (current && !current.read) {
238+ await setMentionsRead([current.key], true);
239+ if (currentAccount(account) && request === contextGeneration) scheduleRefresh(get(mentionsSnapshot)?.revision ?? page?.revision ?? 0);
240+ }
241+ }
242+ } catch (cause) {
243+ if (currentAccount(account) && request === contextGeneration) {
244+ pendingExplicitOpen = false;
245+ if (cause instanceof ApiError && cause.status === 404) {
246+ backToList();
247+ error = 'This mention is no longer available';
248+ scheduleRefresh(page?.revision ?? 0);
249+ } else {
250+ error = cause instanceof Error ? cause.message : 'Could not load saved context';
251+ }
252+ }
253+ } finally {
254+ if (currentAccount(account) && request === contextGeneration) {
255+ contextRequest = null;
256+ loadingContext = false;
257+ const queued = queuedContext;
258+ queuedContext = null;
259+ if (queued && keyOf(queued) === id && queued.revision !== selectedRevision) void loadContext(queued, false);
260+ }
261+ }
262+ }
263+ async function setRead(entry: MentionEntry, read: boolean): Promise<void> {
264+ if (actionBusy) return;
265+ actionBusy = true;
266+ const account = self;
267+ try {
268+ await setMentionsRead([entry.key], read);
269+ if (!currentAccount(account)) return;
270+ const mirror = get(mentionsSnapshot);
271+ if (mirror?.boundary.account === account) scheduleRefresh(mirror.revision);
272+ else scheduleRefresh((page?.revision ?? 0) + 1);
273+ } catch (cause) {
274+ if (currentAccount(account)) error = cause instanceof Error ? cause.message : 'Could not update mention';
275+ } finally {
276+ if (currentAccount(account)) actionBusy = false;
277+ }
278+ }
279+ async function markAllRead(): Promise<void> {
280+ if (!page || actionBusy) return;
281+ actionBusy = true;
282+ const account = self;
283+ const boundary = page.boundary;
284+ try {
285+ await markMentionsReadThrough(boundary);
286+ if (!currentAccount(account)) return;
287+ const mirror = get(mentionsSnapshot);
288+ scheduleRefresh(mirror?.boundary.account === account ? mirror.revision : page?.revision ?? 0);
289+ } catch (cause) {
290+ if (currentAccount(account)) error = cause instanceof Error ? cause.message : 'Could not mark mentions read';
291+ } finally {
292+ if (currentAccount(account)) actionBusy = false;
293+ }
294+ }
295+ async function recover(): Promise<void> {
296+ if (actionBusy) return;
297+ actionBusy = true;
298+ const account = self;
299+ try {
300+ await recoverMentions();
301+ if (!currentAccount(account)) return;
302+ scheduleRefresh(get(mentionsSnapshot)?.revision ?? page?.revision ?? 0);
303+ } catch (cause) {
304+ if (currentAccount(account)) error = cause instanceof Error ? cause.message : 'Could not recover mention history';
305+ } finally {
306+ if (currentAccount(account)) actionBusy = false;
307+ }
308+ }
309+ async function retryCapture(): Promise<void> {
310+ if (!selected || !selectedRoom || actionBusy || loadingContext) return;
311+ actionBusy = true;
312+ const request = contextGeneration;
313+ const account = self;
314+ try {
315+ const result = await retryMentionContext(selected.key.room_id, selected.key.event_id);
316+ if (!currentAccount(account) || request !== contextGeneration || mentionKey(result.key) !== contextKey) return;
317+ saved = result;
318+ scheduleRefresh(get(mentionsSnapshot)?.revision ?? page?.revision ?? 0);
319+ } catch (cause) {
320+ if (currentAccount(account) && request === contextGeneration) error = cause instanceof Error ? cause.message : 'Could not retry context capture';
321+ } finally {
322+ if (currentAccount(account)) actionBusy = false;
323+ }
324+ }
325+ async function retryEntry(entry: MentionEntry): Promise<void> {
326+ if (selected?.revision !== entry.revision || keyOf(selected) !== keyOf(entry)) await loadContext(entry, false);
327+ else await tick();
328+ await retryCapture();
329+ }
330+ function backToList(): void {
331+ contextGeneration++;
332+ contextRequest = null;
333+ queuedContext = null;
334+ loadingContext = false;
335+ pendingExplicitOpen = false;
336+ selected = null;
337+ saved = null;
338+ contextKey = '';
339+ }
340+
341+ const unsubscribe = mentionsSnapshot.subscribe((snapshot) => {
342+ if (!snapshot || snapshot.boundary.account !== self) return;
343+ if (snapshot.revision < (page?.revision ?? -1)) return;
344+ const updated = new Map(snapshot.entries.map((entry) => [keyOf(entry), entry]));
345+ if (entries.length) {
346+ captureViewport();
347+ entries = entries.map((entry) => {
348+ const update = updated.get(keyOf(entry));
349+ return update && update.revision >= entry.revision ? update : entry;
350+ });
351+ if (page) page = { ...page, entries };
352+ void restoreViewport();
353+ }
354+ if (selected) {
355+ const update = snapshot.entries.find((entry) => keyOf(entry) === keyOf(selected!));
356+ if (update && update.revision > selected.revision) selected = update;
357+ }
358+ if (busy) {
359+ pendingRevision = Math.max(pendingRevision, snapshot.revision);
360+ return;
361+ }
362+ if (!page || snapshot.revision > page.revision) scheduleRefresh(snapshot.revision);
363+ });
364+ $: if (self && self !== renderedAccount) {
365+ generation++;
366+ contextGeneration++;
367+ page = null;
368+ entries = [];
369+ error = '';
370+ selected = null;
371+ saved = null;
372+ depth = 1;
373+ busy = false;
374+ actionBusy = false;
375+ loadingContext = false;
376+ contextRequest = null;
377+ queuedContext = null;
378+ pendingExplicitOpen = false;
379+ pendingRevision = 0;
380+ savedListTop = 0;
381+ if (refreshTimer) clearTimeout(refreshTimer);
382+ renderedAccount = self;
383+ if (mounted) void fetchRange(1, generation, self);
384+ }
385+ $: if (selected && selected.revision !== selectedRevision) void loadContext(selected, false);
386+ onMount(() => {
387+ mounted = true;
388+ if (!page) void fetchRange(1, ++generation, self);
389+ });
390+ onDestroy(() => {
391+ alive = false;
392+ generation++;
393+ contextGeneration++;
394+ unsubscribe();
395+ observer?.disconnect();
396+ viewport?.releaseScroll();
397+ if (refreshTimer) clearTimeout(refreshTimer);
398+ });
399+</script>
400+
401+<section class="mentions-inbox">
402+ {#if selected}
403+ {#if error}<div class="error" role="alert"><EmojiText text={error} /><button disabled={loadingContext} onclick={() => void loadContext(selected!)}>Retry</button></div>{/if}
404+ {#if saved?.context}
405+ <ContextTimeline context={saved.context} roomId={selected.key.room_id} eventId={selected.key.event_id} roomName={roomName(selected)} {self} canPaginate={contextCanPaginate} onClose={backToList} {onLive} />
406+ {#if selected.capture.kind === 'Partial' || selected.capture.kind === 'Failed' || selected.capture.kind === 'Unavailable' || saved.state.kind === 'Partial' || saved.state.kind === 'Failed'}<button class="capture-retry" disabled={!selectedRoom || actionBusy} onclick={retryCapture}>Retry context capture</button>{/if}
407+ {#if !contextCanPaginate}<p class="status">This room is no longer joined. Saved context remains available.</p>{/if}
408+ {#if saved.state.kind === 'Partial'}<p class="status">This saved context is partial.</p>{/if}
409+ {#if saved.state.kind === 'Pending'}<p class="status">Saving context…</p>{/if}
410+ {#if saved.state.kind === 'Failed'}<p class="status" role="alert"><EmojiText text={saved.state.message} /></p>{/if}
411+ {:else}
412+ <header><button onclick={backToList}>Back to mentions</button><strong><EmojiText text={roomName(selected)} /></strong></header>
413+ {#if loadingContext}<p class="status">Loading saved context…</p>
414+ {:else if saved?.state.kind === 'Failed'}<p class="status" role="alert"><EmojiText text={saved.state.message} /></p>
415+ {:else if saved?.state.kind === 'Unavailable'}<p class="status">Saved context is unavailable.</p>
416+ {:else if saved?.state.kind === 'Partial'}<p class="status">Saved context is partial and currently unavailable.</p>
417+ {:else}<p class="status">Saved context is not available yet.</p>{/if}
418+ {@const preview = itemEvent(selected.event)}
419+ {#if preview}<MessageRow itemId={preview.item_id} event={preview} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} senderAvatar={preview.sender.avatar} />{/if}
420+ {#if selected.capture.kind === 'Failed' || selected.capture.kind === 'Partial' || selected.capture.kind === 'Unavailable'}
421+ <button disabled={!selectedRoom || actionBusy} onclick={retryCapture}>Retry context capture</button>
422+ {/if}
423+ {/if}
424+ {:else}
425+ <header><h2>Mentions {#if page}<span>({unreadCount} unread)</span>{/if}</h2><button disabled={!page || unreadCount === 0 || actionBusy} onclick={markAllRead}>Mark all read</button></header>
426+ <details class="coverage">
427+ <summary>{page?.coverage.recovering ? 'Recovering mention history…' : 'History coverage'}{#if page?.coverage.incomplete_history} · Incomplete{/if}</summary>
428+ <div class="coverage-body">
429+ {#if page}
430+ {#if page.coverage.recovering}<p>History recovery is in progress.</p>{/if}
431+ {#if page.coverage.incomplete_history}<p>Some mention history could not be checked.</p>{/if}
432+ {#if page.coverage.truncated}<p>Results are truncated.</p>{/if}
433+ {#if page.coverage.historical_supported === false}<p>Historical mention search is unsupported.</p>{/if}
434+ {#if page.coverage.unresolved_candidates}<p>{page.coverage.unresolved_candidates} possible mentions could not be resolved.</p>{/if}
435+ {#if page.coverage.gaps.length}<p>{page.coverage.gaps.length} history gaps remain.</p>{/if}
436+ {#if page.coverage.earliest_timestamp_ms}<p>Oldest observed event from {new Date(page.coverage.earliest_timestamp_ms).toLocaleString()}.</p>{/if}
437+ {#each page.coverage.gaps as gap}<p><EmojiText text={gap.room_id ? joinedRooms.get(gap.room_id)?.name ?? gap.room_id : 'Unknown room'} /> · <EmojiText text={gap.reason} /></p>{/each}
438+ {:else}<p>Coverage is loading.</p>{/if}
439+ <button disabled={actionBusy || page?.coverage.recovering} onclick={recover}>Recover history</button>
440+ </div>
441+ </details>
442+ {#if error}<div class="error" role="alert"><EmojiText text={error} /><button disabled={busy} onclick={() => fetchRange(depth, ++generation, self)}>Retry</button></div>{/if}
443+ <div class="entries" use:bindViewport onscroll={() => { if (!viewport?.consumeScroll()) viewport?.capture(); }}>
444+ <div class="entry-content">
445+ {#each entries as entry (keyOf(entry))}
446+ {@const event = itemEvent(entry.event)}
447+ <article class:unread={!entry.read}>
448+ <div class="entry-head"><button class="room" onclick={() => openContext(entry)}><EmojiText text={roomName(entry)} /></button><time>{new Date(entry.timestamp_ms).toLocaleString()}</time><span>{entry.read ? 'Read' : 'Unread'}</span></div>
449+ <div class="labels">{#each entry.reasons as reason}<span>{reasonLabel(reason)}</span>{/each}<span>{captureLabel(entry.capture)}</span>{#if entry.notification_suppressed}<span>Notifications suppressed</span>{/if}</div>
450+ {#if event}<MessageRow itemId={keyOf(entry)} {event} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} senderAvatar={event.sender.avatar} />{:else}<p>Mention message unavailable</p>{/if}
451+ <div class="entry-actions"><button onclick={() => openContext(entry)}>Open saved context</button><button disabled={actionBusy} onclick={() => void setRead(entry, !entry.read)}>{entry.read ? 'Mark unread' : 'Mark read'}</button>{#if entry.capture.kind === 'Failed' || entry.capture.kind === 'Partial' || entry.capture.kind === 'Unavailable'}<button disabled={!joinedRooms.has(entry.key.room_id) || actionBusy} onclick={() => void retryEntry(entry)}>Retry capture</button>{/if}</div>
452+ </article>
453+ {/each}
454+ </div>
455+ </div>
456+ <footer><button disabled={!page?.next_cursor || page.end_reached || busy} onclick={loadMore}>{busy ? 'Loading…' : 'Load older mentions'}</button></footer>
457+ {#if entries.length === 0 && !busy && !error}<p class="status">No mentions yet.</p>{/if}
458+ {/if}
459+</section>
460+
461+<style>
462+ .mentions-inbox { display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; color:var(--text); }
463+ header { display:flex; align-items:center; gap:.6rem; padding:.6rem; border-bottom:1px solid var(--border); }
464+ header h2 { flex:1; margin:0; font-size:1rem; }
465+ header h2 span { color:var(--muted); font-size:.85em; }
466+ button { color:var(--text); background:var(--panel); border:1px solid var(--border); border-radius:.4rem; padding:.4rem .65rem; cursor:pointer; }
467+ button:disabled { opacity:.55; cursor:default; }
468+ .entries { flex:1; min-height:0; overflow:auto; overflow-anchor:none; padding:.5rem; }
469+ article { border:1px solid var(--border); border-radius:.6rem; padding:.5rem; margin-bottom:.6rem; }
470+ article.unread { border-color:var(--accent); }
471+ .entry-head,.entry-actions,.labels { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
472+ .entry-head .room { font-weight:600; }
473+ .entry-head time,.entry-head span { color:var(--muted); font-size:.8rem; }
474+ .entry-head time { margin-left:auto; }
475+ .labels { margin:.35rem 0; color:var(--muted); font-size:.78rem; }
476+ .labels span { border:1px solid var(--border); border-radius:1rem; padding:.1rem .4rem; }
477+ .entry-actions { justify-content:flex-end; margin-top:.4rem; }
478+ footer { display:flex; justify-content:center; padding:.45rem; }
479+ .coverage { padding:.55rem .75rem; background:var(--panel); border-bottom:1px solid var(--border); }
480+ .coverage-body { max-height:min(15rem, 35vh); overflow:auto; }
481+ .coverage p,.status { margin:.25rem 0; color:var(--muted); }
482+ .error { display:flex; align-items:center; gap:.5rem; padding:.5rem; color:var(--danger); }
483+ .capture-retry { align-self:flex-start; margin:.4rem; }
484+</style>
Modified file: web/src/lib/MessageRow.svelte+6 additions−3 deletions
@@ -30,6 +30,7 @@
3030 export let onEdit: ((eventId: string, body: string) => void) | null = null;
3131 export let editing = false;
3232 export let showSent = true;
33+ export let readOnly = false;
3334 export let self: string | null = null;
3435 export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
3536 export let onCloneSticker: ((eventId: string, body: string, x: number, y: number) => void) | null = null;
@@ -110,7 +111,7 @@
110111 }
111112
112113 function toggleSelected(e: MouseEvent) {
113- if (!interactive || (e.target as Element).closest('a, button, input, video, audio, .picker-pop')) return;
114+ if (readOnly || !interactive || (e.target as Element).closest('a, button, input, video, audio, .picker-pop')) return;
114115 selected = !selected;
115116 }
116117
@@ -263,7 +264,7 @@
263264 bind:this={row}
264265 on:click={toggleSelected}
265266 >
266- {#if interactive && event.event_id}
267+ {#if !readOnly && interactive && event.event_id}
267268 <div class="actions" role="toolbar" aria-label={`Actions for message from ${displayName}`}>
268269 {#each quickReactions as r (r.key)}
269270 {@const label = r.shortcode ? `:${r.shortcode}:` : r.key}
@@ -341,6 +342,7 @@
341342 <button
342343 type="button"
343344 class="user-hit"
345+ disabled={!onUser}
344346 tabindex="-1"
345347 aria-hidden="true"
346348 on:click|stopPropagation={(ev) => openUser(ev, false)}
@@ -365,6 +367,7 @@
365367 <button
366368 type="button"
367369 class="sender"
370+ disabled={!onUser}
368371 style={`color:hsl(${senderHue(event.sender.user_id)} 70% var(--name-lightness))`}
369372 on:click|stopPropagation={(ev) => openUser(ev, false)}
370373 on:contextmenu|preventDefault|stopPropagation={(ev) => openUser(ev, true)}
@@ -432,7 +435,7 @@
432435 class="reaction"
433436 class:mine={r.own}
434437 aria-label={`${r.shortcode ? `:${r.shortcode}:` : r.key.startsWith('mxc://') ? 'custom emote' : r.key}, ${r.count} reaction${r.count === 1 ? '' : 's'}`}
435- disabled={!event.event_id || reacting}
438+ disabled={readOnly || !event.event_id || reacting}
436439 on:click={() => event.event_id && onReact(event.event_id, r.key)}
437440 >
438441 {#if r.image}
Modified file: web/src/lib/api.ts+9 additions
@@ -2,6 +2,9 @@
22 // this build was compiled for, hosted (fetch + SSE) or on-device (worker).
33 import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from './types';
44 import type { Emote } from './emotes';
5+import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';
6+export { mentionKey, parseMentionsPage, parseSavedMentionContext, parseMentionsUpdate } from './mentions-model';
7+export type { MentionKey, MentionReason, MentionCaptureState, MentionEntry, MentionCoverage, MentionGap, MentionsBoundary, MentionsPage, SavedMentionContext, MentionsUpdate } from './mentions-model';
58 import { formatBody } from './markdown';
69 import { mentionedUserIds, type Mention } from './mentions';
710 import { HttpTransport } from './transport-http';
@@ -55,6 +58,12 @@ export const eventContextBefore = (room_id: string, event_id: string, from: stri
5558 transport.eventContextBefore(room_id, event_id, from, limit);
5659 export const eventContextAfter = (room_id: string, event_id: string, from: string, limit?: number): Promise<EventContextPage> =>
5760 transport.eventContextAfter(room_id, event_id, from, limit);
61+export const mentionsPage = (cursor: string | null = null, limit = 50): Promise<MentionsPage> => transport.mentionsPage(cursor, limit);
62+export const mentionContext = (room_id: string, event_id: string): Promise<SavedMentionContext> => transport.mentionContext(room_id, event_id);
63+export const retryMentionContext = (room_id: string, event_id: string): Promise<SavedMentionContext> => transport.retryMentionContext(room_id, event_id);
64+export const setMentionsRead = (keys: MentionKey[], read: boolean) => transport.setMentionsRead(keys, read);
65+export const markMentionsReadThrough = (boundary: MentionsBoundary) => transport.markMentionsReadThrough(boundary);
66+export const recoverMentions = () => transport.recoverMentions();
5867 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
5968 export const getAccountData = (eventType: string) => transport.getAccountData(eventType);
6069 export const getUrlPreview = (room_id: string, url: string) => transport.getUrlPreview(room_id, url);
Added file: web/src/lib/mentions-model.ts+75 additions
@@ -0,0 +1,75 @@
1+import { parseEventContext, parseTimelineItem } from './types';
2+import type { EventContext, TimelineItem } from './types';
3+
4+export interface MentionKey { room_id: string; event_id: string }
5+export type MentionReason = 'Direct' | 'Reply' | 'Room';
6+export type MentionCaptureState =
7+ | { kind: 'Pending' | 'Partial' | 'Ready' | 'Unavailable' }
8+ | { kind: 'Failed'; message: string; retry_at_ms: number };
9+export interface MentionEntry {
10+ key: MentionKey; timestamp_ms: number; discovered_at_ms: number; discovery_revision: number; revision: number;
11+ event: TimelineItem; reasons: MentionReason[]; read: boolean; capture: MentionCaptureState;
12+ room_name: string | null; notification_suppressed: boolean;
13+}
14+export interface MentionCoverage {
15+ earliest_timestamp_ms: number | null; historical_supported: boolean | null; recovering: boolean;
16+ incomplete_history: boolean; gaps: MentionGap[]; truncated: boolean; unresolved_candidates: number;
17+}
18+export interface MentionGap { room_id: string | null; reason: string; token: string | null }
19+export interface MentionsBoundary { account: string; revision: number }
20+export interface MentionsPage {
21+ entries: MentionEntry[]; next_cursor: string | null; end_reached: boolean; coverage: MentionCoverage;
22+ unread_count: number; revision: number; boundary: MentionsBoundary;
23+}
24+export interface SavedMentionContext { key: MentionKey; state: MentionCaptureState; context: EventContext | null; revision: number }
25+export interface MentionsUpdate { snapshot: MentionsPage; upserts: MentionEntry[]; removed: MentionKey[] }
26+
27+type Obj = Record<string, unknown>;
28+const obj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v);
29+const str = (v: unknown): v is string => typeof v === 'string';
30+const bool = (v: unknown): v is boolean => typeof v === 'boolean';
31+const nat = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
32+function key(v: unknown): MentionKey | null {
33+ if (!obj(v) || !str(v.room_id) || !v.room_id || !str(v.event_id) || !v.event_id) return null;
34+ return { room_id: v.room_id, event_id: v.event_id };
35+}
36+function capture(v: unknown): MentionCaptureState | null {
37+ if (str(v) && ['Pending', 'Partial', 'Ready', 'Unavailable'].includes(v)) return { kind: v as 'Pending' | 'Partial' | 'Ready' | 'Unavailable' };
38+ if (obj(v) && obj(v.Failed) && str(v.Failed.message) && nat(v.Failed.retry_at_ms)) return { kind: 'Failed', message: v.Failed.message, retry_at_ms: v.Failed.retry_at_ms };
39+ return null;
40+}
41+function entry(v: unknown): MentionEntry | null {
42+ if (!obj(v)) return null;
43+ const k = key(v.key), ev = parseTimelineItem(v.event), c = capture(v.capture);
44+ if (!k || !ev || !c || !nat(v.timestamp_ms) || !nat(v.discovered_at_ms) || !nat(v.discovery_revision) || !nat(v.revision) || !Array.isArray(v.reasons) || !v.reasons.every(x => str(x) && ['Direct','Reply','Room'].includes(x)) || !bool(v.read) || !(v.room_name === null || str(v.room_name)) || !bool(v.notification_suppressed)) return null;
45+ return { key:k, timestamp_ms:v.timestamp_ms, discovered_at_ms:v.discovered_at_ms, discovery_revision:v.discovery_revision, revision:v.revision, event:ev, reasons:v.reasons as MentionReason[], read:v.read, capture:c, room_name:v.room_name, notification_suppressed:v.notification_suppressed };
46+}
47+function gap(v: unknown): MentionGap | null {
48+ if (!obj(v) || !(v.room_id === null || (str(v.room_id) && v.room_id)) || !str(v.reason) || !(v.token === null || str(v.token))) return null;
49+ return { room_id:v.room_id, reason:v.reason, token:v.token };
50+}
51+function page(v: unknown): MentionsPage | null {
52+ if (!obj(v) || !obj(v.coverage) || !Array.isArray(v.entries) || !Array.isArray(v.coverage['gaps'])) return null;
53+ const entries=v.entries.map(entry), cov=v.coverage as Obj;
54+ const gaps=(cov['gaps'] as unknown[]).map(gap);
55+ if (entries.some(x=>!x) || gaps.some(x=>!x) || !(v.next_cursor === null || str(v.next_cursor)) || !bool(v.end_reached) || !(cov['earliest_timestamp_ms'] === null || nat(cov['earliest_timestamp_ms'])) || !(cov['historical_supported'] === null || bool(cov['historical_supported'])) || !bool(cov['recovering']) || !bool(cov['incomplete_history']) || !bool(cov['truncated']) || !nat(cov['unresolved_candidates']) || !nat(v.unread_count) || !nat(v.revision) || !obj(v.boundary) || !str(v.boundary['account']) || !v.boundary['account'] || !nat(v.boundary['revision'])) return null;
56+ return { entries:entries as MentionEntry[], next_cursor:v.next_cursor as string|null, end_reached:v.end_reached, coverage:{earliest_timestamp_ms:cov['earliest_timestamp_ms'] as number|null,historical_supported:cov['historical_supported'] as boolean|null,recovering:cov['recovering'],incomplete_history:cov['incomplete_history'],gaps:gaps as MentionGap[],truncated:cov['truncated'],unresolved_candidates:cov['unresolved_candidates']}, unread_count:v.unread_count, revision:v.revision, boundary:{account:v.boundary['account'],revision:v.boundary['revision']} };
57+}
58+export function parseMentionsPage(raw: unknown): MentionsPage | null { try { return page(raw); } catch { return null; } }
59+export function parseSavedMentionContext(raw: unknown): SavedMentionContext | null {
60+ try {
61+ if (!obj(raw)) return null;
62+ const k=key(raw.key), state=capture(raw.state), context=raw.context === null ? null : parseEventContext(raw.context);
63+ if (!k || !state || (raw.context !== null && !context) || !nat(raw.revision)) return null;
64+ return {key:k,state,context,revision:raw.revision};
65+ } catch { return null; }
66+}
67+export function parseMentionsUpdate(raw: unknown): MentionsUpdate | null {
68+ try {
69+ if (!obj(raw) || !Array.isArray(raw.upserts) || !Array.isArray(raw.removed)) return null;
70+ const snapshot=page(raw.snapshot), upserts=raw.upserts.map(entry), removed=raw.removed.map(key);
71+ if (!snapshot || upserts.some(x=>!x) || removed.some(x=>!x)) return null;
72+ return {snapshot,upserts:upserts as MentionEntry[],removed:removed as MentionKey[]};
73+ } catch { return null; }
74+}
75+export function mentionKey(key: MentionKey): string { return JSON.stringify([key.room_id,key.event_id]); }
Modified file: web/src/lib/store.ts+20 additions−1 deletions
@@ -1,4 +1,6 @@
1-import { writable } from 'svelte/store';
1+import { get, writable } from 'svelte/store';
2+import { parseMentionsPage, parseMentionsUpdate } from './mentions-model';
3+import type { MentionsPage } from './mentions-model';
24 import { getSession, subscribeEvents } from './api';
35 import type { SoftLogout } from './api';
46 import { isRecord } from './guards';
@@ -23,6 +25,15 @@ import type {
2325 } from './types';
2426
2527 export const sessionUser = writable<string | null | undefined>(undefined);
28+export const mentionsSnapshot = writable<MentionsPage | null>(null);
29+
30+export function publishMentionsPage(page: MentionsPage): void {
31+ if (page.boundary.account !== get(sessionUser)) return;
32+ const current = get(mentionsSnapshot);
33+ if (!current || current.boundary.account !== page.boundary.account || page.revision >= current.revision) {
34+ mentionsSnapshot.set(page);
35+ }
36+}
2637 export const softLogout = writable<SoftLogout | null>(null);
2738 /** Device build only: this tab lost the store lock to another tab that has malice open. */
2839 export const blocked = writable<boolean>(false);
@@ -61,6 +72,7 @@ export function dismissBanner(id: number): void {
6172 }
6273
6374 export function resetClientState(): void {
75+ mentionsSnapshot.set(null);
6476 rooms.set([]);
6577 timelines.set({});
6678 pinnedTimelines.set({});
@@ -113,6 +125,8 @@ function handlePayload(text: string): void {
113125 if (!isRecord(data)) return;
114126 const kind = data['type'];
115127 if (kind === 'snapshot') {
128+ const mentions = parseMentionsPage(data['mentions']);
129+ if (mentions) publishMentionsPage(mentions);
116130 setRooms(data['rooms']);
117131 const rawTimelines = data['timelines'];
118132 if (isRecord(rawTimelines)) {
@@ -136,6 +150,11 @@ function handlePayload(text: string): void {
136150 imagePacks.set(parseImagePacks(data['image_packs']));
137151 return;
138152 }
153+ if (kind === 'mentions') {
154+ const update = parseMentionsUpdate(data['update']);
155+ if (update) publishMentionsPage(update.snapshot);
156+ return;
157+ }
139158 if (kind === 'image_packs') {
140159 imagePacks.set(parseImagePacks(data['packs']));
141160 return;
Modified file: web/src/lib/transport-http.ts+25 additions
@@ -1,5 +1,7 @@
11 import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from './types';
22 import { parseEventContext, parseEventContextPage } from './types';
3+import { parseMentionsPage, parseSavedMentionContext } from './mentions-model';
4+import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';
35 import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type Transport, type UploadExtras, type VerifyAction } from './transport';
46
57 async function parseFailure(res: Response, fallback: string): Promise<never> {
@@ -115,6 +117,29 @@ export class HttpTransport implements Transport {
115117 if (!parsed) throw new ApiError(502, 'Invalid event context response');
116118 return parsed;
117119 }
120+ async mentionsPage(cursor: string | null = null, limit = 50): Promise<MentionsPage> {
121+ const parsed = parseMentionsPage(await this.command({ op: 'mentions_page', cursor, limit }));
122+ if (!parsed) throw new ApiError(502, 'Invalid mentions page response');
123+ return parsed;
124+ }
125+ mentionContext(room_id: string, event_id: string): Promise<SavedMentionContext> {
126+ return this.mentionContextCall('mention_context', room_id, event_id);
127+ }
128+ retryMentionContext(room_id: string, event_id: string): Promise<SavedMentionContext> {
129+ return this.mentionContextCall('retry_mention_context', room_id, event_id);
130+ }
131+ private async mentionContextCall(op: 'mention_context' | 'retry_mention_context', room_id: string, event_id: string): Promise<SavedMentionContext> {
132+ const parsed = parseSavedMentionContext(await this.command({ op, room_id, event_id }));
133+ if (!parsed) throw new ApiError(502, 'Invalid saved mention context response');
134+ return parsed;
135+ }
136+ async setMentionsRead(keys: MentionKey[], read: boolean): Promise<void> {
137+ await this.command({ op: 'set_mentions_read', keys, read });
138+ }
139+ async markMentionsReadThrough(boundary: MentionsBoundary): Promise<void> {
140+ await this.command({ op: 'mark_mentions_read_through', boundary });
141+ }
142+ async recoverMentions(): Promise<void> { await this.command({ op: 'recover_mentions' }); }
118143 async eventContextBefore(room_id: string, event_id: string, from: string, limit = 20): Promise<EventContextPage> {
119144 return this.eventContextPage('before', room_id, event_id, from, limit);
120145 }
Modified file: web/src/lib/transport-worker.ts+17 additions
@@ -1,4 +1,6 @@
11 import { parseEventContext, parseEventContextPage } from './types';
2+import { parseMentionsPage, parseSavedMentionContext } from './mentions-model';
3+import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';
24 import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from './types';
35 import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type SoftLogout, type Transport, type UploadExtras, type VerifyAction } from './transport';
46 import { statusForKind } from './wire-status';
@@ -230,6 +232,21 @@ export class WorkerTransport implements Transport {
230232 paginateRoom(room_id: string): Promise<void> {
231233 return this.client.call('paginate', { room_id });
232234 }
235+ async mentionsPage(cursor: string | null = null, limit = 50): Promise<MentionsPage> {
236+ const parsed = parseMentionsPage(await this.client.call('mentions_page', { cursor, limit }));
237+ if (!parsed) throw new ApiError(502, 'Invalid mentions page response');
238+ return parsed;
239+ }
240+ mentionContext(room_id: string, event_id: string): Promise<SavedMentionContext> { return this.mentionContextCall('mention_context', room_id, event_id); }
241+ retryMentionContext(room_id: string, event_id: string): Promise<SavedMentionContext> { return this.mentionContextCall('retry_mention_context', room_id, event_id); }
242+ private async mentionContextCall(op: string, room_id: string, event_id: string): Promise<SavedMentionContext> {
243+ const parsed = parseSavedMentionContext(await this.client.call(op, { room_id, event_id }));
244+ if (!parsed) throw new ApiError(502, 'Invalid saved mention context response');
245+ return parsed;
246+ }
247+ async setMentionsRead(keys: MentionKey[], read: boolean): Promise<void> { await this.client.call('set_mentions_read', { keys, read }); }
248+ async markMentionsReadThrough(boundary: MentionsBoundary): Promise<void> { await this.client.call('mark_mentions_read_through', { boundary }); }
249+ async recoverMentions(): Promise<void> { await this.client.call('recover_mentions'); }
233250 eventContext(room_id: string, event_id: string, limit = 20): Promise<EventContext> {
234251 return this.contextCall('event_context', { room_id, event_id, limit });
235252 }
Modified file: web/src/lib/transport.ts+16 additions−1 deletions
@@ -1,4 +1,5 @@
11 import type { EventContext, EventContextPage, MemberSummary, PackUsage, RoomSummary } from './types';
2+import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';
23
34 export class ApiError extends Error {
45 status: number;
@@ -62,7 +63,15 @@ export type CommandRequest =
6263 | { op: 'set_pack_enabled'; room_id: string; state_key: string; enabled: boolean }
6364 | { op: 'clone_pack_image'; from_pack: string; shortcode: string; to: PackTarget }
6465 | { op: 'clone_sticker'; room_id: string; event_id: string; to: PackTarget; shortcode: string }
65- | { op: 'clone_pack'; from_pack: string; to: PackTarget; display_name: string | null };
66+ | { op: 'clone_pack'; from_pack: string; to: PackTarget; display_name: string | null }
67+ | MentionsRequest;
68+
69+export type MentionsRequest =
70+ | { op: 'mentions_page'; cursor: string | null; limit: number }
71+ | { op: 'mention_context' | 'retry_mention_context'; room_id: string; event_id: string }
72+ | { op: 'set_mentions_read'; keys: MentionKey[]; read: boolean }
73+ | { op: 'mark_mentions_read_through'; boundary: MentionsBoundary }
74+ | { op: 'recover_mentions' };
6675
6776 export type PackTarget = { kind: 'user' } | { kind: 'room'; room_id: string; state_key: string };
6877
@@ -101,6 +110,12 @@ export interface Transport {
101110 eventContext(room_id: string, event_id: string, limit?: number): Promise<EventContext>;
102111 eventContextBefore(room_id: string, event_id: string, from: string, limit?: number): Promise<EventContextPage>;
103112 eventContextAfter(room_id: string, event_id: string, from: string, limit?: number): Promise<EventContextPage>;
113+ mentionsPage(cursor?: string | null, limit?: number): Promise<MentionsPage>;
114+ mentionContext(room_id: string, event_id: string): Promise<SavedMentionContext>;
115+ retryMentionContext(room_id: string, event_id: string): Promise<SavedMentionContext>;
116+ setMentionsRead(keys: MentionKey[], read: boolean): Promise<void>;
117+ markMentionsReadThrough(boundary: MentionsBoundary): Promise<void>;
118+ recoverMentions(): Promise<void>;
104119 setTyping(room_id: string, typing: boolean): Promise<void>;
105120 getAccountData(eventType: string): Promise<unknown | null>;
106121 getUrlPreview(room_id: string, url: string): Promise<unknown | null>;