Commit 43c2cc753052
tarball10 files changed+88-3
@@ -1,4 +1,4 @@
1
-use macltok::ruma::{events::GlobalAccountDataEventType, serde::Raw};1
+use macltok::ruma::{OwnedTransactionId, events::GlobalAccountDataEventType, serde::Raw};22
use macltok::{33
AttachmentUpload, ClientHandle, CreateRoomOptions, HistoryRepairBudget, HistoryRepairPolicy,44
ImagePackSource, MediaId, MediaSize, MemberFilter, MentionKey, MentionsBoundary,@@ -103,6 +103,10 @@ pub enum Request {
103103
StartRecentHistory {104104
room_id: String,105105
},106
+ FillHistoryGap {107
+ room_id: String,108
+ gap_id: String,109
+ },106110
HistoryRepairPolicy,107111
SetHistoryRepairPolicy {108112
policy: HistoryRepairPolicy,@@ -598,6 +602,15 @@ pub async fn dispatch(
598602
.await?;599603
Ok(Reply::Json(json!(report)))600604
}605
+ Request::FillHistoryGap { room_id, gap_id } => {606
+ if gap_id.is_empty() {607
+ return Err(CoreError::bad_request("invalid history gap id"));608
+ }609
+ handle610
+ .fill_history_gap(parse_room_id(&room_id)?, OwnedTransactionId::from(gap_id))611
+ .await?;612
+ Ok(Reply::Done)613
+ }601614
Request::HistoryRepairPolicy => {602615
Ok(Reply::Json(json!(handle.history_repair_policy().await?)))603616
}@@ -569,6 +569,7 @@ async fn post_command(
569569
| Request::ContinueHistoryRepair { .. }570570
| Request::CancelHistoryRepair { .. }571571
| Request::StartRecentHistory { .. }572
+ | Request::FillHistoryGap { .. }572573
| Request::HistoryRepairPolicy573574
| Request::SetHistoryRepairPolicy { .. }574575
| Request::Kick { .. }@@ -36,6 +36,7 @@
3636
continueHistoryRepair,3737
cancelHistoryRepair,3838
startRecentHistory,39
+ fillHistoryGap,3940
joinRoom,4041
leaveRoom,4142
logout,@@ -133,6 +134,12 @@
133134
let createBusy = false;134135
let leaveBusy = false;135136
let paginateBusy = false;137
+ let fillingGaps: Record<string, string> = {};138
+ let historyAccount: string | null | undefined;139
+ $: if ($sessionUser !== historyAccount) {140
+ historyAccount = $sessionUser;141
+ fillingGaps = {};142
+ }136143
let reacting = false;137144
let composer = '';138145
let drafts: Record<string, string> = {};@@ -419,7 +426,8 @@
419426
out.push({ key: e.item_id || e.event_id || `m-${index}`, item, showSender });420427
} else {421428
prevSender = null;422
- out.push({ key: `${item.kind}-${index}`, item, showSender });429
+ const key = item.kind === 'historyGap' ? `gap-${item.gap.id}` : `${item.kind}-${index}`;430
+ out.push({ key, item, showSender });423431
}424432
});425433
return out;@@ -1485,6 +1493,22 @@
14851493
}14861494
}14871495
1496
+ async function fillGap(gapId: string): Promise<void> {1497
+ const roomId = $selectedRoomId;1498
+ if (!roomId || fillingGaps[roomId]) return;1499
+ fillingGaps = { ...fillingGaps, [roomId]: gapId };1500
+ try {1501
+ await fillHistoryGap(roomId, gapId);1502
+ } catch (e) {1503
+ pushBanner(e instanceof Error ? e.message : 'Loading the missing history failed');1504
+ } finally {1505
+ if (fillingGaps[roomId] === gapId) {1506
+ const { [roomId]: _done, ...rest } = fillingGaps;1507
+ fillingGaps = rest;1508
+ }1509
+ }1510
+ }1511
+14881512
async function loadMembers(roomId: string): Promise<void> {14891513
if (membersFor === roomId && $membersLoading) return;14901514
membersFor = roomId;@@ -2591,6 +2615,17 @@
25912615
<div class="new-row"><span>New</span></div>25922616
{:else if g.item.kind === 'start'}25932617
<p class="start-note">Beginning of history</p>2618
+ {:else if g.item.kind === 'historyGap'}2619
+ {#if g.item.gap.continuity !== 'Verified'}2620
+ {@const gap = g.item.gap}2621
+ {@const filling = fillingGaps[selectedRoom.room_id] === gap.id || gap.lifecycle === 'Fetching'}2622
+ <div class="history-gap">2623
+ <span>{gap.failure ?? 'Some history is not loaded'}</span>2624
+ <button type="button" class="ghost-btn" disabled={!!fillingGaps[selectedRoom.room_id] || gap.lifecycle === 'Fetching' || gap.lifecycle === 'Unavailable'} on:click={() => void fillGap(gap.id)}>2625
+ {filling ? 'Loading…' : gap.lifecycle === 'Unavailable' ? 'History unavailable' : 'Fetch older history'}2626
+ </button>2627
+ </div>2628
+ {/if}25942629
{:else if g.item.kind === 'unknown'}25952630
<p class="unknown-note">Unsupported event ({g.item.label})</p>25962631
{:else}@@ -1091,3 +1091,4 @@ input, textarea { font: inherit; }
10911091
.pinned-list { list-style: none; margin: 0; padding: 0 8px 6px 58px; max-height: 40vh; overflow-y: auto; animation: pop-in var(--pop); }10921092
.pinned-list li { display: flex; align-items: center; gap: 6px; border-top: 1px solid var(--border); }10931093
.pinned-strip .icon-btn { width: 26px; height: 26px; }1094
+.history-gap { display: flex; align-items: center; justify-content: center; gap: 10px; margin: 12px 0; padding: 10px; border: 1px dashed var(--border); border-radius: var(--r-md); color: var(--muted); font-size: 12px; }@@ -46,7 +46,7 @@
4646
const seen = new Set<string>();4747
const result: TimelineItem[] = [];4848
for (const item of items) {49
- if (item.kind === 'readMarker' || item.kind === 'start' || item.kind === 'unknown') continue;49
+ if (item.kind === 'readMarker' || item.kind === 'start' || item.kind === 'unknown' || item.kind === 'historyGap') continue;5050
if (item.kind === 'event') {5151
const eventKey = stable(item.event);5252
if (seen.has(eventKey)) continue;@@ -74,6 +74,7 @@ export const continueHistoryRepair = (room_id: string, budget?: HistoryRepairBud
7474
transport.continueHistoryRepair(room_id, budget);7575
export const cancelHistoryRepair = (room_id: string): Promise<void> => transport.cancelHistoryRepair(room_id);7676
export const startRecentHistory = (room_id: string): Promise<HistoryRepairReport> => transport.startRecentHistory(room_id);77
+export const fillHistoryGap = (room_id: string, gap_id: string): Promise<void> => transport.fillHistoryGap(room_id, gap_id);7778
export const historyRepairPolicy = (): Promise<HistoryRepairPolicy> => transport.historyRepairPolicy();7879
export const setHistoryRepairPolicy = (policy: HistoryRepairPolicy): Promise<void> => transport.setHistoryRepairPolicy(policy);7980
export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);@@ -160,6 +160,9 @@ export class HttpTransport implements Transport {
160160
if (!parsed) throw new ApiError(502, 'Invalid history repair response');161161
return parsed;162162
}163
+ async fillHistoryGap(room_id: string, gap_id: string): Promise<void> {164
+ await this.command({ op: 'fill_history_gap', room_id, gap_id });165
+ }163166
async historyRepairPolicy(): Promise<HistoryRepairPolicy> {164167
const parsed = parseHistoryRepairPolicy(await this.command({ op: 'history_repair_policy' }));165168
if (!parsed) throw new ApiError(502, 'Invalid history repair policy response');@@ -267,6 +267,9 @@ export class WorkerTransport implements Transport {
267267
if (!parsed) throw new ApiError(502, 'Invalid history repair response');268268
return parsed;269269
}270
+ async fillHistoryGap(room_id: string, gap_id: string): Promise<void> {271
+ await this.client.call('fill_history_gap', { room_id, gap_id });272
+ }270273
async historyRepairPolicy(): Promise<HistoryRepairPolicy> {271274
const parsed = parseHistoryRepairPolicy(await this.client.call('history_repair_policy'));272275
if (!parsed) throw new ApiError(502, 'Invalid history repair policy response');@@ -73,6 +73,7 @@ export type HistoryRepairRequest =
7373
| { op: 'continue_history_repair'; room_id: string; budget: HistoryRepairBudget }7474
| { op: 'cancel_history_repair'; room_id: string }7575
| { op: 'start_recent_history'; room_id: string }76
+ | { op: 'fill_history_gap'; room_id: string; gap_id: string }7677
| { op: 'history_repair_policy' }7778
| { op: 'set_history_repair_policy'; policy: HistoryRepairPolicy };7879
@@ -130,6 +131,7 @@ export interface Transport {
130131
continueHistoryRepair(room_id: string, budget?: HistoryRepairBudget): Promise<HistoryRepairReport>;131132
cancelHistoryRepair(room_id: string): Promise<void>;132133
startRecentHistory(room_id: string): Promise<HistoryRepairReport>;134
+ fillHistoryGap(room_id: string, gap_id: string): Promise<void>;133135
historyRepairPolicy(): Promise<HistoryRepairPolicy>;134136
setHistoryRepairPolicy(policy: HistoryRepairPolicy): Promise<void>;135137
setTyping(room_id: string, typing: boolean): Promise<void>;@@ -650,11 +650,21 @@ function parseTimelineEvent(raw: Record<string, unknown>): TimelineEvent {
650650
651651
export type TimelineItem =652652
| { kind: 'event'; event: TimelineEvent }653
+ | { kind: 'historyGap'; gap: HistoryGap }653654
| { kind: 'day'; timestamp_ms: number }654655
| { kind: 'readMarker' }655656
| { kind: 'start' }656657
| { kind: 'unknown'; label: string };657658
659
+export interface HistoryGap {660
+ id: string;661
+ older_boundary: string | null;662
+ newer_boundary: string | null;663
+ lifecycle: 'Idle' | 'Fetching' | 'Paused' | 'Failed' | 'Unavailable';664
+ continuity: 'Unresolved' | 'Verified' | 'AccessibleStart';665
+ failure: string | null;666
+}667
+658668
export interface EventContext {659669
events_before: TimelineItem[];660670
event: TimelineItem | null;@@ -678,6 +688,22 @@ export function parseTimelineItem(raw: unknown): TimelineItem | null {
678688
if (raw === 'TimelineStart') return { kind: 'start' };679689
if (!isRecord(raw)) return null;680690
if (isRecord(raw['Event'])) return { kind: 'event', event: parseTimelineEvent(raw['Event']) };691
+ if (isRecord(raw['HistoryGap'])) {692
+ const gap = raw['HistoryGap'];693
+ const lifecycle = gap['lifecycle'];694
+ const continuity = gap['continuity'];695
+ return {696
+ kind: 'historyGap',697
+ gap: {698
+ id: stringField(gap, 'id'),699
+ older_boundary: optionalString(gap, 'older_boundary'),700
+ newer_boundary: optionalString(gap, 'newer_boundary'),701
+ lifecycle: lifecycle === 'Fetching' || lifecycle === 'Paused' || lifecycle === 'Failed' || lifecycle === 'Unavailable' ? lifecycle : 'Idle',702
+ continuity: continuity === 'Verified' || continuity === 'AccessibleStart' ? continuity : 'Unresolved',703
+ failure: optionalString(gap, 'failure')704
+ }705
+ };706
+ }681707
if ('DayDivider' in raw) {682708
const inner = raw['DayDivider'];683709
const ts = isRecord(inner) && typeof inner['timestamp_ms'] === 'number' ? inner['timestamp_ms'] : 0;