atagen

malice

public
Star0
Watch0

Commit d2a34d0f4861

tarball

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

6 files changed+138-8
timeline: flag possible federation delay
Modified file: web/src/App.svelte+2 additions
@@ -69,6 +69,7 @@
6969 membersLoading,
7070 mentionsSnapshot,
7171 pushBanner,
72+ markTimelineOpened,
7273 imagePacks,
7374 historyRepairs,
7475 publishHistoryRepair,
@@ -733,6 +734,7 @@
733734 openFailed = nextFailed;
734735 }
735736 try {
737+ markTimelineOpened(roomId);
736738 await openRoom(roomId);
737739 if (warmRooms.includes(roomId)) openReady = { ...openReady, [roomId]: true };
738740 } catch (e) {
Modified file: web/src/lib/MessageRow.svelte+17 additions
@@ -36,6 +36,16 @@
3636 export let onCloneSticker: ((eventId: string, body: string, x: number, y: number) => void) | null = null;
3737 export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
3838
39+ $: lag = lagLabel(event);
40+
41+ function lagLabel(event: TimelineEvent): string | null {
42+ const lag = event.delivery_lag_ms;
43+ if (!lag) return null;
44+ if (lag < 60 * 60 * 1000) return `${Math.max(1, Math.floor(lag / 60000))}m`;
45+ if (lag < 24 * 60 * 60 * 1000) return `${Math.floor(lag / 3600000)}h`;
46+ return `${Math.floor(lag / 86400000)}d`;
47+ }
48+
3949 function openUser(ev: MouseEvent, menu: boolean): void {
4050 if (!onUser) return;
4151 const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
@@ -374,6 +384,11 @@
374384 >
375385 <EmojiText text={displayName} />
376386 </button>
387+ {#if lag}
388+ <span class="federation-lag" title="Possibly delayed by federation" aria-label={`Arrived ${lag} late, possibly delayed by federation`}>
389+ <span aria-hidden="true">◷</span><span>{lag}</span>
390+ </span>
391+ {/if}
377392 <time class="time" datetime={new Date(event.timestamp_ms).toISOString()}>
378393 {formatDay(event.timestamp_ms)} {formatClock(event.timestamp_ms)}
379394 </time>
@@ -492,6 +507,8 @@
492507 .hover-time { opacity: 0; font-size: 10.5px; color: var(--muted); }
493508 .msg-body { min-width: 0; flex: 1; }
494509 .msg-head { display: flex; align-items: baseline; gap: 8px; }
510+ .federation-lag { display: inline-flex; align-items: center; gap: 3px; color: var(--danger); font-size: 11px; font-weight: 650; }
511+ .federation-lag > span:last-child { display: inline-grid; place-items: center; min-width: 24px; height: 18px; padding: 0 4px; border: 1px solid currentColor; border-radius: 999px; }
495512 .sender { font-weight: 650; font-size: 13.5px; padding: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
496513 .sender:hover { text-decoration: underline; }
497514 .user-hit { flex: none; align-self: flex-start; padding: 0; border: none; background: none; border-radius: 50%; cursor: pointer; }
Added file: web/src/lib/arrivals.ts+30 additions
@@ -0,0 +1,30 @@
1+import type { TimelineItem } from './types';
2+
3+export const FEDERATION_LAG_MS = 60 * 1000;
4+
5+/**
6+ * Carries delivery lag forward from the previous list and measures it for events appended
7+ * after the last one already known. `since` is when the room last became able to receive
8+ * events, so catch-up after being offline or closed doesn't count as federation delay.
9+ * Returns null when nothing in `items` was known before, meaning the list was replaced.
10+ */
11+export function observeArrivals(previous: TimelineItem[], items: TimelineItem[], since: number, now: number): TimelineItem[] | null {
12+ const lags = new Map<string, number | undefined>();
13+ for (const item of previous) if (item.kind === 'event') lags.set(item.event.item_id, item.event.delivery_lag_ms);
14+ let lastKnown = -1;
15+ items.forEach((item, index) => {
16+ if (item.kind === 'event' && lags.has(item.event.item_id)) lastKnown = index;
17+ });
18+ if (lastKnown === -1) return null;
19+ return items.map((item, index) => {
20+ if (item.kind !== 'event') return item;
21+ const event = item.event;
22+ if (lags.has(event.item_id)) {
23+ const carried = lags.get(event.item_id);
24+ return carried === undefined ? item : { ...item, event: { ...event, delivery_lag_ms: carried } };
25+ }
26+ if (index < lastKnown || event.own) return item;
27+ const lag = now - Math.max(event.timestamp_ms, since);
28+ return lag < FEDERATION_LAG_MS ? item : { ...item, event: { ...event, delivery_lag_ms: lag } };
29+ });
30+}
Modified file: web/src/lib/store.ts+34 additions−8 deletions
@@ -6,6 +6,7 @@ import type { HistoryRepairReport } from './repair-model';
66 import { getSession, subscribeEvents } from './api';
77 import type { SoftLogout } from './api';
88 import { isRecord } from './guards';
9+import { observeArrivals } from './arrivals';
910 import { resetLinkPreviews } from './link-previews';
1011 import { resetQuickReactions } from './quick-reactions';
1112 import { resetPackPreferences } from './pack-preferences';
@@ -100,6 +101,8 @@ export function resetClientState(): void {
100101 resetQuickReactions();
101102 resetPackPreferences();
102103 resetLinkPreviews();
104+ onlineSince = 0;
105+ liveSince = {};
103106 }
104107
105108 export function setEventContext(state: EventContextState): void {
@@ -118,10 +121,31 @@ export function clearEventContext(roomId: string, requestId?: string): void {
118121 });
119122 }
120123
124+let onlineSince = 0;
125+let liveSince: Record<string, number> = {};
126+
127+/** Events the room received while it was closed are catch-up, not federation delay. */
128+export function markTimelineOpened(roomId: string): void {
129+ liveSince = { ...liveSince, [roomId]: Date.now() };
130+}
131+
132+function setConnection(state: string): void {
133+ if (state === 'online' && get(connection) !== 'online') onlineSince = Date.now();
134+ connection.set(state);
135+}
136+
137+function withArrivals(roomId: string, previous: TimelineItem[], items: TimelineItem[]): TimelineItem[] {
138+ const now = Date.now();
139+ const observed = observeArrivals(previous, items, Math.max(onlineSince, liveSince[roomId] ?? 0), now);
140+ if (observed) return observed;
141+ liveSince = { ...liveSince, [roomId]: now };
142+ return items;
143+}
144+
121145 function applyTimelinePayload(roomId: string, payload: Record<string, unknown>): void {
122146 if (!Array.isArray(payload['items'])) return;
123147 const items = parseTimelineList(payload['items']);
124- timelines.update((all) => ({ ...all, [roomId]: items }));
148+ timelines.update((all) => ({ ...all, [roomId]: withArrivals(roomId, all[roomId] ?? [], items) }));
125149 }
126150
127151 function setRooms(raw: unknown): void {
@@ -138,6 +162,7 @@ function handlePayload(text: string): void {
138162 if (!isRecord(data)) return;
139163 const kind = data['type'];
140164 if (kind === 'snapshot') {
165+ if (typeof data['connection'] === 'string') setConnection(data['connection']);
141166 const repairs: Record<string, HistoryRepairReport> = {};
142167 if (isRecord(data['repairs'])) {
143168 for (const [roomId, value] of Object.entries(data['repairs'])) {
@@ -151,11 +176,13 @@ function handlePayload(text: string): void {
151176 setRooms(data['rooms']);
152177 const rawTimelines = data['timelines'];
153178 if (isRecord(rawTimelines)) {
154- const next: Record<string, TimelineItem[]> = {};
155- for (const [roomId, list] of Object.entries(rawTimelines)) {
156- next[roomId] = parseTimelineList(list);
157- }
158- timelines.update((all) => ({ ...all, ...next }));
179+ timelines.update((all) => {
180+ const next = { ...all };
181+ for (const [roomId, list] of Object.entries(rawTimelines)) {
182+ next[roomId] = withArrivals(roomId, all[roomId] ?? [], parseTimelineList(list));
183+ }
184+ return next;
185+ });
159186 }
160187 const rawPinned = data['pinned'];
161188 if (isRecord(rawPinned)) {
@@ -163,7 +190,6 @@ function handlePayload(text: string): void {
163190 for (const [roomId, list] of Object.entries(rawPinned)) next[roomId] = parseTimelineList(list);
164191 pinnedTimelines.set(next);
165192 }
166- if (typeof data['connection'] === 'string') connection.set(data['connection']);
167193 const snapshotEncryption = parseEncryptionState(data['encryption']);
168194 if (snapshotEncryption) encryption.set(snapshotEncryption);
169195 const snapshotVerification = parseVerificationState(data['verification']);
@@ -215,7 +241,7 @@ function handlePayload(text: string): void {
215241 return;
216242 }
217243 if (kind === 'connection') {
218- if (typeof data['state'] === 'string') connection.set(data['state']);
244+ if (typeof data['state'] === 'string') setConnection(data['state']);
219245 return;
220246 }
221247 if (kind === 'encryption') {
Modified file: web/src/lib/types.ts+1 additions
@@ -609,6 +609,7 @@ export interface TimelineEvent {
609609 reactions: Reaction[];
610610 read_by: string[];
611611 editable: boolean;
612+ delivery_lag_ms?: number;
612613 }
613614
614615 function parseReactions(raw: unknown): Reaction[] {
Added file: web/test/arrivals.test.ts+54 additions
@@ -0,0 +1,54 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { observeArrivals } from '../src/lib/arrivals.ts';
4+import type { TimelineEvent, TimelineItem } from '../src/lib/types.ts';
5+
6+const MINUTE = 60 * 1000;
7+
8+function event(id: string, timestamp_ms: number, extra: Partial<TimelineEvent> = {}): TimelineItem {
9+ return { kind: 'event', event: { item_id: id, timestamp_ms, own: false, ...extra } as TimelineEvent };
10+}
11+
12+function lagOf(items: TimelineItem[] | null, id: string): number | undefined {
13+ const found = items?.find((item) => item.kind === 'event' && item.event.item_id === id);
14+ return found?.kind === 'event' ? found.event.delivery_lag_ms : undefined;
15+}
16+
17+test('flags a delayed event appended after newer known events', () => {
18+ const now = 100 * MINUTE;
19+ const previous = [event('a', 90 * MINUTE), event('b', 95 * MINUTE)];
20+ const next = observeArrivals(previous, [...previous, event('late', 80 * MINUTE)], 0, now);
21+ assert.equal(lagOf(next, 'late'), 20 * MINUTE);
22+});
23+
24+test('ignores backfill and prompt arrivals', () => {
25+ const now = 100 * MINUTE;
26+ const previous = [event('a', 99 * MINUTE)];
27+ const next = observeArrivals(previous, [event('old', 10 * MINUTE), ...previous, event('new', now - 1000)], 0, now);
28+ assert.equal(lagOf(next, 'old'), undefined);
29+ assert.equal(lagOf(next, 'new'), undefined);
30+});
31+
32+test('measures from when the room became live, not from the send time', () => {
33+ const now = 100 * MINUTE;
34+ const previous = [event('a', 10 * MINUTE)];
35+ const caughtUp = observeArrivals(previous, [...previous, event('offline', 20 * MINUTE)], now - 5000, now);
36+ assert.equal(lagOf(caughtUp, 'offline'), undefined);
37+ const delayed = observeArrivals(previous, [...previous, event('stuck', 20 * MINUTE)], now - 3 * MINUTE, now);
38+ assert.equal(lagOf(delayed, 'stuck'), 3 * MINUTE);
39+});
40+
41+test('carries a flag across later updates', () => {
42+ const now = 100 * MINUTE;
43+ const previous = [event('a', 99 * MINUTE), event('late', 50 * MINUTE, { delivery_lag_ms: 50 * MINUTE })];
44+ const next = observeArrivals(previous, [...previous.map((item) => item.kind === 'event' ? event(item.event.item_id, item.event.timestamp_ms) : item), event('c', now)], 0, now + MINUTE);
45+ assert.equal(lagOf(next, 'late'), 50 * MINUTE);
46+});
47+
48+test('skips own events and replaced lists', () => {
49+ const now = 100 * MINUTE;
50+ const previous = [event('a', 99 * MINUTE)];
51+ const own = observeArrivals(previous, [...previous, event('mine', 10 * MINUTE, { own: true })], 0, now);
52+ assert.equal(lagOf(own, 'mine'), undefined);
53+ assert.equal(observeArrivals(previous, [event('x', 10 * MINUTE)], 0, now), null);
54+});