| 1 | <script lang="ts"> |
| 2 | import { get } from 'svelte/store'; |
| 3 | import { onDestroy, onMount, tick } from 'svelte'; |
| 4 | import { getMembers, 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 { contextWithProfiles, roomsLackingProfiles, withProfiles } from './mention-profiles'; |
| 14 | import type { RoomProfiles } from './mention-profiles'; |
| 15 | import type { MentionEntry, MentionsPage, SavedMentionContext } from './mentions-model'; |
| 16 | |
| 17 | export let self: string; |
| 18 | export let onLive: (roomId: string, eventId: string, threadRoot: string | null) => void; |
| 19 | |
| 20 | let page: MentionsPage | null = null; |
| 21 | let entries: MentionEntry[] = []; |
| 22 | let selected: MentionEntry | null = null; |
| 23 | let saved: SavedMentionContext | null = null; |
| 24 | let list: HTMLDivElement; |
| 25 | let viewport: TimelineViewport | null = null; |
| 26 | let observer: ResizeObserver | null = null; |
| 27 | let error = ''; |
| 28 | let busy = false; |
| 29 | let actionBusy = false; |
| 30 | let loadingContext = false; |
| 31 | let mounted = false; |
| 32 | let alive = true; |
| 33 | let generation = 0; |
| 34 | let contextGeneration = 0; |
| 35 | let depth = 1; |
| 36 | let pendingRevision = 0; |
| 37 | let refreshTimer: ReturnType<typeof setTimeout> | null = null; |
| 38 | let renderedAccount = ''; |
| 39 | let selectedRevision = -1; |
| 40 | let contextKey = ''; |
| 41 | let contextRequest: { key: string; revision: number } | null = null; |
| 42 | let queuedContext: MentionEntry | null = null; |
| 43 | let pendingExplicitOpen = false; |
| 44 | let savedListTop = 0; |
| 45 | let roomProfiles: RoomProfiles = {}; |
| 46 | let profileRequests = new Set<string>(); |
| 47 | $: unreadCount = $mentionsSnapshot?.boundary.account === self ? $mentionsSnapshot.unread_count : page?.unread_count ?? 0; |
| 48 | $: joinedRooms = new Map($rooms.filter((room) => room.membership === 'Joined').map((room) => [room.room_id, room])); |
| 49 | $: selectedRoom = selected ? joinedRooms.get(selected.key.room_id) : undefined; |
| 50 | $: contextCanPaginate = !!selectedRoom; |
| 51 | $: requestProfiles([ |
| 52 | ...entries.map((entry) => ({ roomId: entry.key.room_id, item: entry.event })), |
| 53 | ...(selected && saved?.context ? [...saved.context.events_before, saved.context.event, ...saved.context.events_after].map((item) => ({ roomId: selected!.key.room_id, item })) : []) |
| 54 | ], joinedRooms); |
| 55 | |
| 56 | function itemEvent(item: TimelineItem) { |
| 57 | return item.kind === 'event' ? item.event : null; |
| 58 | } |
| 59 | function roomName(entry: MentionEntry): string { |
| 60 | return entry.room_name || joinedRooms.get(entry.key.room_id)?.name || entry.key.room_id; |
| 61 | } |
| 62 | const GAP_LABELS: Record<string, string> = { |
| 63 | SyncDiscontinuity: 'Sync was interrupted', |
| 64 | LimitedTimeline: 'Too many messages arrived at once', |
| 65 | RoomLeft: 'You left the room', |
| 66 | ContextUnavailable: 'Context could not be loaded', |
| 67 | NotificationHistoryUnavailable: 'Notification history unavailable', |
| 68 | CandidateExpired: 'A message could not be read', |
| 69 | StoreReset: 'Inbox was reset', |
| 70 | StoreFailed: 'Inbox could not be saved', |
| 71 | HookTimedOut: 'Checking a message timed out' |
| 72 | }; |
| 73 | |
| 74 | function gapLabel(reason: string): string { |
| 75 | return GAP_LABELS[reason] ?? reason; |
| 76 | } |
| 77 | |
| 78 | function reasonLabel(reason: string): string { |
| 79 | return reason === 'Direct' ? 'Direct mention' : reason === 'Reply' ? 'Reply' : reason === 'Keyword' ? 'Keyword' : 'Room mention'; |
| 80 | } |
| 81 | function captureLabel(state: MentionEntry['capture']): string { |
| 82 | if (state.kind === 'Failed') return state.message; |
| 83 | return state.kind === 'Ready' ? 'Context saved' : state.kind === 'Partial' ? 'Partial context saved' : state.kind === 'Pending' ? 'Saving context' : 'Context unavailable'; |
| 84 | } |
| 85 | function keyOf(entry: MentionEntry): string { return mentionKey(entry.key); } |
| 86 | function requestProfiles(items: { roomId: string; item: TimelineItem }[], joined: Map<string, unknown>): void { |
| 87 | const account = self; |
| 88 | const requests = profileRequests; |
| 89 | for (const roomId of roomsLackingProfiles(items)) { |
| 90 | if (requests.has(roomId) || !joined.has(roomId)) continue; |
| 91 | requests.add(roomId); |
| 92 | getMembers(roomId).then(({ members }) => { |
| 93 | if (currentAccount(account) && requests === profileRequests) roomProfiles = { ...roomProfiles, [roomId]: Object.fromEntries(members.map((member) => [member.user_id, member])) }; |
| 94 | }, () => requests.delete(roomId)); |
| 95 | } |
| 96 | } |
| 97 | function bindViewport(node: HTMLDivElement) { |
| 98 | list = node; |
| 99 | viewport?.releaseScroll(); |
| 100 | observer?.disconnect(); |
| 101 | node.scrollTop = savedListTop; |
| 102 | viewport = new TimelineViewport(node, null); |
| 103 | const content = node.querySelector<HTMLElement>('.entry-content'); |
| 104 | observer = new ResizeObserver(() => viewport?.restore()); |
| 105 | if (content) observer.observe(content); |
| 106 | void tick().then(() => { |
| 107 | if (!alive || selected || list !== node) return; |
| 108 | node.scrollTop = savedListTop; |
| 109 | viewport?.capture(); |
| 110 | }); |
| 111 | return { |
| 112 | destroy() { |
| 113 | if (!selected) savedListTop = node.scrollTop; |
| 114 | observer?.disconnect(); |
| 115 | observer = null; |
| 116 | viewport?.releaseScroll(); |
| 117 | viewport = null; |
| 118 | } |
| 119 | }; |
| 120 | } |
| 121 | function openContext(entry: MentionEntry): void { |
| 122 | if (!selected) savedListTop = list?.scrollTop ?? savedListTop; |
| 123 | void loadContext(entry); |
| 124 | } |
| 125 | function currentAccount(account: string): boolean { |
| 126 | return alive && account === self && get(sessionUser) === account; |
| 127 | } |
| 128 | function captureViewport(): void { viewport?.capture(); } |
| 129 | async function restoreViewport(): Promise<void> { |
| 130 | await tick(); |
| 131 | if (alive) viewport?.restore(); |
| 132 | } |
| 133 | async function fetchPage(cursor: string | null): Promise<MentionsPage> { |
| 134 | return mentionsPage(cursor, 50); |
| 135 | } |
| 136 | async function fetchRange(targetDepth: number, request: number, account: string): Promise<void> { |
| 137 | busy = true; |
| 138 | error = ''; |
| 139 | const anchorPage = page; |
| 140 | const anchorRevision = anchorPage?.revision ?? 0; |
| 141 | let succeeded = false; |
| 142 | try { |
| 143 | const first = await fetchPage(null); |
| 144 | if (!currentAccount(account) || request !== generation || first.boundary.account !== account) return; |
| 145 | const firstSnapshot = first; |
| 146 | const gathered = [...first.entries]; |
| 147 | let loadedDepth = 1; |
| 148 | let cursor = first.next_cursor; |
| 149 | let last = first; |
| 150 | for (let index = 1; index < targetDepth && cursor; index++) { |
| 151 | last = await fetchPage(cursor); |
| 152 | if (!currentAccount(account) || request !== generation || last.boundary.account !== account) return; |
| 153 | gathered.push(...last.entries); |
| 154 | loadedDepth++; |
| 155 | cursor = last.next_cursor; |
| 156 | } |
| 157 | const mirror = get(mentionsSnapshot); |
| 158 | if (mirror?.boundary.account === account && mirror.revision > firstSnapshot.revision) { |
| 159 | pendingRevision = Math.max(pendingRevision, mirror.revision); |
| 160 | } |
| 161 | if (firstSnapshot.revision < anchorRevision) return; |
| 162 | captureViewport(); |
| 163 | const unique = new Map<string, MentionEntry>(); |
| 164 | for (const entry of gathered) unique.set(keyOf(entry), entry); |
| 165 | if (mirror?.boundary.account === account) { |
| 166 | for (const update of mirror.entries) { |
| 167 | const key = keyOf(update); |
| 168 | const previous = unique.get(key); |
| 169 | if (previous && update.revision >= previous.revision) unique.set(key, update); |
| 170 | } |
| 171 | } |
| 172 | const replacement = [...unique.values()]; |
| 173 | page = { ...firstSnapshot, next_cursor: last.next_cursor, end_reached: last.end_reached, entries: replacement }; |
| 174 | entries = replacement; |
| 175 | if (selected) { |
| 176 | const latestSelected = replacement.find((entry) => keyOf(entry) === keyOf(selected!)); |
| 177 | if (latestSelected && latestSelected.revision > selected.revision) selected = latestSelected; |
| 178 | void loadContext(selected, false); |
| 179 | } |
| 180 | publishMentionsPage(firstSnapshot); |
| 181 | if (pendingRevision <= firstSnapshot.revision) pendingRevision = 0; |
| 182 | depth = loadedDepth; |
| 183 | succeeded = true; |
| 184 | await restoreViewport(); |
| 185 | } catch (cause) { |
| 186 | if (currentAccount(account) && request === generation) error = cause instanceof Error ? cause.message : 'Could not load mentions'; |
| 187 | } finally { |
| 188 | if (currentAccount(account) && request === generation) { |
| 189 | busy = false; |
| 190 | if (succeeded && pendingRevision > (page?.revision ?? 0)) scheduleRefresh(pendingRevision); |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | function scheduleRefresh(revision: number): void { |
| 195 | pendingRevision = Math.max(pendingRevision, revision); |
| 196 | if (busy || !mounted) return; |
| 197 | if (refreshTimer) clearTimeout(refreshTimer); |
| 198 | refreshTimer = setTimeout(() => { |
| 199 | refreshTimer = null; |
| 200 | if (!alive || busy) return; |
| 201 | const request = ++generation; |
| 202 | void fetchRange(Math.max(1, depth), request, self); |
| 203 | }, 200); |
| 204 | } |
| 205 | async function loadMore(): Promise<void> { |
| 206 | if (!page?.next_cursor || page.end_reached || busy) return; |
| 207 | const cursor = page.next_cursor; |
| 208 | const request = ++generation; |
| 209 | const account = self; |
| 210 | busy = true; |
| 211 | error = ''; |
| 212 | let succeeded = false; |
| 213 | try { |
| 214 | const next = await fetchPage(cursor); |
| 215 | if (!currentAccount(account) || request !== generation || next.boundary.account !== account) return; |
| 216 | const current = page; |
| 217 | if (!current || next.revision < current.revision) return; |
| 218 | captureViewport(); |
| 219 | const unique = new Map(entries.map((entry) => [keyOf(entry), entry])); |
| 220 | for (const entry of next.entries) unique.set(keyOf(entry), entry); |
| 221 | page = { ...current, next_cursor: next.next_cursor, end_reached: next.end_reached, entries: [...unique.values()] }; |
| 222 | entries = page.entries; |
| 223 | const mirror = get(mentionsSnapshot); |
| 224 | if (mirror?.boundary.account === account) { |
| 225 | for (const update of mirror.entries) { |
| 226 | const key = keyOf(update); |
| 227 | const previous = unique.get(key); |
| 228 | if (previous && update.revision >= previous.revision) unique.set(key, update); |
| 229 | } |
| 230 | entries = [...unique.values()]; |
| 231 | page = { ...page, entries }; |
| 232 | } |
| 233 | depth++; |
| 234 | succeeded = true; |
| 235 | await restoreViewport(); |
| 236 | } catch (cause) { |
| 237 | if (currentAccount(account) && request === generation) error = cause instanceof Error ? cause.message : 'Could not load mentions'; |
| 238 | } finally { |
| 239 | if (currentAccount(account) && request === generation) { |
| 240 | busy = false; |
| 241 | if (succeeded && pendingRevision > (page?.revision ?? 0)) scheduleRefresh(pendingRevision); |
| 242 | } |
| 243 | } |
| 244 | } |
| 245 | async function loadContext(entry: MentionEntry, explicit = true): Promise<void> { |
| 246 | const id = keyOf(entry); |
| 247 | if (explicit) pendingExplicitOpen = true; |
| 248 | if (contextRequest?.key === id) { |
| 249 | if (entry.revision > contextRequest.revision) queuedContext = entry; |
| 250 | return; |
| 251 | } |
| 252 | if (contextKey !== id) { |
| 253 | if (!selected) savedListTop = list?.scrollTop ?? savedListTop; |
| 254 | saved = null; |
| 255 | selectedRevision = -1; |
| 256 | } |
| 257 | contextKey = id; |
| 258 | selectedRevision = entry.revision; |
| 259 | selected = entry; |
| 260 | loadingContext = true; |
| 261 | error = ''; |
| 262 | const account = self; |
| 263 | const request = ++contextGeneration; |
| 264 | contextRequest = { key: id, revision: entry.revision }; |
| 265 | try { |
| 266 | const result = await mentionContext(entry.key.room_id, entry.key.event_id); |
| 267 | if (!currentAccount(account) || request !== contextGeneration || contextKey !== id || mentionKey(result.key) !== id) return; |
| 268 | saved = result; |
| 269 | if (pendingExplicitOpen) { |
| 270 | pendingExplicitOpen = false; |
| 271 | const current = entries.find((item) => keyOf(item) === id); |
| 272 | if (current && !current.read) { |
| 273 | await setMentionsRead([current.key], true); |
| 274 | if (currentAccount(account) && request === contextGeneration) scheduleRefresh(get(mentionsSnapshot)?.revision ?? page?.revision ?? 0); |
| 275 | } |
| 276 | } |
| 277 | } catch (cause) { |
| 278 | if (currentAccount(account) && request === contextGeneration) { |
| 279 | pendingExplicitOpen = false; |
| 280 | if (cause instanceof ApiError && cause.status === 404) { |
| 281 | backToList(); |
| 282 | error = 'This mention is no longer available'; |
| 283 | scheduleRefresh(page?.revision ?? 0); |
| 284 | } else { |
| 285 | error = cause instanceof Error ? cause.message : 'Could not load saved context'; |
| 286 | } |
| 287 | } |
| 288 | } finally { |
| 289 | if (currentAccount(account) && request === contextGeneration) { |
| 290 | contextRequest = null; |
| 291 | loadingContext = false; |
| 292 | const queued = queuedContext; |
| 293 | queuedContext = null; |
| 294 | if (queued && keyOf(queued) === id && queued.revision !== selectedRevision) void loadContext(queued, false); |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | async function setRead(entry: MentionEntry, read: boolean): Promise<void> { |
| 299 | if (actionBusy) return; |
| 300 | actionBusy = true; |
| 301 | const account = self; |
| 302 | try { |
| 303 | await setMentionsRead([entry.key], read); |
| 304 | if (!currentAccount(account)) return; |
| 305 | const mirror = get(mentionsSnapshot); |
| 306 | if (mirror?.boundary.account === account) scheduleRefresh(mirror.revision); |
| 307 | else scheduleRefresh((page?.revision ?? 0) + 1); |
| 308 | } catch (cause) { |
| 309 | if (currentAccount(account)) error = cause instanceof Error ? cause.message : 'Could not update mention'; |
| 310 | } finally { |
| 311 | if (currentAccount(account)) actionBusy = false; |
| 312 | } |
| 313 | } |
| 314 | async function markAllRead(): Promise<void> { |
| 315 | if (!page || actionBusy) return; |
| 316 | actionBusy = true; |
| 317 | const account = self; |
| 318 | const boundary = page.boundary; |
| 319 | try { |
| 320 | await markMentionsReadThrough(boundary); |
| 321 | if (!currentAccount(account)) return; |
| 322 | const mirror = get(mentionsSnapshot); |
| 323 | scheduleRefresh(mirror?.boundary.account === account ? mirror.revision : page?.revision ?? 0); |
| 324 | } catch (cause) { |
| 325 | if (currentAccount(account)) error = cause instanceof Error ? cause.message : 'Could not mark mentions read'; |
| 326 | } finally { |
| 327 | if (currentAccount(account)) actionBusy = false; |
| 328 | } |
| 329 | } |
| 330 | async function recover(): Promise<void> { |
| 331 | if (actionBusy) return; |
| 332 | actionBusy = true; |
| 333 | const account = self; |
| 334 | try { |
| 335 | await recoverMentions(); |
| 336 | if (!currentAccount(account)) return; |
| 337 | scheduleRefresh(get(mentionsSnapshot)?.revision ?? page?.revision ?? 0); |
| 338 | } catch (cause) { |
| 339 | if (currentAccount(account)) error = cause instanceof Error ? cause.message : 'Could not recover mention history'; |
| 340 | } finally { |
| 341 | if (currentAccount(account)) actionBusy = false; |
| 342 | } |
| 343 | } |
| 344 | async function retryCapture(): Promise<void> { |
| 345 | if (!selected || !selectedRoom || actionBusy || loadingContext) return; |
| 346 | actionBusy = true; |
| 347 | const request = contextGeneration; |
| 348 | const account = self; |
| 349 | try { |
| 350 | const result = await retryMentionContext(selected.key.room_id, selected.key.event_id); |
| 351 | if (!currentAccount(account) || request !== contextGeneration || mentionKey(result.key) !== contextKey) return; |
| 352 | saved = result; |
| 353 | scheduleRefresh(get(mentionsSnapshot)?.revision ?? page?.revision ?? 0); |
| 354 | } catch (cause) { |
| 355 | if (currentAccount(account) && request === contextGeneration) error = cause instanceof Error ? cause.message : 'Could not retry context capture'; |
| 356 | } finally { |
| 357 | if (currentAccount(account)) actionBusy = false; |
| 358 | } |
| 359 | } |
| 360 | async function retryEntry(entry: MentionEntry): Promise<void> { |
| 361 | if (selected?.revision !== entry.revision || keyOf(selected) !== keyOf(entry)) await loadContext(entry, false); |
| 362 | else await tick(); |
| 363 | await retryCapture(); |
| 364 | } |
| 365 | function backToList(): void { |
| 366 | contextGeneration++; |
| 367 | contextRequest = null; |
| 368 | queuedContext = null; |
| 369 | loadingContext = false; |
| 370 | pendingExplicitOpen = false; |
| 371 | selected = null; |
| 372 | saved = null; |
| 373 | contextKey = ''; |
| 374 | } |
| 375 | |
| 376 | const unsubscribe = mentionsSnapshot.subscribe((snapshot) => { |
| 377 | if (!snapshot || snapshot.boundary.account !== self) return; |
| 378 | if (snapshot.revision < (page?.revision ?? -1)) return; |
| 379 | const updated = new Map(snapshot.entries.map((entry) => [keyOf(entry), entry])); |
| 380 | if (entries.length) { |
| 381 | captureViewport(); |
| 382 | entries = entries.map((entry) => { |
| 383 | const update = updated.get(keyOf(entry)); |
| 384 | return update && update.revision >= entry.revision ? update : entry; |
| 385 | }); |
| 386 | if (page) page = { ...page, entries }; |
| 387 | void restoreViewport(); |
| 388 | } |
| 389 | if (selected) { |
| 390 | const update = snapshot.entries.find((entry) => keyOf(entry) === keyOf(selected!)); |
| 391 | if (update && update.revision > selected.revision) selected = update; |
| 392 | } |
| 393 | if (busy) { |
| 394 | pendingRevision = Math.max(pendingRevision, snapshot.revision); |
| 395 | return; |
| 396 | } |
| 397 | if (!page || snapshot.revision > page.revision) scheduleRefresh(snapshot.revision); |
| 398 | }); |
| 399 | $: if (self && self !== renderedAccount) { |
| 400 | generation++; |
| 401 | contextGeneration++; |
| 402 | page = null; |
| 403 | entries = []; |
| 404 | error = ''; |
| 405 | selected = null; |
| 406 | saved = null; |
| 407 | depth = 1; |
| 408 | busy = false; |
| 409 | actionBusy = false; |
| 410 | loadingContext = false; |
| 411 | contextRequest = null; |
| 412 | queuedContext = null; |
| 413 | pendingExplicitOpen = false; |
| 414 | pendingRevision = 0; |
| 415 | savedListTop = 0; |
| 416 | roomProfiles = {}; |
| 417 | profileRequests = new Set(); |
| 418 | if (refreshTimer) clearTimeout(refreshTimer); |
| 419 | renderedAccount = self; |
| 420 | if (mounted) void fetchRange(1, generation, self); |
| 421 | } |
| 422 | $: if (selected && selected.revision !== selectedRevision) void loadContext(selected, false); |
| 423 | onMount(() => { |
| 424 | mounted = true; |
| 425 | if (!page) void fetchRange(1, ++generation, self); |
| 426 | }); |
| 427 | onDestroy(() => { |
| 428 | alive = false; |
| 429 | generation++; |
| 430 | contextGeneration++; |
| 431 | unsubscribe(); |
| 432 | observer?.disconnect(); |
| 433 | viewport?.releaseScroll(); |
| 434 | if (refreshTimer) clearTimeout(refreshTimer); |
| 435 | }); |
| 436 | </script> |
| 437 | |
| 438 | <section class="mentions-inbox"> |
| 439 | {#if selected} |
| 440 | {#if error}<div class="error" role="alert"><EmojiText text={error} /><button disabled={loadingContext} onclick={() => void loadContext(selected!)}>Retry</button></div>{/if} |
| 441 | {#if saved?.context} |
| 442 | <ContextTimeline context={contextWithProfiles(saved.context, roomProfiles[selected.key.room_id])} roomId={selected.key.room_id} eventId={selected.key.event_id} roomName={roomName(selected)} {self} canPaginate={contextCanPaginate} onClose={backToList} {onLive} /> |
| 443 | {#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} |
| 444 | {#if !contextCanPaginate}<p class="status">This room is no longer joined. Saved context remains available.</p>{/if} |
| 445 | {#if saved.state.kind === 'Partial'}<p class="status">This saved context is partial.</p>{/if} |
| 446 | {#if saved.state.kind === 'Pending'}<p class="status">Saving context…</p>{/if} |
| 447 | {#if saved.state.kind === 'Failed'}<p class="status" role="alert"><EmojiText text={saved.state.message} /></p>{/if} |
| 448 | {:else} |
| 449 | <header><button onclick={backToList}>Back to mentions</button><strong><EmojiText text={roomName(selected)} /></strong></header> |
| 450 | {#if loadingContext}<p class="status">Loading saved context…</p> |
| 451 | {:else if saved?.state.kind === 'Failed'}<p class="status" role="alert"><EmojiText text={saved.state.message} /></p> |
| 452 | {:else if saved?.state.kind === 'Unavailable'}<p class="status">Saved context is unavailable.</p> |
| 453 | {:else if saved?.state.kind === 'Partial'}<p class="status">Saved context is partial and currently unavailable.</p> |
| 454 | {:else}<p class="status">Saved context is not available yet.</p>{/if} |
| 455 | {@const preview = itemEvent(withProfiles(selected.event, roomProfiles[selected.key.room_id]))} |
| 456 | {#if preview}<MessageRow itemId={preview.item_id} event={preview} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} senderAvatar={preview.sender.avatar} />{/if} |
| 457 | {#if selected.capture.kind === 'Failed' || selected.capture.kind === 'Partial' || selected.capture.kind === 'Unavailable'} |
| 458 | <button disabled={!selectedRoom || actionBusy} onclick={retryCapture}>Retry context capture</button> |
| 459 | {/if} |
| 460 | {/if} |
| 461 | {:else} |
| 462 | <header><h2>Mentions {#if page}<span>({unreadCount} unread)</span>{/if}</h2><button disabled={!page || unreadCount === 0 || actionBusy} onclick={markAllRead}>Mark all read</button></header> |
| 463 | <details class="coverage"> |
| 464 | <summary>{page?.coverage.recovering ? 'Recovering mention history…' : 'History coverage'}{#if page?.coverage.gaps.length} · Incomplete{/if}</summary> |
| 465 | <div class="coverage-body"> |
| 466 | {#if page} |
| 467 | {#if page.coverage.recovering}<p>History recovery is in progress.</p>{/if} |
| 468 | {#if page.coverage.truncated}<p>Results are truncated.</p>{/if} |
| 469 | {#if page.coverage.historical_supported === false}<p>Historical mention search is unsupported.</p>{/if} |
| 470 | {#if page.coverage.unresolved_candidates}<p>{page.coverage.unresolved_candidates} possible mentions could not be resolved.</p>{/if} |
| 471 | {#if page.coverage.gaps.length}<p>{page.coverage.gaps.length} history gaps remain.</p>{/if} |
| 472 | {#if page.coverage.earliest_timestamp_ms}<p>Oldest observed event from {new Date(page.coverage.earliest_timestamp_ms).toLocaleString()}.</p>{/if} |
| 473 | {#each page.coverage.gaps as gap}<p><EmojiText text={gap.room_id ? joinedRooms.get(gap.room_id)?.name ?? gap.room_id : 'Unknown room'} /> · {gapLabel(gap.reason)}</p>{/each} |
| 474 | {:else}<p>Coverage is loading.</p>{/if} |
| 475 | <button disabled={actionBusy || page?.coverage.recovering} onclick={recover}>Recover history</button> |
| 476 | </div> |
| 477 | </details> |
| 478 | {#if error}<div class="error" role="alert"><EmojiText text={error} /><button disabled={busy} onclick={() => fetchRange(depth, ++generation, self)}>Retry</button></div>{/if} |
| 479 | <div class="entries" use:bindViewport onscroll={() => { if (!viewport?.consumeScroll()) viewport?.capture(); }}> |
| 480 | <div class="entry-content"> |
| 481 | {#each entries as entry (keyOf(entry))} |
| 482 | {@const event = itemEvent(withProfiles(entry.event, roomProfiles[entry.key.room_id]))} |
| 483 | <article class:unread={!entry.read}> |
| 484 | <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> |
| 485 | <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> |
| 486 | {#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} |
| 487 | <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> |
| 488 | </article> |
| 489 | {/each} |
| 490 | </div> |
| 491 | </div> |
| 492 | <footer><button disabled={!page?.next_cursor || page.end_reached || busy} onclick={loadMore}>{busy ? 'Loading…' : 'Load older mentions'}</button></footer> |
| 493 | {#if entries.length === 0 && !busy && !error}<p class="status">No mentions yet.</p>{/if} |
| 494 | {/if} |
| 495 | </section> |
| 496 | |
| 497 | <style> |
| 498 | .mentions-inbox { display:flex; flex-direction:column; min-width:0; min-height:0; height:100%; color:var(--text); } |
| 499 | header { display:flex; align-items:center; gap:.6rem; padding:.6rem; border-bottom:1px solid var(--border); } |
| 500 | header h2 { flex:1; margin:0; font-size:1rem; } |
| 501 | header h2 span { color:var(--muted); font-size:.85em; } |
| 502 | button { color:var(--text); background:var(--panel); border:1px solid var(--border); border-radius:.4rem; padding:.4rem .65rem; cursor:pointer; } |
| 503 | button:disabled { opacity:.55; cursor:default; } |
| 504 | .entries { flex:1; min-height:0; overflow:auto; overflow-anchor:none; padding:.5rem; } |
| 505 | article { border:1px solid var(--border); border-radius:.6rem; padding:.5rem; margin-bottom:.6rem; } |
| 506 | article.unread { border-color:var(--accent); } |
| 507 | .entry-head,.entry-actions,.labels { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; } |
| 508 | .entry-head .room { font-weight:600; text-align:left; } |
| 509 | .entry-head time,.entry-head span { color:var(--muted); font-size:.8rem; } |
| 510 | .entry-head time { margin-left:auto; } |
| 511 | .labels { margin:.35rem 0; color:var(--muted); font-size:.78rem; } |
| 512 | .labels span { border:1px solid var(--border); border-radius:1rem; padding:.1rem .4rem; } |
| 513 | .entry-actions { justify-content:flex-end; margin-top:.4rem; } |
| 514 | footer { display:flex; justify-content:center; padding:.45rem; } |
| 515 | .coverage { padding:.55rem .75rem; background:var(--panel); border-bottom:1px solid var(--border); } |
| 516 | .coverage-body { max-height:min(15rem, 35vh); overflow:auto; } |
| 517 | .coverage p,.status { margin:.25rem 0; color:var(--muted); } |
| 518 | .error { display:flex; align-items:center; gap:.5rem; padding:.5rem; color:var(--danger); } |
| 519 | .capture-retry { align-self:flex-start; margin:.4rem; } |
| 520 | </style> |