Commit 668ab5c30531
tarball10 files changed+927-10
@@ -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}@@ -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>@@ -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>@@ -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
<button342343
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
<button366368
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}@@ -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);@@ -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]); }@@ -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;@@ -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
}@@ -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
}@@ -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>;