Commit 9f996e377406
tarball10 files changed+607-5
@@ -4,6 +4,8 @@
44
import LoginPanel from './lib/LoginPanel.svelte';55
import MessageRow from './lib/MessageRow.svelte';66
import MentionsInbox from './lib/MentionsInbox.svelte';7
+ import HistoryRepairPanel from './lib/HistoryRepairPanel.svelte';8
+ import type { HistoryRepairPolicy, HistoryRepairReport } from './lib/repair-model';79
import MxcAvatar from './lib/MxcAvatar.svelte';810
import PinButton from './lib/PinButton.svelte';911
import ContextMenu, { type MenuItem } from './lib/ContextMenu.svelte';@@ -26,6 +28,10 @@
2628
getRooms,2729
getSession,2830
getSpaceChildren,31
+ historyRepair,32
+ continueHistoryRepair,33
+ cancelHistoryRepair,34
+ startRecentHistory,2935
joinRoom,3036
leaveRoom,3137
logout,@@ -60,6 +66,8 @@
6066
mentionsSnapshot,6167
pushBanner,6268
imagePacks,69
+ historyRepairs,70
+ publishHistoryRepair,6371
resetClientState,6472
rooms,6573
selectedRoomId,@@ -67,6 +75,7 @@
6775
softLogout,6876
timelines,6977
pinnedTimelines,78
+ pinnedTimelineVersions,7079
verification7180
} from './lib/store';7281
import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';@@ -165,6 +174,115 @@
165174
let spaceChildrenError: string | null = null;166175
let avatarByUser: Record<string, string | null> = {};167176
let restoringRoute = false;177
+ let repairPanels: Record<string, boolean> = {};178
+ let repairBusy: Record<string, boolean> = {};179
+ let repairCancelling: Record<string, boolean> = {};180
+ let repairErrors: Record<string, string | null> = {};181
+ let repairJumps: Record<string, { previous: HistoryRepairReport | null }> = {};182
+ let repairAccount: string | null | undefined;183
+ let repairEpoch = 0;184
+ $: if ($sessionUser !== repairAccount) {185
+ repairAccount = $sessionUser;186
+ repairEpoch++;187
+ repairPanels = {};188
+ repairBusy = {};189
+ repairCancelling = {};190
+ repairErrors = {};191
+ repairJumps = {};192
+ }193
+ $: currentRepair = $selectedRoomId ? $historyRepairs[$selectedRoomId] ?? null : null;194
+ $: currentRepairActive = !!($selectedRoomId && repairBusy[$selectedRoomId]) || repairActive(currentRepair);195
+ $: maybeJumpAfterRepair($selectedRoomId, currentRepair, repairJumps);196
+ $: settleRepairCancellation($historyRepairs, repairBusy);197
+198
+ function repairActive(report: HistoryRepairReport | null | undefined): boolean {199
+ return report?.phase === 'FetchingRecent' || report?.phase === 'Bridging';200
+ }201
+202
+ function settleRepairCancellation(reports: Record<string, HistoryRepairReport>, busy: Record<string, boolean>): void {203
+ for (const [roomId, report] of Object.entries(reports)) {204
+ if (repairCancelling[roomId] && !busy[roomId] && !repairActive(report)) {205
+ repairCancelling = { ...repairCancelling, [roomId]: false };206
+ }207
+ }208
+ }209
+210
+ function maybeJumpAfterRepair(roomId: string | null, report: HistoryRepairReport | null, jumps: typeof repairJumps): void {211
+ if (!roomId || roomId !== $selectedRoomId || !report?.recent_available || !jumps[roomId] || report === jumps[roomId].previous) return;212
+ const next = { ...repairJumps };213
+ delete next[roomId];214
+ repairJumps = next;215
+ stickToBottom = true;216
+ const epoch = repairEpoch;217
+ void tick().then(() => {218
+ if (epoch === repairEpoch && $selectedRoomId === roomId) scrollToBottom();219
+ });220
+ }221
+222
+ async function showHistoryRepair(roomId: string): Promise<void> {223
+ if ($selectedRoomId !== roomId) await selectRoom(roomId);224
+ if ($selectedRoomId === roomId) repairPanels = { ...repairPanels, [roomId]: true };225
+ }226
+227
+ async function runHistoryRepair(roomId: string, action: 'start' | 'continue' | 'recent', policy: HistoryRepairPolicy | null = null): Promise<void> {228
+ if (!$sessionUser || repairBusy[roomId] || repairActive($historyRepairs[roomId]) || repairCancelling[roomId]) return;229
+ const account = $sessionUser;230
+ const epoch = repairEpoch;231
+ repairPanels = { ...repairPanels, [roomId]: true };232
+ repairBusy = { ...repairBusy, [roomId]: true };233
+ repairErrors = { ...repairErrors, [roomId]: null };234
+ if (action !== 'continue') repairJumps = { ...repairJumps, [roomId]: { previous: $historyRepairs[roomId] ?? null } };235
+ try {236
+ if (!openReady[roomId]) {237
+ await openRoom(roomId);238
+ if ($sessionUser !== account || epoch !== repairEpoch) return;239
+ openReady = { ...openReady, [roomId]: true };240
+ const failures = { ...openFailed };241
+ delete failures[roomId];242
+ openFailed = failures;243
+ touchWarmRoom(roomId);244
+ }245
+ if (repairCancelling[roomId]) return;246
+ const report = action === 'continue' ? await continueHistoryRepair(roomId)247
+ : action === 'recent' ? await startRecentHistory(roomId) : await historyRepair(roomId, policy);248
+ if ($sessionUser !== account || epoch !== repairEpoch) return;249
+ if (report.room_id !== roomId) throw new Error('History repair returned a different room');250
+ publishHistoryRepair(report, account);251
+ maybeJumpAfterRepair(roomId, report, repairJumps);252
+ } catch (cause) {253
+ if ($sessionUser === account && epoch === repairEpoch) {254
+ repairErrors = { ...repairErrors, [roomId]: cause instanceof Error ? cause.message : 'Could not repair history' };255
+ }256
+ } finally {257
+ if ($sessionUser === account && epoch === repairEpoch) {258
+ repairBusy = { ...repairBusy, [roomId]: false };259
+ repairCancelling = { ...repairCancelling, [roomId]: false };260
+ if (repairJumps[roomId]) {261
+ const next = { ...repairJumps };262
+ delete next[roomId];263
+ repairJumps = next;264
+ }265
+ }266
+ }267
+ }268
+269
+ async function cancelRoomRepair(roomId: string): Promise<void> {270
+ if (!$sessionUser || repairCancelling[roomId]) return;271
+ const account = $sessionUser;272
+ const epoch = repairEpoch;273
+ repairCancelling = { ...repairCancelling, [roomId]: true };274
+ try {275
+ await cancelHistoryRepair(roomId);276
+ if ($sessionUser === account && epoch === repairEpoch && !repairBusy[roomId] && !repairActive($historyRepairs[roomId])) {277
+ repairCancelling = { ...repairCancelling, [roomId]: false };278
+ }279
+ } catch (cause) {280
+ if ($sessionUser === account && epoch === repairEpoch) {281
+ repairCancelling = { ...repairCancelling, [roomId]: false };282
+ repairErrors = { ...repairErrors, [roomId]: cause instanceof Error ? cause.message : 'Could not cancel history repair' };283
+ }284
+ }285
+ }168286
169287
function motionDuration(ms: number): number {170288
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : ms;@@ -643,6 +761,11 @@
643761
644762
function noteUserScroll(): void {645763
timelineViewport?.releaseScroll();764
+ if ($selectedRoomId && repairJumps[$selectedRoomId]) {765
+ const next = { ...repairJumps };766
+ delete next[$selectedRoomId];767
+ repairJumps = next;768
+ }646769
}647770
648771
function syncComposer(node: HTMLTextAreaElement) {@@ -1025,15 +1148,18 @@
10251148
}10261149
10271150
let pinnedOpenFor: string | null = null;1151
+ let pinnedCloseVersion = 0;10281152
let pinsExpanded = false;10291153
let pinPreview: TimelineEvent | null = null;10301154
let pinJumpBusy = false;1031
- $: syncPinned(selectedRoom?.room_id ?? null, (selectedRoom?.pinned_events.length ?? 0) > 0);1032
- function syncPinned(roomId: string | null, hasPins: boolean): void {1155
+ $: syncPinned(selectedRoom?.room_id ?? null, (selectedRoom?.pinned_events.length ?? 0) > 0, $selectedRoomId ? $pinnedTimelineVersions[$selectedRoomId] ?? 0 : 0, currentRepairActive);1156
+ function syncPinned(roomId: string | null, hasPins: boolean, version: number, repairing: boolean): void {10331157
const want = hasPins ? roomId : null;1034
- if (want === pinnedOpenFor) return;1158
+ if (want === pinnedOpenFor && version === pinnedCloseVersion) return;1159
+ if (want && repairing) return;10351160
if (pinnedOpenFor) runCommand({ op: 'close_pinned', room_id: pinnedOpenFor }).catch(() => undefined);10361161
pinnedOpenFor = want;1162
+ pinnedCloseVersion = version;10371163
pinsExpanded = false;10381164
if (want) runCommand({ op: 'open_pinned', room_id: want }).catch(() => undefined);10391165
}@@ -1331,7 +1457,7 @@
13311457
13321458
async function paginate(): Promise<void> {13331459
const roomId = $selectedRoomId;1334
- if (!roomId || paginateBusy) return;1460
+ if (!roomId || paginateBusy || currentRepairActive) return;13351461
paginateBusy = true;13361462
try {13371463
await paginateRoom(roomId);@@ -1579,6 +1705,9 @@
15791705
items.push({ label: room.favourite ? 'Unpin' : room.space ? 'Pin to top' : 'Pin', action: () => void togglePin(room) });15801706
items.push({ label: 'Copy link', action: () => copyText(`https://matrix.to/#/${room.canonical_alias ?? room.room_id}`) });15811707
items.push({ label: 'Copy room ID', action: () => copyText(room.room_id) });1708
+ if (!room.space && room.membership === 'Joined') {1709
+ items.push({ label: 'Repair message history', action: () => void showHistoryRepair(room.room_id) });1710
+ }15821711
if (room.membership === 'Joined') items.push({ label: 'Leave', danger: true, divider: true, action: () => void leave(room.room_id) });15831712
return items;15841713
}@@ -1976,6 +2105,9 @@
19762105
{#if roomMenuOpen}19772106
<div class="room-menu" role="menu">19782107
{#if selectedRoom.membership === 'Joined'}2108
+ <button type="button" role="menuitem" on:click={() => roomMenuAction(() => void showHistoryRepair(selectedRoom.room_id))}>2109
+ Repair message history2110
+ </button>19792111
<button19802112
type="button"19812113
role="menuitem"@@ -2328,6 +2460,19 @@
23282460
</p>23292461
</div>23302462
{:else}2463
+ {#if repairPanels[selectedRoom.room_id] ?? !!currentRepair}2464
+ <HistoryRepairPanel2465
+ report={currentRepair}2466
+ busy={!!repairBusy[selectedRoom.room_id]}2467
+ cancelling={!!repairCancelling[selectedRoom.room_id]}2468
+ error={repairErrors[selectedRoom.room_id] ?? null}2469
+ onStart={(policy) => void runHistoryRepair(selectedRoom.room_id, 'start', policy)}2470
+ onCancel={() => void cancelRoomRepair(selectedRoom.room_id)}2471
+ onContinue={() => void runHistoryRepair(selectedRoom.room_id, 'continue')}2472
+ onRecent={() => void runHistoryRepair(selectedRoom.room_id, 'recent')}2473
+ onClose={() => (repairPanels = { ...repairPanels, [selectedRoom.room_id]: false })}2474
+ />2475
+ {/if}23312476
{#if pins.length}23322477
<div class="pinned-strip" class:open={pinsExpanded}>23332478
<div class="pinned-head">@@ -2431,7 +2576,7 @@
24312576
{#if hasStart}24322577
<p class="start-note">Beginning of history</p>24332578
{:else}2434
- <button type="button" class="ghost-btn" disabled={paginateBusy} on:click={() => void paginate()}>2579
+ <button type="button" class="ghost-btn" disabled={paginateBusy || currentRepairActive} on:click={() => void paginate()}>24352580
{paginateBusy ? 'Loading…' : 'Load older messages'}24362581
</button>24372582
{/if}@@ -0,0 +1,148 @@
1
+<script lang="ts">2
+ import EmojiText from './EmojiText.svelte';3
+ import type { HistoryRepairPolicy, HistoryRepairReport } from './repair-model';4
+5
+ export let report: HistoryRepairReport | null = null;6
+ export let busy = false;7
+ export let cancelling = false;8
+ export let error: string | null = null;9
+ export let onStart: (policy: HistoryRepairPolicy | null) => void;10
+ export let onCancel: () => void;11
+ export let onContinue: () => void;12
+ export let onRecent: () => void;13
+ export let onClose: () => void;14
+15
+ let expanded = true;16
+ let choice: 'saved' | 'StartRecent' | 'Bridge' | 'BridgeShortGaps' = 'saved';17
+ let threshold: number | undefined = 15000;18
+19
+ $: active = busy || cancelling || report?.phase === 'FetchingRecent' || report?.phase === 'Bridging';20
+ $: canRepair = choice !== 'BridgeShortGaps' || (threshold !== undefined && Number.isSafeInteger(threshold) && threshold >= 0);21
+ $: serverFailure = serverError(report?.failure?.server);22
+ $: paused = !active && report?.phase === 'Paused';23
+ $: canContinue = paused && report?.strategy === 'Bridge';24
+25
+ function serverError(value: unknown): string {26
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return '';27
+ const server = value as Record<string, unknown>;28
+ const status = server.status;29
+ const code = server.errcode;30
+ const message = server.message;31
+ if ((typeof status !== 'number' || !Number.isFinite(status)) && typeof code !== 'string' && typeof message !== 'string') return '';32
+ const parts = [typeof status === 'number' ? `HTTP ${status}` : '', typeof code === 'string' ? code : '', typeof message === 'string' ? message : ''];33
+ return parts.filter(Boolean).join(' · ');34
+ }35
+36
+ function policy(): HistoryRepairPolicy | null {37
+ if (choice === 'saved') return null;38
+ if (choice === 'BridgeShortGaps') return { BridgeShortGaps: { threshold_ms: threshold! } };39
+ return choice;40
+ }41
+42
+ function stopLabel(value: NonNullable<HistoryRepairReport['stop']>): string {43
+ const labels: Record<string, string> = {44
+ Budget: 'Paused at the repair budget',45
+ Cancelled: 'Repair cancelled',46
+ NoAnchor: 'No retained history anchor was found',47
+ HistoryUnavailable: 'History is unavailable',48
+ CursorStalled: 'Repair made no progress'49
+ };50
+ return labels[value];51
+ }52
+53
+ function continuityLabel(value: HistoryRepairReport['continuity']): string {54
+ if (value === 'Unresolved') return 'Continuity unresolved';55
+ if (value === 'Verified') return 'Continuity verified';56
+ if (value === 'AccessibleStart') return 'Beginning of accessible history reached';57
+ return value;58
+ }59
+60
+ function start(): void {61
+ if (canRepair) onStart(policy());62
+ }63
+</script>64
+65
+<section class="repair" aria-label="History repair">66
+ <div class="banner">67
+ <button type="button" class="summary" aria-expanded={expanded} on:click={() => expanded = !expanded}>68
+ <span>History repair</span>69
+ {#if active}<span class="muted">{cancelling ? 'Cancelling…' : report?.phase === 'Bridging' ? 'Bridging history…' : 'Loading recent messages…'}</span>{/if}70
+ {#if report?.recent_available === true}<span class="recent">Recent messages available</span>71
+ {:else if report?.recent_available === false}<span class="muted">Recent messages unavailable</span>72
+ {:else}<span class="muted">{active ? 'Repair in progress' : 'History recovery'}</span>{/if}73
+ {#if report?.recent_available === true && report.continuity === 'Unresolved'}<span class="muted">Continuity unresolved</span>{/if}74
+ <span aria-hidden="true">{expanded ? '▴' : '▾'}</span>75
+ </button>76
+ <button type="button" class="close" aria-label="Close history repair" on:click={onClose}>✕</button>77
+ </div>78
+ {#if expanded}79
+ <div class="details" aria-live="polite">80
+ {#if report}81
+ <p class="state">{#if active}{report.phase === 'FetchingRecent' ? 'Loading recent messages' : 'Bridging retained history'}{:else if report.recent_available}Recent messages are available{:else}Recent messages could not be restored{/if}</p>82
+ <p>{continuityLabel(report.continuity)}{report.continuity === 'Unresolved' ? '. The missing interval has not been linked to retained history.' : '.'}</p>83
+ <p>{report.strategy === 'Recent' ? 'Recent history' : report.strategy === 'Overlap' ? 'Retained overlap' : 'Bridge progress'} · {report.pages} pages and {report.events} events</p>84
+ {#if report.coverage_duration_ms !== null}<p>Gap duration {report.coverage_duration_ms} ms</p>{/if}85
+ {#if report.stop}<p>{stopLabel(report.stop)}</p>{/if}86
+ {#if report.phase === 'Paused' && report.strategy === 'Bridge'}<p class="muted">Keep this room open to continue bridging. Closing it or restarting the app clears unfinished progress.</p>{/if}87
+ {#if report.failure}88
+ <p class="failure"><EmojiText text={report.failure.message} /></p>89
+ {#if serverFailure}<p class="server-error"><EmojiText text={serverFailure} /></p>{/if}90
+ {/if}91
+ {#if !report.threshold_evaluated && typeof report.requested_policy !== 'string'}92
+ <p>The requested threshold of {report.requested_policy.BridgeShortGaps.threshold_ms} ms was not evaluated. Recent messages were used.</p>93
+ {/if}94
+ {/if}95
+ {#if error}<p class="failure" role="alert"><EmojiText text={error} /></p>{/if}96
+ {#if !active}97
+ <label class="field">Repair policy98
+ <select bind:value={choice}>99
+ <option value="saved">Use saved policy</option>100
+ <option value="StartRecent">Start recent</option>101
+ <option value="Bridge">Bridge retained history</option>102
+ <option value="BridgeShortGaps">Bridge short gaps</option>103
+ </select>104
+ </label>105
+ {#if choice === 'BridgeShortGaps'}106
+ <label class="field">Threshold in milliseconds107
+ <input type="number" min="0" step="1" bind:value={threshold} />108
+ </label>109
+ <p class="muted">The threshold cannot yet be evaluated, so this currently starts at recent messages. A threshold of zero disables automatic bridging. Bridge retained history remains available.</p>110
+ {/if}111
+ {/if}112
+ <div class="actions">113
+ {#if active}114
+ <button type="button" class="ghost-btn" disabled={cancelling} on:click={onCancel}>{cancelling ? 'Cancelling…' : 'Cancel repair'}</button>115
+ {#if cancelling}<span role="status">Waiting for the active repair to stop</span>{/if}116
+ {:else}117
+ <button type="button" class="primary-btn" disabled={!canRepair} on:click={start}>Repair history</button>118
+ {#if canContinue}119
+ <button type="button" class="ghost-btn" on:click={onContinue}>Continue bridging</button>120
+ {/if}121
+ {#if paused}122
+ <button type="button" class="ghost-btn" on:click={onRecent}>Use recent window</button>123
+ {/if}124
+ {/if}125
+ <button type="button" class="ghost-btn" on:click={onClose}>Close</button>126
+ </div>127
+ </div>128
+ {/if}129
+</section>130
+131
+<style>132
+ .repair { max-height: min(42vh, 320px); overflow: auto; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); color: var(--text); }133
+ .banner { display: flex; align-items: center; gap: 6px; padding: 6px; }134
+ .summary { display: flex; flex: 1; min-width: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 5px 7px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }135
+ .summary span:first-child { font-weight: 600; }136
+ .recent { color: var(--ok-text); font-size: 12px; }137
+ .muted { color: var(--muted); font-size: 12px; }138
+ .close { border: 0; background: transparent; color: var(--muted); cursor: pointer; }139
+ .details { overflow: auto; padding: 0 12px 12px; font-size: 13px; }140
+ .details p { margin: 7px 0; }141
+ .state { font-weight: 600; }142
+ .field { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }143
+ select, input { width: 100%; min-height: var(--ctl-h); padding: 5px 7px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg-soft); color: var(--text); font: inherit; }144
+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; }145
+ .actions [role='status'] { color: var(--muted); }146
+ .failure { color: var(--danger-text); }147
+ @media (max-width: 520px) { .summary { flex-wrap: wrap; justify-content: flex-start; } }148
+</style>@@ -0,0 +1,103 @@
1
+<script lang="ts">2
+ import { get } from 'svelte/store';3
+ import { onMount } from 'svelte';4
+ import { historyRepairPolicy, setHistoryRepairPolicy } from './api';5
+ import { sessionUser } from './store';6
+ import type { HistoryRepairPolicy } from './repair-model';7
+8
+ let selected: 'StartRecent' | 'Bridge' | 'BridgeShortGaps' = 'StartRecent';9
+ let threshold: number | undefined = 15000;10
+ let loading = true;11
+ let loaded = false;12
+ let busy = false;13
+ let error = '';14
+ let notice = '';15
+ let alive = true;16
+17
+ async function loadPolicy(): Promise<void> {18
+ const account = get(sessionUser);19
+ if (!account) {20
+ loading = false;21
+ loaded = false;22
+ error = 'Sign in to load the saved history repair policy.';23
+ return;24
+ }25
+ loading = true;26
+ loaded = false;27
+ error = '';28
+ try {29
+ const policy = await historyRepairPolicy();30
+ if (!alive || get(sessionUser) !== account) return;31
+ if (policy === 'StartRecent' || policy === 'Bridge') selected = policy;32
+ else {33
+ selected = 'BridgeShortGaps';34
+ threshold = policy.BridgeShortGaps.threshold_ms;35
+ }36
+ loaded = true;37
+ } catch (cause) {38
+ if (alive && get(sessionUser) === account) error = cause instanceof Error ? cause.message : 'Could not load the saved history repair policy.';39
+ } finally {40
+ if (alive && get(sessionUser) === account) loading = false;41
+ }42
+ }43
+44
+ onMount(() => {45
+ void loadPolicy();46
+ return () => { alive = false; };47
+ });48
+49
+ async function save(): Promise<void> {50
+ error = '';51
+ notice = '';52
+ const ms = threshold;53
+ if (!loaded || busy || loading) return;54
+ if (selected === 'BridgeShortGaps' && (ms === undefined || !Number.isSafeInteger(ms) || ms < 0)) {55
+ error = 'Enter a whole number of milliseconds that is zero or greater.';56
+ return;57
+ }58
+ const account = get(sessionUser);59
+ if (!account) return;60
+ const policy: HistoryRepairPolicy = selected === 'BridgeShortGaps'61
+ ? { BridgeShortGaps: { threshold_ms: ms! } }62
+ : selected;63
+ busy = true;64
+ try {65
+ await setHistoryRepairPolicy(policy);66
+ if (alive && get(sessionUser) === account) notice = 'History repair policy saved.';67
+ } catch (cause) {68
+ if (alive && get(sessionUser) === account) error = cause instanceof Error ? cause.message : 'Could not save the history repair policy.';69
+ } finally {70
+ if (alive && get(sessionUser) === account) busy = false;71
+ }72
+ }73
+</script>74
+75
+<section aria-label="History repair settings">76
+ <h3 class="sec-h">History repair</h3>77
+ <label class="field">Saved policy78
+ <select bind:value={selected} disabled={loading || busy}>79
+ <option value="StartRecent">Start recent</option>80
+ <option value="Bridge">Bridge retained history</option>81
+ <option value="BridgeShortGaps">Bridge short gaps</option>82
+ </select>83
+ </label>84
+ {#if selected === 'BridgeShortGaps'}85
+ <label class="field">Gap threshold in milliseconds86
+ <input type="number" min="0" step="1" bind:value={threshold} disabled={loading || busy} />87
+ </label>88
+ <p class="note">The threshold cannot yet be evaluated, so this policy currently starts at recent messages. A threshold of zero disables automatic bridging. Choose Bridge retained history to bridge explicitly.</p>89
+ {/if}90
+ <button type="button" class="primary-btn" disabled={!loaded || loading || busy || (selected === 'BridgeShortGaps' && (threshold === undefined || !Number.isSafeInteger(threshold) || threshold < 0))} on:click={save}>{busy ? 'Saving…' : 'Save'}</button>91
+ {#if !loaded && !loading}<button type="button" class="ghost-btn" disabled={busy} on:click={() => void loadPolicy()}>Retry loading saved policy</button>{/if}92
+ {#if error}<p class="error" role="alert">{error}</p>{/if}93
+ {#if notice}<p class="notice" role="status">{notice}</p>{/if}94
+</section>95
+96
+<style>97
+ section { margin-top: 18px; }98
+ .sec-h { margin: 0 0 8px; color: var(--muted); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; }99
+ .field { display: flex; flex-direction: column; gap: 6px; margin: 10px 0; font-size: 13px; }100
+ select, input { width: 100%; min-height: var(--ctl-h); padding: 6px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg-soft); color: var(--text); font: inherit; }101
+ .note, .notice { color: var(--muted); font-size: 13px; }102
+ .error { color: var(--danger-text); background: var(--danger-bg); padding: 8px; border-radius: var(--r-sm); }103
+</style>@@ -13,6 +13,7 @@
1313
import { DEFAULT_QUICK_REACTIONS } from './quick-reactions';1414
import ReactionPicker from './ReactionPicker.svelte';1515
import ThemeSettings from './ThemeSettings.svelte';16
+ import HistoryRepairSettings from './HistoryRepairSettings.svelte';1617
import type { ImagePack, QuickReaction } from './types';1718
1819
export let quick: QuickReaction[];@@ -35,6 +36,7 @@
3536
</div>3637
3738
<ThemeSettings />39
+ <HistoryRepairSettings />3840
3941
<section aria-label="Message spacing">4042
<h3 class="sec-h">Space between messages</h3>@@ -3,12 +3,16 @@
33
import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from './types';44
import type { Emote } from './emotes';55
import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';6
+import type { HistoryRepairBudget, HistoryRepairPolicy, HistoryRepairReport } from './repair-model';7
+export { parseHistoryRepairPolicy, parseHistoryRepairReport } from './repair-model';8
+export type { HistoryRepairBudget, HistoryRepairFailure, HistoryRepairPolicy, HistoryRepairReport } from './repair-model';69
export { mentionKey, parseMentionsPage, parseSavedMentionContext, parseMentionsUpdate } from './mentions-model';710
export type { MentionKey, MentionReason, MentionCaptureState, MentionEntry, MentionCoverage, MentionGap, MentionsBoundary, MentionsPage, SavedMentionContext, MentionsUpdate } from './mentions-model';811
import { formatBody } from './markdown';912
import { mentionedUserIds, type Mention } from './mentions';1013
import { HttpTransport } from './transport-http';1114
import type { CommandRequest, SendKind, SessionInfo, Transport, VerifyAction } from './transport';15
+export type { HistoryRepairRequest } from './transport';1216
import { createDeviceTransport } from 'malice:device-transport';1317
1418
export { ApiError } from './transport';@@ -64,6 +68,14 @@ export const retryMentionContext = (room_id: string, event_id: string): Promise<
6468
export const setMentionsRead = (keys: MentionKey[], read: boolean) => transport.setMentionsRead(keys, read);6569
export const markMentionsReadThrough = (boundary: MentionsBoundary) => transport.markMentionsReadThrough(boundary);6670
export const recoverMentions = () => transport.recoverMentions();71
+export const historyRepair = (room_id: string, policy: HistoryRepairPolicy | null = null, budget?: HistoryRepairBudget): Promise<HistoryRepairReport> =>72
+ transport.historyRepair(room_id, policy, budget);73
+export const continueHistoryRepair = (room_id: string, budget?: HistoryRepairBudget): Promise<HistoryRepairReport> =>74
+ transport.continueHistoryRepair(room_id, budget);75
+export const cancelHistoryRepair = (room_id: string): Promise<void> => transport.cancelHistoryRepair(room_id);76
+export const startRecentHistory = (room_id: string): Promise<HistoryRepairReport> => transport.startRecentHistory(room_id);77
+export const historyRepairPolicy = (): Promise<HistoryRepairPolicy> => transport.historyRepairPolicy();78
+export const setHistoryRepairPolicy = (policy: HistoryRepairPolicy): Promise<void> => transport.setHistoryRepairPolicy(policy);6779
export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);6880
export const getAccountData = (eventType: string) => transport.getAccountData(eventType);6981
export const getUrlPreview = (room_id: string, url: string) => transport.getUrlPreview(room_id, url);@@ -0,0 +1,94 @@
1
+export type HistoryRepairPolicy =2
+ | 'StartRecent'3
+ | 'Bridge'4
+ | { BridgeShortGaps: { threshold_ms: number } };5
+6
+export interface HistoryRepairBudget {7
+ max_pages: number;8
+ max_events: number;9
+ max_duration_ms: number;10
+}11
+12
+export interface HistoryRepairFailure {13
+ kind: string;14
+ message: string;15
+ server?: { status: number; errcode: string; message: string } | null;16
+ [key: string]: unknown;17
+}18
+19
+export interface HistoryRepairReport {20
+ room_id: string;21
+ requested_policy: HistoryRepairPolicy;22
+ strategy: 'Recent' | 'Overlap' | 'Bridge';23
+ coverage_duration_ms: number | null;24
+ threshold_evaluated: boolean;25
+ pages: number;26
+ events: number;27
+ phase: 'FetchingRecent' | 'Bridging' | 'Ready' | 'Paused';28
+ continuity: 'Unresolved' | 'Verified' | 'AccessibleStart';29
+ recent_available: boolean;30
+ stop: 'Cancelled' | 'Budget' | 'HistoryUnavailable' | 'NoAnchor' | 'CursorStalled' | null;31
+ failure: HistoryRepairFailure | null;32
+}33
+34
+export const DEFAULT_HISTORY_REPAIR_BUDGET: HistoryRepairBudget = {35
+ max_pages: 10,36
+ max_events: 1000,37
+ max_duration_ms: 1500038
+};39
+40
+function record(value: unknown): Record<string, unknown> | null {41
+ return typeof value === 'object' && value !== null && !Array.isArray(value)42
+ ? (value as Record<string, unknown>)43
+ : null;44
+}45
+46
+function safeNonnegativeInteger(value: unknown): value is number {47
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;48
+}49
+50
+export function parseHistoryRepairPolicy(value: unknown): HistoryRepairPolicy | null {51
+ if (value === 'StartRecent' || value === 'Bridge') return value;52
+ const outer = record(value);53
+ const details = outer && record(outer['BridgeShortGaps']);54
+ if (!outer || Object.keys(outer).length !== 1 || !details || Object.keys(details).length !== 1 || !safeNonnegativeInteger(details['threshold_ms'])) return null;55
+ return { BridgeShortGaps: { threshold_ms: details['threshold_ms'] } };56
+}57
+58
+function isOneOf<T extends string>(value: unknown, values: readonly T[]): value is T {59
+ return typeof value === 'string' && (values as readonly string[]).includes(value);60
+}61
+62
+export function parseHistoryRepairReport(value: unknown): HistoryRepairReport | null {63
+ const data = record(value);64
+ if (!data) return null;65
+ const policy = parseHistoryRepairPolicy(data['requested_policy']);66
+ const failure = data['failure'];67
+ const failureData = failure === null ? null : record(failure);68
+ if (69
+ typeof data['room_id'] !== 'string' || !policy ||70
+ !isOneOf(data['strategy'], ['Recent', 'Overlap', 'Bridge']) ||71
+ !(data['coverage_duration_ms'] === null || safeNonnegativeInteger(data['coverage_duration_ms'])) ||72
+ typeof data['threshold_evaluated'] !== 'boolean' ||73
+ !safeNonnegativeInteger(data['pages']) || !safeNonnegativeInteger(data['events']) ||74
+ !isOneOf(data['phase'], ['FetchingRecent', 'Bridging', 'Ready', 'Paused']) ||75
+ !isOneOf(data['continuity'], ['Unresolved', 'Verified', 'AccessibleStart']) ||76
+ typeof data['recent_available'] !== 'boolean' ||77
+ !(data['stop'] === null || isOneOf(data['stop'], ['Cancelled', 'Budget', 'HistoryUnavailable', 'NoAnchor', 'CursorStalled'])) ||78
+ !(failure === null || (failureData && typeof failureData['kind'] === 'string' && typeof failureData['message'] === 'string'))79
+ ) return null;80
+ return {81
+ room_id: data['room_id'],82
+ requested_policy: policy,83
+ strategy: data['strategy'],84
+ coverage_duration_ms: data['coverage_duration_ms'],85
+ threshold_evaluated: data['threshold_evaluated'],86
+ pages: data['pages'],87
+ events: data['events'],88
+ phase: data['phase'],89
+ continuity: data['continuity'],90
+ recent_available: data['recent_available'],91
+ stop: data['stop'],92
+ failure: failureData as HistoryRepairFailure | null93
+ };94
+}@@ -1,6 +1,8 @@
11
import { get, writable } from 'svelte/store';22
import { parseMentionsPage, parseMentionsUpdate } from './mentions-model';33
import type { MentionsPage } from './mentions-model';4
+import { parseHistoryRepairReport } from './repair-model';5
+import type { HistoryRepairReport } from './repair-model';46
import { getSession, subscribeEvents } from './api';57
import type { SoftLogout } from './api';68
import { isRecord } from './guards';@@ -40,6 +42,13 @@ export const blocked = writable<boolean>(false);
4042
export const rooms = writable<RoomSummary[]>([]);4143
export const timelines = writable<Record<string, TimelineItem[]>>({});4244
export const pinnedTimelines = writable<Record<string, TimelineItem[]>>({});45
+export const pinnedTimelineVersions = writable<Record<string, number>>({});46
+export const historyRepairs = writable<Record<string, HistoryRepairReport>>({});47
+48
+export function publishHistoryRepair(report: HistoryRepairReport, account: string): void {49
+ if (account !== get(sessionUser)) return;50
+ historyRepairs.update((all) => ({ ...all, [report.room_id]: report }));51
+}4352
export interface EventContextState {4453
room_id: string;4554
request_id: string;@@ -76,6 +85,8 @@ export function resetClientState(): void {
7685
rooms.set([]);7786
timelines.set({});7887
pinnedTimelines.set({});88
+ pinnedTimelineVersions.set({});89
+ historyRepairs.set({});7990
eventContexts.set({});8091
connection.set('connecting');8192
selectedRoomId.set(null);@@ -125,6 +136,14 @@ function handlePayload(text: string): void {
125136
if (!isRecord(data)) return;126137
const kind = data['type'];127138
if (kind === 'snapshot') {139
+ const repairs: Record<string, HistoryRepairReport> = {};140
+ if (isRecord(data['repairs'])) {141
+ for (const [roomId, value] of Object.entries(data['repairs'])) {142
+ const report = parseHistoryRepairReport(value);143
+ if (report && report.room_id === roomId) repairs[roomId] = report;144
+ }145
+ }146
+ historyRepairs.set(repairs);128147
const mentions = parseMentionsPage(data['mentions']);129148
if (mentions) publishMentionsPage(mentions);130149
setRooms(data['rooms']);@@ -155,6 +174,12 @@ function handlePayload(text: string): void {
155174
if (update) publishMentionsPage(update.snapshot);156175
return;157176
}177
+ if (kind === 'history_repair') {178
+ const report = parseHistoryRepairReport(data['report']);179
+ const account = get(sessionUser);180
+ if (report && account) publishHistoryRepair(report, account);181
+ return;182
+ }158183
if (kind === 'image_packs') {159184
imagePacks.set(parseImagePacks(data['packs']));160185
return;@@ -179,6 +204,7 @@ function handlePayload(text: string): void {
179204
if (kind === 'pinned_closed') {180205
const roomId = data['room_id'];181206
if (typeof roomId === 'string') {207
+ pinnedTimelineVersions.update((all) => ({ ...all, [roomId]: (all[roomId] ?? 0) + 1 }));182208
pinnedTimelines.update((all) => {183209
const { [roomId]: _closed, ...rest } = all;184210
return rest;@@ -2,6 +2,8 @@ import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from
22
import { parseEventContext, parseEventContextPage } from './types';33
import { parseMentionsPage, parseSavedMentionContext } from './mentions-model';44
import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';5
+import { DEFAULT_HISTORY_REPAIR_BUDGET, parseHistoryRepairPolicy, parseHistoryRepairReport } from './repair-model';6
+import type { HistoryRepairBudget, HistoryRepairPolicy, HistoryRepairReport } from './repair-model';57
import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type Transport, type UploadExtras, type VerifyAction } from './transport';68
79
async function parseFailure(res: Response, fallback: string): Promise<never> {@@ -140,6 +142,32 @@ export class HttpTransport implements Transport {
140142
await this.command({ op: 'mark_mentions_read_through', boundary });141143
}142144
async recoverMentions(): Promise<void> { await this.command({ op: 'recover_mentions' }); }145
+ async historyRepair(room_id: string, policy: HistoryRepairPolicy | null = null, budget: HistoryRepairBudget = DEFAULT_HISTORY_REPAIR_BUDGET): Promise<HistoryRepairReport> {146
+ const parsed = parseHistoryRepairReport(await this.command({ op: 'history_repair', room_id, policy, budget }));147
+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');148
+ return parsed;149
+ }150
+ async continueHistoryRepair(room_id: string, budget: HistoryRepairBudget = DEFAULT_HISTORY_REPAIR_BUDGET): Promise<HistoryRepairReport> {151
+ const parsed = parseHistoryRepairReport(await this.command({ op: 'continue_history_repair', room_id, budget }));152
+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');153
+ return parsed;154
+ }155
+ async cancelHistoryRepair(room_id: string): Promise<void> {156
+ await this.command({ op: 'cancel_history_repair', room_id });157
+ }158
+ async startRecentHistory(room_id: string): Promise<HistoryRepairReport> {159
+ const parsed = parseHistoryRepairReport(await this.command({ op: 'start_recent_history', room_id }));160
+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');161
+ return parsed;162
+ }163
+ async historyRepairPolicy(): Promise<HistoryRepairPolicy> {164
+ const parsed = parseHistoryRepairPolicy(await this.command({ op: 'history_repair_policy' }));165
+ if (!parsed) throw new ApiError(502, 'Invalid history repair policy response');166
+ return parsed;167
+ }168
+ async setHistoryRepairPolicy(policy: HistoryRepairPolicy): Promise<void> {169
+ await this.command({ op: 'set_history_repair_policy', policy });170
+ }143171
async eventContextBefore(room_id: string, event_id: string, from: string, limit = 20): Promise<EventContextPage> {144172
return this.eventContextPage('before', room_id, event_id, from, limit);145173
}@@ -1,6 +1,8 @@
11
import { parseEventContext, parseEventContextPage } from './types';22
import { parseMentionsPage, parseSavedMentionContext } from './mentions-model';33
import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';4
+import { DEFAULT_HISTORY_REPAIR_BUDGET, parseHistoryRepairPolicy, parseHistoryRepairReport } from './repair-model';5
+import type { HistoryRepairBudget, HistoryRepairPolicy, HistoryRepairReport } from './repair-model';46
import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from './types';57
import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type SoftLogout, type Transport, type UploadExtras, type VerifyAction } from './transport';68
import { statusForKind } from './wire-status';@@ -247,6 +249,32 @@ export class WorkerTransport implements Transport {
247249
async setMentionsRead(keys: MentionKey[], read: boolean): Promise<void> { await this.client.call('set_mentions_read', { keys, read }); }248250
async markMentionsReadThrough(boundary: MentionsBoundary): Promise<void> { await this.client.call('mark_mentions_read_through', { boundary }); }249251
async recoverMentions(): Promise<void> { await this.client.call('recover_mentions'); }252
+ async historyRepair(room_id: string, policy: HistoryRepairPolicy | null = null, budget: HistoryRepairBudget = DEFAULT_HISTORY_REPAIR_BUDGET): Promise<HistoryRepairReport> {253
+ const parsed = parseHistoryRepairReport(await this.client.call('history_repair', { room_id, policy, budget }));254
+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');255
+ return parsed;256
+ }257
+ async continueHistoryRepair(room_id: string, budget: HistoryRepairBudget = DEFAULT_HISTORY_REPAIR_BUDGET): Promise<HistoryRepairReport> {258
+ const parsed = parseHistoryRepairReport(await this.client.call('continue_history_repair', { room_id, budget }));259
+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');260
+ return parsed;261
+ }262
+ async cancelHistoryRepair(room_id: string): Promise<void> {263
+ await this.client.call('cancel_history_repair', { room_id });264
+ }265
+ async startRecentHistory(room_id: string): Promise<HistoryRepairReport> {266
+ const parsed = parseHistoryRepairReport(await this.client.call('start_recent_history', { room_id }));267
+ if (!parsed) throw new ApiError(502, 'Invalid history repair response');268
+ return parsed;269
+ }270
+ async historyRepairPolicy(): Promise<HistoryRepairPolicy> {271
+ const parsed = parseHistoryRepairPolicy(await this.client.call('history_repair_policy'));272
+ if (!parsed) throw new ApiError(502, 'Invalid history repair policy response');273
+ return parsed;274
+ }275
+ async setHistoryRepairPolicy(policy: HistoryRepairPolicy): Promise<void> {276
+ await this.client.call('set_history_repair_policy', { policy });277
+ }250278
eventContext(room_id: string, event_id: string, limit = 20): Promise<EventContext> {251279
return this.contextCall('event_context', { room_id, event_id, limit });252280
}@@ -1,5 +1,6 @@
11
import type { EventContext, EventContextPage, MemberSummary, PackUsage, RoomSummary } from './types';22
import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext } from './mentions-model';3
+import type { HistoryRepairBudget, HistoryRepairPolicy, HistoryRepairReport } from './repair-model';34
45
export class ApiError extends Error {56
status: number;@@ -64,8 +65,17 @@ export type CommandRequest =
6465
| { op: 'clone_pack_image'; from_pack: string; shortcode: string; to: PackTarget }6566
| { op: 'clone_sticker'; room_id: string; event_id: string; to: PackTarget; shortcode: string }6667
| { op: 'clone_pack'; from_pack: string; to: PackTarget; display_name: string | null }68
+ | HistoryRepairRequest6769
| MentionsRequest;6870
71
+export type HistoryRepairRequest =72
+ | { op: 'history_repair'; room_id: string; policy: HistoryRepairPolicy | null; budget: HistoryRepairBudget }73
+ | { op: 'continue_history_repair'; room_id: string; budget: HistoryRepairBudget }74
+ | { op: 'cancel_history_repair'; room_id: string }75
+ | { op: 'start_recent_history'; room_id: string }76
+ | { op: 'history_repair_policy' }77
+ | { op: 'set_history_repair_policy'; policy: HistoryRepairPolicy };78
+6979
export type MentionsRequest =7080
| { op: 'mentions_page'; cursor: string | null; limit: number }7181
| { op: 'mention_context' | 'retry_mention_context'; room_id: string; event_id: string }@@ -116,6 +126,12 @@ export interface Transport {
116126
setMentionsRead(keys: MentionKey[], read: boolean): Promise<void>;117127
markMentionsReadThrough(boundary: MentionsBoundary): Promise<void>;118128
recoverMentions(): Promise<void>;129
+ historyRepair(room_id: string, policy?: HistoryRepairPolicy | null, budget?: HistoryRepairBudget): Promise<HistoryRepairReport>;130
+ continueHistoryRepair(room_id: string, budget?: HistoryRepairBudget): Promise<HistoryRepairReport>;131
+ cancelHistoryRepair(room_id: string): Promise<void>;132
+ startRecentHistory(room_id: string): Promise<HistoryRepairReport>;133
+ historyRepairPolicy(): Promise<HistoryRepairPolicy>;134
+ setHistoryRepairPolicy(policy: HistoryRepairPolicy): Promise<void>;119135
setTyping(room_id: string, typing: boolean): Promise<void>;120136
getAccountData(eventType: string): Promise<unknown | null>;121137
getUrlPreview(room_id: string, url: string): Promise<unknown | null>;