| 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> |