atagen

malice

public
Star0
Watch0

Commit 43c2cc753052

tarball

atagen <boss@atagen.co>2026-10-25 09:09 UTC

10 files changed+88-3
history: expose segmented gap filling
Modified file: crates/malice-core/src/request.rs+14 additions−1 deletions
@@ -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+ handle
610+ .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 }
Modified file: crates/malice-server/src/routes.rs+1 additions
@@ -569,6 +569,7 @@ async fn post_command(
569569 | Request::ContinueHistoryRepair { .. }
570570 | Request::CancelHistoryRepair { .. }
571571 | Request::StartRecentHistory { .. }
572+ | Request::FillHistoryGap { .. }
572573 | Request::HistoryRepairPolicy
573574 | Request::SetHistoryRepairPolicy { .. }
574575 | Request::Kick { .. }
Modified file: web/src/App.svelte+36 additions−1 deletions
@@ -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}
Modified file: web/src/app.css+1 additions
@@ -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; }
Modified file: web/src/lib/ContextTimeline.svelte+1 additions−1 deletions
@@ -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;
Modified file: web/src/lib/api.ts+1 additions
@@ -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);
Modified file: web/src/lib/transport-http.ts+3 additions
@@ -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');
Modified file: web/src/lib/transport-worker.ts+3 additions
@@ -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');
Modified file: web/src/lib/transport.ts+2 additions
@@ -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>;
Modified file: web/src/lib/types.ts+26 additions
@@ -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;