Commit d2a34d0f4861
tarball6 files changed+138-8
@@ -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) {@@ -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; }@@ -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 appended7
+ * after the last one already known. `since` is when the room last became able to receive8
+ * 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
+}@@ -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') {@@ -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[] {@@ -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
+});