atagen

malice

public
Star0
Watch0

Commit 4f91f89d99fa

tarball

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

5 files changed+62-11
threads: open thread messages where they live

Thread replies never appear in the room timeline, so jumping to one from the mentions inbox, a pin or a reply quote either paginated the room in vain or reported it as not loaded. Those jumps now open the thread pane focused on the event, using the event's thread_root when it is known and the loaded thread timelines otherwise.
Modified file: web/src/App.svelte+35 additions−4 deletions
@@ -75,6 +75,7 @@
7575 selectedRoomId,
7676 sessionUser,
7777 softLogout,
78+ threadTimelines,
7879 timelines,
7980 pinnedTimelines,
8081 pinnedTimelineVersions,
@@ -1094,7 +1095,12 @@
10941095 const roomId = $selectedRoomId;
10951096 if (!roomId || !eventId || pinJumpBusy) return;
10961097 pinsExpanded = false;
1097- pinPreview = pins.find((event) => event.event_id === eventId) ?? null;
1098+ const pinned = pins.find((event) => event.event_id === eventId) ?? null;
1099+ if (pinned?.thread_root) {
1100+ openThreadHere(pinned.thread_root, eventId);
1101+ return;
1102+ }
1103+ pinPreview = pinned;
10981104 pinJumpBusy = true;
10991105 try {
11001106 for (let attempt = 0; attempt < 24; attempt += 1) {
@@ -1117,6 +1123,11 @@
11171123
11181124 function jumpTo(eventId: string): void {
11191125 const el = timelineEl?.querySelector<HTMLElement>(`[data-event="${CSS.escape(eventId)}"]`);
1126+ const root = !el && $selectedRoomId ? threadHolding($selectedRoomId, eventId) : null;
1127+ if (root) {
1128+ openThreadHere(root, eventId);
1129+ return;
1130+ }
11201131 if (!el) {
11211132 pushBanner('The original message is not loaded. Scroll up to load older messages.');
11221133 return;
@@ -1274,10 +1285,29 @@
12741285 $: threadShown = !!threadView && (bothPanesFit || !membersOpen || rightFront === 'thread');
12751286 $: membersShown = !!selectedRoom && membersOpen && (bothPanesFit || !threadView || rightFront === 'members');
12761287
1277- function openThreadHere(rootEventId: string): void {
1288+ function openThreadHere(rootEventId: string, focusEventId?: string): void {
12781289 if (!$selectedRoomId) return;
12791290 rightFront = 'thread';
1280- void showThread({ room_id: $selectedRoomId, root_event_id: rootEventId });
1291+ void showThread({ room_id: $selectedRoomId, root_event_id: rootEventId, focus_event_id: focusEventId });
1292+ }
1293+
1294+ async function openLive(roomId: string, eventId: string, threadRoot: string | null): Promise<void> {
1295+ await selectRoom(roomId);
1296+ if ($selectedRoomId !== roomId) return;
1297+ if (threadRoot) {
1298+ openThreadHere(threadRoot, eventId);
1299+ return;
1300+ }
1301+ await tick();
1302+ if (timelineEl?.querySelector(`[data-event="${CSS.escape(eventId)}"]`)) jumpTo(eventId);
1303+ }
1304+
1305+ function threadHolding(roomId: string, eventId: string): string | null {
1306+ for (const [key, list] of Object.entries($threadTimelines)) {
1307+ const [room, root] = key.split('\u0000');
1308+ if (room === roomId && list.some((item) => item.kind === 'event' && item.event.event_id === eventId)) return root;
1309+ }
1310+ return null;
12811311 }
12821312
12831313 async function openThreadIn(roomId: string, rootEventId: string): Promise<void> {
@@ -2267,7 +2297,7 @@
22672297 {#key mentionsOpen ? 'mentions' : $selectedRoomId ?? 'home'}
22682298 <div class="chat-view">
22692299 {#if mentionsOpen && $sessionUser}
2270- <MentionsInbox self={$sessionUser} onLive={(roomId) => void selectRoom(roomId)} />
2300+ <MentionsInbox self={$sessionUser} onLive={(roomId, eventId, threadRoot) => void openLive(roomId, eventId, threadRoot)} />
22712301 {:else if !selectedRoom}
22722302 <div class="empty" in:fade={{ duration: motionDuration(180) }}>
22732303 <div class="empty-mark" aria-hidden="true">◈</div>
@@ -2552,6 +2582,7 @@
25522582 onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}
25532583 onMore={(ev, x, y) => void openMessageMenu(ev, x, y)}
25542584 onMentionStart={() => !$membersByRoom[threadView.room_id] && void loadMembers(threadView.room_id)}
2585+ focusEventId={threadView.focus_event_id ?? null}
25552586 onClose={() => void showThread(null)}
25562587 />
25572588 {/if}
Modified file: web/src/lib/ContextTimeline.svelte+3 additions−2 deletions
@@ -14,10 +14,11 @@
1414 export let self: string | null = null;
1515 export let canPaginate = true;
1616 export let onClose: () => void;
17- export let onLive: (roomId: string) => void;
17+ export let onLive: (roomId: string, eventId: string, threadRoot: string | null) => void;
1818 export let closeLabel = 'Back to mentions';
1919
2020 let rows: TimelineItem[] = [];
21+ $: targetThread = rows.reduce<string | null>((found, item) => found ?? (item.kind === 'event' && item.event.event_id === eventId ? item.event.thread_root : null), null);
2122 let beforeToken: string | null = null;
2223 let afterToken: string | null = null;
2324 let beforeEnded = false;
@@ -186,7 +187,7 @@
186187 <header>
187188 <button bind:this={backButton} onclick={onClose}><EmojiText text={closeLabel} /></button>
188189 <strong><EmojiText text={roomName} /></strong>
189- <button disabled={!canPaginate} onclick={() => onLive(roomId)}>Go to current messages</button>
190+ <button disabled={!canPaginate} onclick={() => onLive(roomId, eventId, targetThread)}>Go to current messages</button>
190191 </header>
191192 {#if error}<div class="error" role="alert"><EmojiText text={error} /></div>{/if}
192193 <button class="page-control" disabled={!beforeToken || beforeEnded || beforeBusy || !canPaginate} onclick={() => load('before')}>
Modified file: web/src/lib/MentionsInbox.svelte+1 additions−1 deletions
@@ -13,7 +13,7 @@
1313 import type { MentionEntry, MentionsPage, SavedMentionContext } from './mentions-model';
1414
1515 export let self: string;
16- export let onLive: (roomId: string) => void;
16+ export let onLive: (roomId: string, eventId: string, threadRoot: string | null) => void;
1717
1818 let page: MentionsPage | null = null;
1919 let entries: MentionEntry[] = [];
Modified file: web/src/lib/ThreadPane.svelte+19 additions−1 deletions
@@ -49,11 +49,13 @@
4949 export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
5050 export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
5151 export let onMentionStart: () => void = () => {};
52+ export let focusEventId: string | null = null;
5253 export let onClose: () => void;
5354
5455 const GROUP_GAP_MS = 5 * 60 * 1000;
5556
5657 let list: HTMLDivElement | null = null;
58+ let thread: HTMLDivElement | null = null;
5759 let composerRef: Composer | null = null;
5860 let draft = '';
5961 let picked: Mention[] = [];
@@ -77,6 +79,8 @@
7779 $: emotes = customEmotes(packs);
7880 $: switchTo(key);
7981 $: void follow(events);
82+ let focused: string | null = null;
83+ $: void focusOn(focusEventId, events);
8084
8185 function threadEvents(list: TimelineItem[], root: TimelineItem | null): TimelineEvent[] {
8286 const out = list.flatMap((item) => (item.kind === 'event' ? [item.event] : []));
@@ -103,6 +107,20 @@
103107 editing = null;
104108 stickToBottom = true;
105109 lastRead = '';
110+ focused = null;
111+ }
112+
113+ async function focusOn(target: string | null, list: TimelineEvent[]): Promise<void> {
114+ if (!target || target === focused || !list.some((event) => event.event_id === target)) return;
115+ focused = target;
116+ stickToBottom = false;
117+ await tick();
118+ const row = list.length && thread ? thread.querySelector<HTMLElement>(`[data-event="${CSS.escape(target)}"]`) : null;
119+ if (!row) return;
120+ row.scrollIntoView({ block: 'center', behavior: 'smooth' });
121+ row.classList.remove('flash');
122+ void row.offsetWidth;
123+ row.classList.add('flash');
106124 }
107125
108126 async function follow(_events: TimelineEvent[]): Promise<void> {
@@ -260,7 +278,7 @@
260278 <svelte:window on:focus={sendReceipt} />
261279 <svelte:document on:visibilitychange={sendReceipt} />
262280
263-<div class="thread-pane">
281+<div class="thread-pane" bind:this={thread}>
264282 <header class="thread-head">
265283 <h2>Thread</h2>
266284 <span class="thread-room"><EmojiText text={roomName} /></span>
Modified file: web/src/lib/threads.ts+4 additions−3 deletions
@@ -9,6 +9,7 @@ const PINNED_TYPE = 'dev.malice.pinned_threads';
99 export interface OpenThread {
1010 room_id: string;
1111 root_event_id: string;
12+ focus_event_id?: string;
1213 }
1314
1415 export const openThread = writable<OpenThread | null>(null);
@@ -62,13 +63,13 @@ let opened: OpenThread | null = null;
6263 /** The room must already be open, since macltok builds thread timelines inside it. */
6364 export async function showThread(target: OpenThread | null): Promise<void> {
6465 const previous = opened;
65- if (previous && target && previous.room_id === target.room_id && previous.root_event_id === target.root_event_id) return;
6666 opened = target;
6767 openThread.set(target);
68- if (previous) runCommand({ op: 'close_thread', ...previous }).catch(() => undefined);
68+ if (previous && target && previous.room_id === target.room_id && previous.root_event_id === target.root_event_id) return;
69+ if (previous) runCommand({ op: 'close_thread', room_id: previous.room_id, root_event_id: previous.root_event_id }).catch(() => undefined);
6970 if (!target) return;
7071 try {
71- await runCommand({ op: 'open_thread', ...target });
72+ await runCommand({ op: 'open_thread', room_id: target.room_id, root_event_id: target.root_event_id });
7273 } catch (e) {
7374 if (opened === target) {
7475 opened = null;