atagen

malice

public
Star0
Watch0

Commit f93c7e88ea05

tarball

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

4 files changed+281-175
web: move room timeline rows out of App

App.svelte had grown past 2700 lines with the grouping, pending and gap rendering inline, which made the thread pane work hard to review. Grouping is now a pure module so it can be tested without mounting the app.
Modified file: web/src/App.svelte+35 additions−175 deletions
@@ -2,13 +2,13 @@
22 import { onMount, tick } from 'svelte';
33 import { fade, fly } from 'svelte/transition';
44 import LoginPanel from './lib/LoginPanel.svelte';
5- import MessageRow from './lib/MessageRow.svelte';
65 import MentionsInbox from './lib/MentionsInbox.svelte';
76 import MxcAvatar from './lib/MxcAvatar.svelte';
87 import PinButton from './lib/PinButton.svelte';
98 import ThreadPane from './lib/ThreadPane.svelte';
109 import Composer from './lib/Composer.svelte';
1110 import SearchView from './lib/SearchView.svelte';
11+ import RoomTimelineRows from './lib/RoomTimelineRows.svelte';
1212 import type { ScopeOption } from './lib/search-model';
1313 import { releaseStaged, stageFiles as stageAttachments, type StagedAttachment, type StickerHandlers } from './lib/staging';
1414 import RoomThreads from './lib/RoomThreads.svelte';
@@ -98,7 +98,6 @@
9898 urlPreviewSettings
9999 } from './lib/link-previews';
100100 import { notifyTyping, stopTyping } from './lib/typing';
101- import { formatDay } from './lib/format';
102101 import {
103102 MEMBERS_WIDTH,
104103 SIDE_WIDTH,
@@ -114,7 +113,7 @@
114113 } from './lib/appearance';
115114 import SettingsPanel from './lib/SettingsPanel.svelte';
116115 import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
117- import type { ImagePack, MemberSummary, OwnProfile, PackImage, Person, QuickReaction, ReplyReference, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
116+ import type { ImagePack, MemberSummary, OwnProfile, PackImage, Person, QuickReaction, ReplyReference, RoomSummary, TimelineEvent, TimelineItem } from './lib/types';
118117
119118 let roomsLoading = false;
120119 let booted = false;
@@ -231,86 +230,7 @@
231230 $: memberGroups = groupMembers(members, roomLevels);
232231 $: people = peopleIn(items, members);
233232 $: hasStart = items.some((i) => i.kind === 'start');
234- $: grouped = buildGroups(items);
235- $: lastSentId = lastOwnSent(items);
236233 $: pendingItems = $selectedRoomId ? ($pendingByRoom[$selectedRoomId] ?? []) : [];
237- $: lastRealOwn = lastSenderIsOwn(items);
238-
239- function lastSenderIsOwn(list: TimelineItem[]): boolean {
240- for (let i = list.length - 1; i >= 0; i -= 1) {
241- const it = list[i];
242- if (it.kind === 'event') return it.event.sender.user_id === $sessionUser;
243- }
244- return false;
245- }
246-
247- function pendingShowSender(index: number): boolean {
248- return index === 0 ? !lastRealOwn : false;
249- }
250-
251- function pendingToEvent(p: PendingMessage): TimelineEvent {
252- const state: SendState = p.status === 'failed' ? { kind: 'Failed', will_retry: false } : { kind: 'Sending' };
253- return {
254- item_id: `pending-${p.id}`,
255- event_id: null,
256- transaction_id: p.id,
257- sender: {
258- user_id: $sessionUser ?? '',
259- display_name: profile?.display_name ?? null,
260- avatar: null,
261- name_ambiguous: false
262- },
263- timestamp_ms: p.createdAt,
264- own: true,
265- send_state: state,
266- content: {
267- kind: 'Message',
268- message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: p.formatted, edited: false, media: null, link_previews: [] }
269- },
270- reply_to: p.replyTo,
271- thread_root: null,
272- thread_summary: null,
273- reactions: [],
274- read_by: [],
275- editable: false
276- };
277- }
278-
279- interface Group {
280- key: string;
281- item: TimelineItem;
282- showSender: boolean;
283- }
284-
285- function lastOwnSent(list: TimelineItem[]): string | null {
286- for (let i = list.length - 1; i >= 0; i--) {
287- const item = list[i];
288- if (item.kind === 'event' && item.event.own && item.event.send_state.kind === 'Sent') return item.event.item_id;
289- }
290- return null;
291- }
292-
293- function buildGroups(list: TimelineItem[]): Group[] {
294- const out: Group[] = [];
295- let prevSender: string | null = null;
296- let prevTs = 0;
297- list.forEach((item, index) => {
298- let showSender = true;
299- if (item.kind === 'event') {
300- const e = item.event;
301- showSender =
302- prevSender !== e.sender.user_id || Math.abs(e.timestamp_ms - prevTs) > 5 * 60 * 1000;
303- prevSender = e.sender.user_id;
304- prevTs = e.timestamp_ms;
305- out.push({ key: e.item_id || e.event_id || `m-${index}`, item, showSender });
306- } else {
307- prevSender = null;
308- const key = item.kind === 'historyGap' ? `gap-${item.gap.id}` : `${item.kind}-${index}`;
309- out.push({ key, item, showSender });
310- }
311- });
312- return out;
313- }
314234
315235 function roomUnread(r: RoomSummary): number {
316236 return r.unread_messages + r.unread_mentions;
@@ -2463,99 +2383,39 @@
24632383 aria-label={`Messages in ${selectedRoom.name}`}
24642384 >
24652385 <div class="timeline-inner" use:pinTimeline>
2466- <div class="timeline-top">
2467- {#if recentState === 'Loading'}
2468- <p class="side-note">Loading recent history…</p>
2469- {:else if recentState && typeof recentState === 'object'}
2470- <div class="history-gap">
2471- <span>Recent history could not be loaded</span>
2472- <button type="button" class="ghost-btn" disabled={!!recentRetrying[selectedRoom.room_id]} on:click={() => void retryRecentHistory(selectedRoom.room_id)}>
2473- {recentRetrying[selectedRoom.room_id] ? 'Loading…' : 'Retry'}
2474- </button>
2475- </div>
2476- {/if}
2477- {#if hasStart}
2478- <p class="start-note">Beginning of history</p>
2479- {:else}
2480- <button type="button" class="ghost-btn" disabled={paginateBusy || recentState === 'Loading'} on:click={() => void paginate()}>
2481- {paginateBusy ? 'Loading…' : 'Load older messages'}
2482- </button>
2483- {/if}
2484- </div>
2485- {#if grouped.length === 0}
2486- <p class="side-note timeline-empty">No messages yet</p>
2487- {:else}
2488- {#each grouped as g (g.key)}
2489- {#if g.item.kind === 'day'}
2490- <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>
2491- {:else if g.item.kind === 'readMarker'}
2492- <div class="new-row"><span>New</span></div>
2493- {:else if g.item.kind === 'start'}
2494- <p class="start-note">Beginning of history</p>
2495- {:else if g.item.kind === 'historyGap'}
2496- {#if g.item.gap.continuity !== 'Verified'}
2497- {@const gap = g.item.gap}
2498- {@const filling = fillingGaps[selectedRoom.room_id] === gap.id || gap.lifecycle === 'Fetching'}
2499- <div class="history-gap">
2500- <span>{gap.failure ?? 'Some history is not loaded'}</span>
2501- <button type="button" class="ghost-btn" disabled={!!fillingGaps[selectedRoom.room_id] || gap.lifecycle === 'Fetching' || gap.lifecycle === 'Unavailable'} on:click={() => void fillGap(gap.id)}>
2502- {filling ? 'Loading…' : gap.lifecycle === 'Unavailable' ? 'History unavailable' : 'Fetch older history'}
2503- </button>
2504- </div>
2505- {/if}
2506- {:else if g.item.kind === 'unknown'}
2507- <p class="unknown-note">Unsupported event ({g.item.label})</p>
2508- {:else}
2509- <MessageRow
2510- itemId={g.key}
2511- event={g.item.event}
2512- showSender={g.showSender}
2513- senderAvatar={senderAvatarFor(g.item.event.sender.user_id) ?? g.item.event.sender.avatar}
2514- {reacting}
2515- onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
2516- quickReactions={usableQuickReactions}
2517- packs={roomPacks}
2518- linkPreviews={$linkPreviews}
2519- {loadPreview}
2520- onQuickChange={(list) => void changeQuickReactions(list)}
2521- {people}
2522- self={$sessionUser ?? null}
2523- onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}
2524- onCloneSticker={openCloneStickerMenu}
2525- onMore={(ev, x, y) => void openMessageMenu(ev, x, y)}
2526- onOpenThread={selectedRoom.membership === 'Joined' || g.item.event.thread_summary ? openThreadHere : null}
2527- onReply={selectedRoom.membership === 'Joined' ? startReply : null}
2528- onJump={jumpTo}
2529- onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}
2530- editing={editing !== null && editing.eventId === g.item.event.event_id}
2531- showSent={g.item.event.item_id === lastSentId && pendingItems.length === 0}
2532- />
2533- {/if}
2534- {/each}
2535- {/if}
2536- {#each pendingItems as p, i (p.id)}
2537- <MessageRow
2538- itemId={`pending-${p.id}`}
2539- event={pendingToEvent(p)}
2540- onJump={jumpTo}
2541- showSender={pendingShowSender(i)}
2542- senderAvatar={senderAvatarFor($sessionUser ?? '')}
2543- {reacting}
2544- onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
2545- quickReactions={usableQuickReactions}
2546- packs={roomPacks}
2547- linkPreviews={$linkPreviews}
2548- {loadPreview}
2549- onQuickChange={(list) => void changeQuickReactions(list)}
2550- />
2551- {#if p.status === 'failed'}
2552- <div class="pending-actions">
2553- <span class="pending-error" role="alert"><EmojiText text={p.error ?? ''} /></span>
2554- <button type="button" class="ghost-btn" on:click={() => retryPending(p.id)}>Retry</button>
2555- <button type="button" class="ghost-btn danger" on:click={() => discardPending(p.id)}>Discard</button>
2556- </div>
2557- {/if}
2558- {/each}
2386+ <RoomTimelineRows
2387+ {items}
2388+ pending={pendingItems}
2389+ joined={selectedRoom.membership === 'Joined'}
2390+ self={$sessionUser ?? null}
2391+ selfName={profile?.display_name ?? null}
2392+ {people}
2393+ {senderAvatarFor}
2394+ {reacting}
2395+ quickReactions={usableQuickReactions}
2396+ packs={roomPacks}
2397+ linkPreviews={$linkPreviews}
2398+ {loadPreview}
2399+ editingEventId={editing?.eventId ?? null}
2400+ {recentState}
2401+ recentRetrying={!!recentRetrying[selectedRoom.room_id]}
2402+ paginating={paginateBusy}
2403+ fillingGap={fillingGaps[selectedRoom.room_id] ?? null}
2404+ onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
2405+ onQuickChange={(list) => void changeQuickReactions(list)}
2406+ onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}
2407+ onCloneSticker={openCloneStickerMenu}
2408+ onMore={(ev, x, y) => void openMessageMenu(ev, x, y)}
2409+ onOpenThread={openThreadHere}
2410+ onReply={startReply}
2411+ onJump={jumpTo}
2412+ onEdit={startEdit}
2413+ onPaginate={() => void paginate()}
2414+ onFillGap={(gapId) => void fillGap(gapId)}
2415+ onRetryRecent={() => void retryRecentHistory(selectedRoom.room_id)}
2416+ onRetryPending={retryPending}
2417+ onDiscardPending={discardPending}
2418+ />
25592419 </div>
25602420 </div>
25612421
Added file: web/src/lib/RoomTimelineRows.svelte+139 additions
@@ -0,0 +1,139 @@
1+<script lang="ts">
2+ import EmojiText from './EmojiText.svelte';
3+ import MessageRow from './MessageRow.svelte';
4+ import { formatDay } from './format';
5+ import type { PendingMessage } from './pending';
6+ import { buildGroups, lastOwnSent, lastSenderIs, pendingToEvent } from './timeline-groups';
7+ import type { ImagePack, LinkPreview, Person, QuickReaction, RecentHistoryState, ReplyReference, TimelineEvent, TimelineItem } from './types';
8+
9+ export let items: TimelineItem[];
10+ export let pending: PendingMessage[] = [];
11+ export let joined: boolean;
12+ export let self: string | null;
13+ export let selfName: string | null = null;
14+ export let people: Record<string, Person> = {};
15+ export let senderAvatarFor: (userId: string) => string | null;
16+ export let reacting: boolean;
17+ export let quickReactions: QuickReaction[] = [];
18+ export let packs: ImagePack[] = [];
19+ export let linkPreviews: Record<string, LinkPreview | null> = {};
20+ export let loadPreview: ((url: string) => void) | null = null;
21+ export let editingEventId: string | null = null;
22+ export let recentState: RecentHistoryState | undefined = undefined;
23+ export let recentRetrying = false;
24+ export let paginating = false;
25+ export let fillingGap: string | null = null;
26+ export let onReact: (eventId: string, key: string, shortcode?: string) => void;
27+ export let onQuickChange: (list: QuickReaction[]) => void;
28+ export let onUser: (userId: string, x: number, y: number, menu: boolean) => void;
29+ export let onCloneSticker: (eventId: string, body: string, x: number, y: number) => void;
30+ export let onMore: (event: TimelineEvent, x: number, y: number) => void;
31+ export let onOpenThread: (rootEventId: string) => void;
32+ export let onReply: (target: ReplyReference) => void;
33+ export let onJump: (eventId: string) => void;
34+ export let onEdit: (eventId: string, body: string) => void;
35+ export let onPaginate: () => void;
36+ export let onFillGap: (gapId: string) => void;
37+ export let onRetryRecent: () => void;
38+ export let onRetryPending: (id: string) => void;
39+ export let onDiscardPending: (id: string) => void;
40+
41+ $: hasStart = items.some((item) => item.kind === 'start');
42+ $: grouped = buildGroups(items);
43+ $: lastSentId = lastOwnSent(items);
44+ $: pendingFollowsSelf = lastSenderIs(items, self);
45+</script>
46+
47+<div class="timeline-top">
48+ {#if recentState === 'Loading'}
49+ <p class="side-note">Loading recent history…</p>
50+ {:else if recentState && typeof recentState === 'object'}
51+ <div class="history-gap">
52+ <span>Recent history could not be loaded</span>
53+ <button type="button" class="ghost-btn" disabled={recentRetrying} on:click={onRetryRecent}>
54+ {recentRetrying ? 'Loading…' : 'Retry'}
55+ </button>
56+ </div>
57+ {/if}
58+ {#if hasStart}
59+ <p class="start-note">Beginning of history</p>
60+ {:else}
61+ <button type="button" class="ghost-btn" disabled={paginating || recentState === 'Loading'} on:click={onPaginate}>
62+ {paginating ? 'Loading…' : 'Load older messages'}
63+ </button>
64+ {/if}
65+</div>
66+{#if grouped.length === 0}
67+ <p class="side-note timeline-empty">No messages yet</p>
68+{:else}
69+ {#each grouped as g (g.key)}
70+ {#if g.item.kind === 'day'}
71+ <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>
72+ {:else if g.item.kind === 'readMarker'}
73+ <div class="new-row"><span>New</span></div>
74+ {:else if g.item.kind === 'start'}
75+ <p class="start-note">Beginning of history</p>
76+ {:else if g.item.kind === 'historyGap'}
77+ {#if g.item.gap.continuity !== 'Verified'}
78+ {@const gap = g.item.gap}
79+ {@const filling = fillingGap === gap.id || gap.lifecycle === 'Fetching'}
80+ <div class="history-gap">
81+ <span>{gap.failure ?? 'Some history is not loaded'}</span>
82+ <button type="button" class="ghost-btn" disabled={!!fillingGap || gap.lifecycle === 'Fetching' || gap.lifecycle === 'Unavailable'} on:click={() => onFillGap(gap.id)}>
83+ {filling ? 'Loading…' : gap.lifecycle === 'Unavailable' ? 'History unavailable' : 'Fetch older history'}
84+ </button>
85+ </div>
86+ {/if}
87+ {:else if g.item.kind === 'unknown'}
88+ <p class="unknown-note">Unsupported event ({g.item.label})</p>
89+ {:else}
90+ <MessageRow
91+ itemId={g.key}
92+ event={g.item.event}
93+ showSender={g.showSender}
94+ senderAvatar={senderAvatarFor(g.item.event.sender.user_id) ?? g.item.event.sender.avatar}
95+ {reacting}
96+ {onReact}
97+ {quickReactions}
98+ {packs}
99+ {linkPreviews}
100+ {loadPreview}
101+ {onQuickChange}
102+ {people}
103+ {self}
104+ {onUser}
105+ {onCloneSticker}
106+ {onMore}
107+ onOpenThread={joined || g.item.event.thread_summary ? onOpenThread : null}
108+ onReply={joined ? onReply : null}
109+ {onJump}
110+ onEdit={joined ? onEdit : null}
111+ editing={editingEventId !== null && editingEventId === g.item.event.event_id}
112+ showSent={g.item.event.item_id === lastSentId && pending.length === 0}
113+ />
114+ {/if}
115+ {/each}
116+{/if}
117+{#each pending as p, i (p.id)}
118+ <MessageRow
119+ itemId={`pending-${p.id}`}
120+ event={pendingToEvent(p, self, selfName)}
121+ {onJump}
122+ showSender={i === 0 && !pendingFollowsSelf}
123+ senderAvatar={senderAvatarFor(self ?? '')}
124+ {reacting}
125+ {onReact}
126+ {quickReactions}
127+ {packs}
128+ {linkPreviews}
129+ {loadPreview}
130+ {onQuickChange}
131+ />
132+ {#if p.status === 'failed'}
133+ <div class="pending-actions">
134+ <span class="pending-error" role="alert"><EmojiText text={p.error ?? ''} /></span>
135+ <button type="button" class="ghost-btn" on:click={() => onRetryPending(p.id)}>Retry</button>
136+ <button type="button" class="ghost-btn danger" on:click={() => onDiscardPending(p.id)}>Discard</button>
137+ </div>
138+ {/if}
139+{/each}
Added file: web/src/lib/timeline-groups.ts+70 additions
@@ -0,0 +1,70 @@
1+import type { PendingMessage } from './pending';
2+import type { SendState, TimelineEvent, TimelineItem } from './types';
3+
4+const SENDER_RUN_MS = 5 * 60 * 1000;
5+
6+export interface Group {
7+ key: string;
8+ item: TimelineItem;
9+ showSender: boolean;
10+}
11+
12+/** Consecutive events from one sender within a few minutes share a header. */
13+export function buildGroups(list: TimelineItem[]): Group[] {
14+ const out: Group[] = [];
15+ let prevSender: string | null = null;
16+ let prevTs = 0;
17+ list.forEach((item, index) => {
18+ if (item.kind === 'event') {
19+ const e = item.event;
20+ const showSender = prevSender !== e.sender.user_id || Math.abs(e.timestamp_ms - prevTs) > SENDER_RUN_MS;
21+ prevSender = e.sender.user_id;
22+ prevTs = e.timestamp_ms;
23+ out.push({ key: e.item_id || e.event_id || `m-${index}`, item, showSender });
24+ } else {
25+ prevSender = null;
26+ const key = item.kind === 'historyGap' ? `gap-${item.gap.id}` : `${item.kind}-${index}`;
27+ out.push({ key, item, showSender: true });
28+ }
29+ });
30+ return out;
31+}
32+
33+export function lastOwnSent(list: TimelineItem[]): string | null {
34+ for (let i = list.length - 1; i >= 0; i--) {
35+ const item = list[i];
36+ if (item.kind === 'event' && item.event.own && item.event.send_state.kind === 'Sent') return item.event.item_id;
37+ }
38+ return null;
39+}
40+
41+export function lastSenderIs(list: TimelineItem[], userId: string | null): boolean {
42+ for (let i = list.length - 1; i >= 0; i -= 1) {
43+ const item = list[i];
44+ if (item.kind === 'event') return item.event.sender.user_id === userId;
45+ }
46+ return false;
47+}
48+
49+export function pendingToEvent(p: PendingMessage, self: string | null, displayName: string | null): TimelineEvent {
50+ const state: SendState = p.status === 'failed' ? { kind: 'Failed', will_retry: false } : { kind: 'Sending' };
51+ return {
52+ item_id: `pending-${p.id}`,
53+ event_id: null,
54+ transaction_id: p.id,
55+ sender: { user_id: self ?? '', display_name: displayName, avatar: null, name_ambiguous: false },
56+ timestamp_ms: p.createdAt,
57+ own: true,
58+ send_state: state,
59+ content: {
60+ kind: 'Message',
61+ message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: p.formatted, edited: false, media: null, link_previews: [] }
62+ },
63+ reply_to: p.replyTo,
64+ thread_root: null,
65+ thread_summary: null,
66+ reactions: [],
67+ read_by: [],
68+ editable: false
69+ };
70+}
Added file: web/test/timeline-groups.test.ts+37 additions
@@ -0,0 +1,37 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { buildGroups, lastOwnSent, lastSenderIs } from '../src/lib/timeline-groups.ts';
4+import type { TimelineEvent, TimelineItem } from '../src/lib/types.ts';
5+
6+const MINUTE = 60 * 1000;
7+
8+function event(id: string, sender: string, timestamp_ms: number, extra: Partial<TimelineEvent> = {}): TimelineItem {
9+ return {
10+ kind: 'event',
11+ event: { item_id: id, event_id: id, timestamp_ms, own: false, send_state: { kind: 'Sent' }, sender: { user_id: sender }, ...extra } as TimelineEvent
12+ };
13+}
14+
15+test('a sender run shares one header until a gap or another sender', () => {
16+ const groups = buildGroups([
17+ event('a', '@x', 0),
18+ event('b', '@x', MINUTE),
19+ event('c', '@x', 10 * MINUTE),
20+ event('d', '@y', 11 * MINUTE),
21+ { kind: 'day', timestamp_ms: 12 * MINUTE },
22+ event('e', '@y', 12 * MINUTE)
23+ ]);
24+ assert.deepEqual(groups.map((g) => g.showSender), [true, false, true, true, true, true]);
25+});
26+
27+test('gap rows are keyed by their id so pagination keeps them mounted', () => {
28+ const gap = { kind: 'historyGap', gap: { id: 'g1', older_boundary: null, newer_boundary: null, lifecycle: 'Idle', continuity: 'Unresolved', failure: null } } as TimelineItem;
29+ assert.equal(buildGroups([event('a', '@x', 0), gap])[1].key, 'gap-g1');
30+});
31+
32+test('the latest own sent event and the latest sender are found from the end', () => {
33+ const list = [event('a', '@me', 0, { own: true }), event('b', '@x', 1), event('c', '@me', 2, { own: true, send_state: { kind: 'Sending' } })];
34+ assert.equal(lastOwnSent(list), 'a');
35+ assert.equal(lastSenderIs(list, '@me'), true);
36+ assert.equal(lastSenderIs([], '@me'), false);
37+});