atagen

malice

public
Star0
Watch0

Commit 7ebc2472a9e3

tarball

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

17 files changed+923-106
threads: open threads in a side pane with room foldouts

Thread replies were invisible, because macltok's live timeline hides threaded events and malice never rendered thread summaries or opened thread timelines. Roots now carry a reply chip and a reply-in-thread action, and the pane runs on macltok's thread timeline for paginating, sending, editing, reacting and threaded receipts. Each room in the channels pane can fold out its threads, newest activity first. Pins go in dev.malice.pinned_threads account data so they follow the account, and the room search box matches thread text. Until macltok ships its per-room thread list, the foldout only knows threads whose roots are in loaded history.
Modified file: crates/malice-core/src/feed.rs+54 additions−1 deletions
@@ -15,6 +15,7 @@ pub enum WireEvent {
1515 rooms: Vec<RoomSummary>,
1616 timelines: HashMap<String, Vector<TimelineItem>>,
1717 pinned: HashMap<String, Vector<TimelineItem>>,
18+ threads: Vec<ThreadItems>,
1819 connection: String,
1920 encryption: Option<EncryptionState>,
2021 verification: Option<VerificationState>,
@@ -50,6 +51,15 @@ pub enum WireEvent {
5051 PinnedClosed {
5152 room_id: String,
5253 },
54+ Thread {
55+ room_id: String,
56+ root_event_id: String,
57+ items: Vector<TimelineItem>,
58+ },
59+ ThreadClosed {
60+ room_id: String,
61+ root_event_id: String,
62+ },
5363 Connection {
5464 state: String,
5565 },
@@ -70,6 +80,13 @@ pub enum WireEvent {
7080 },
7181 }
7282
83+#[derive(Debug, Clone, Serialize)]
84+pub struct ThreadItems {
85+ pub room_id: String,
86+ pub root_event_id: String,
87+ pub items: Vector<TimelineItem>,
88+}
89+
7390 /// `macltok::Retain`'s wire form. macltok's own enum has no `rename_all`,
7491 /// so this exists to give it one.
7592 #[derive(Debug, Clone, Copy, Serialize)]
@@ -112,10 +129,26 @@ pub fn snapshot_event(state: &State) -> WireEvent {
112129 TimelineId::Room(_) | TimelineId::Thread { .. } => None,
113130 })
114131 .collect();
132+ let threads = state
133+ .timelines
134+ .iter()
135+ .filter_map(|(timeline_id, timeline)| match timeline_id {
136+ TimelineId::Thread {
137+ room_id,
138+ root_event_id,
139+ } => Some(ThreadItems {
140+ room_id: room_id.as_str().to_owned(),
141+ root_event_id: root_event_id.as_str().to_owned(),
142+ items: timeline.items.clone(),
143+ }),
144+ TimelineId::Room(_) | TimelineId::Pinned(_) => None,
145+ })
146+ .collect();
115147 WireEvent::Snapshot {
116148 rooms: state.rooms.iter().cloned().collect(),
117149 timelines,
118150 pinned,
151+ threads,
119152 connection: connection_label(&state.connection),
120153 encryption: state.encryption.clone(),
121154 verification: state.verification.clone(),
@@ -179,7 +212,27 @@ pub fn wire_events(change: &StateChange) -> Vec<WireEvent> {
179212 }],
180213 TimelineChange::Diverged => vec![],
181214 },
182- StateChange::Timeline { .. } => vec![],
215+ StateChange::Timeline {
216+ timeline_id:
217+ TimelineId::Thread {
218+ room_id,
219+ root_event_id,
220+ },
221+ change,
222+ } => match change {
223+ TimelineChange::Reset { items } | TimelineChange::Changed { items, .. } => {
224+ vec![WireEvent::Thread {
225+ room_id: room_id.as_str().to_owned(),
226+ root_event_id: root_event_id.as_str().to_owned(),
227+ items: items.clone(),
228+ }]
229+ }
230+ TimelineChange::Closed => vec![WireEvent::ThreadClosed {
231+ room_id: room_id.as_str().to_owned(),
232+ root_event_id: root_event_id.as_str().to_owned(),
233+ }],
234+ TimelineChange::Diverged => vec![],
235+ },
183236 StateChange::Connection(state) => vec![WireEvent::Connection {
184237 state: connection_label(state),
185238 }],
Modified file: crates/malice-core/src/request.rs+76 additions−18 deletions
@@ -23,6 +23,17 @@ fn default_avatar_size() -> u32 {
2323 AVATAR_SIZE
2424 }
2525
26+fn timeline_for(room_id: &str, thread_root: Option<&str>) -> Result<TimelineId, CoreError> {
27+ let room_id = parse_room_id(room_id)?;
28+ Ok(match thread_root {
29+ Some(root) => TimelineId::Thread {
30+ room_id,
31+ root_event_id: parse_event_id(root)?,
32+ },
33+ None => TimelineId::Room(room_id),
34+ })
35+}
36+
2637 fn parse_keyword(raw: &str) -> Result<String, CoreError> {
2738 let keyword = raw.trim();
2839 if keyword.is_empty() || keyword.chars().count() > 128 {
@@ -62,6 +73,8 @@ pub enum Request {
6273 mentions: Vec<String>,
6374 #[serde(default)]
6475 kind: SendKind,
76+ #[serde(default)]
77+ thread_root: Option<String>,
6578 },
6679 Edit {
6780 #[serde(default)]
@@ -74,6 +87,8 @@ pub enum Request {
7487 formatted_body: Option<String>,
7588 #[serde(default)]
7689 mentions: Vec<String>,
90+ #[serde(default)]
91+ thread_root: Option<String>,
7792 },
7893 Upload {
7994 room_id: String,
@@ -93,6 +108,8 @@ pub enum Request {
93108 Paginate {
94109 #[serde(default)]
95110 room_id: String,
111+ #[serde(default)]
112+ thread_root: Option<String>,
96113 },
97114 StartRecentHistory {
98115 room_id: String,
@@ -172,12 +189,16 @@ pub enum Request {
172189 key: String,
173190 #[serde(default)]
174191 shortcode: Option<String>,
192+ #[serde(default)]
193+ thread_root: Option<String>,
175194 },
176195 Read {
177196 #[serde(default)]
178197 room_id: String,
179198 #[serde(default)]
180199 event_id: String,
200+ #[serde(default)]
201+ thread_root: Option<String>,
181202 },
182203 Profile,
183204 SpaceChildren {
@@ -267,6 +288,16 @@ pub enum Request {
267288 event_id: String,
268289 #[serde(default)]
269290 reason: Option<String>,
291+ #[serde(default)]
292+ thread_root: Option<String>,
293+ },
294+ OpenThread {
295+ room_id: String,
296+ root_event_id: String,
297+ },
298+ CloseThread {
299+ room_id: String,
300+ root_event_id: String,
270301 },
271302 SetFavourite {
272303 room_id: String,
@@ -504,8 +535,9 @@ pub async fn dispatch(
504535 reply_to,
505536 mentions,
506537 kind,
538+ thread_root,
507539 } => {
508- let room_id = parse_room_id(&room_id)?;
540+ let timeline = timeline_for(&room_id, thread_root.as_deref())?;
509541 if body.trim().is_empty() {
510542 return Err(CoreError::bad_request("body must not be empty"));
511543 }
@@ -529,9 +561,7 @@ pub async fn dispatch(
529561 mentions,
530562 },
531563 };
532- handle
533- .send_message(TimelineId::Room(room_id), content)
534- .await?;
564+ handle.send_message(timeline, content).await?;
535565 Ok(Reply::Done)
536566 }
537567 Request::Upload {
@@ -566,9 +596,13 @@ pub async fn dispatch(
566596 handle.upload(upload).await?;
567597 Ok(Reply::Done)
568598 }
569- Request::Paginate { room_id } => {
570- let room_id = parse_room_id(&room_id)?;
571- handle.paginate(TimelineId::Room(room_id), 30).await?;
599+ Request::Paginate {
600+ room_id,
601+ thread_root,
602+ } => {
603+ handle
604+ .paginate(timeline_for(&room_id, thread_root.as_deref())?, 30)
605+ .await?;
572606 Ok(Reply::Done)
573607 }
574608 Request::StartRecentHistory { room_id } => {
@@ -689,8 +723,9 @@ pub async fn dispatch(
689723 event_id,
690724 key,
691725 shortcode,
726+ thread_root,
692727 } => {
693- let room_id = parse_room_id(&room_id)?;
728+ let timeline = timeline_for(&room_id, thread_root.as_deref())?;
694729 let event_id = parse_event_id(&event_id)?;
695730 let key = key.trim();
696731 if !valid_reaction_key(key) {
@@ -708,7 +743,7 @@ pub async fn dispatch(
708743 }
709744 };
710745 handle
711- .toggle_reaction(TimelineId::Room(room_id), event_id, key, shortcode)
746+ .toggle_reaction(timeline, event_id, key, shortcode)
712747 .await?;
713748 Ok(Reply::Done)
714749 }
@@ -718,8 +753,9 @@ pub async fn dispatch(
718753 body,
719754 formatted_body,
720755 mentions,
756+ thread_root,
721757 } => {
722- let room_id = parse_room_id(&room_id)?;
758+ let timeline = timeline_for(&room_id, thread_root.as_deref())?;
723759 let event_id = parse_event_id(&event_id)?;
724760 if body.trim().is_empty() {
725761 return Err(CoreError::bad_request("body must not be empty"));
@@ -734,16 +770,19 @@ pub async fn dispatch(
734770 },
735771 mentions: parse_mentions(&mentions)?,
736772 };
737- handle
738- .edit_message(TimelineId::Room(room_id), event_id, content)
739- .await?;
773+ handle.edit_message(timeline, event_id, content).await?;
740774 Ok(Reply::Done)
741775 }
742- Request::Read { room_id, event_id } => {
743- let room_id = parse_room_id(&room_id)?;
744- let event_id = parse_event_id(&event_id)?;
776+ Request::Read {
777+ room_id,
778+ event_id,
779+ thread_root,
780+ } => {
745781 handle
746- .mark_read(TimelineId::Room(room_id), event_id)
782+ .mark_read(
783+ timeline_for(&room_id, thread_root.as_deref())?,
784+ parse_event_id(&event_id)?,
785+ )
747786 .await?;
748787 Ok(Reply::Done)
749788 }
@@ -967,16 +1006,35 @@ pub async fn dispatch(
9671006 room_id,
9681007 event_id,
9691008 reason,
1009+ thread_root,
9701010 } => {
9711011 handle
9721012 .redact(
973- TimelineId::Room(parse_room_id(&room_id)?),
1013+ timeline_for(&room_id, thread_root.as_deref())?,
9741014 parse_event_id(&event_id)?,
9751015 reason_text(reason)?,
9761016 )
9771017 .await?;
9781018 Ok(Reply::Done)
9791019 }
1020+ Request::OpenThread {
1021+ room_id,
1022+ root_event_id,
1023+ } => {
1024+ handle
1025+ .open_thread(parse_room_id(&room_id)?, parse_event_id(&root_event_id)?)
1026+ .await?;
1027+ Ok(Reply::Done)
1028+ }
1029+ Request::CloseThread {
1030+ room_id,
1031+ root_event_id,
1032+ } => {
1033+ handle
1034+ .close_thread(parse_room_id(&room_id)?, parse_event_id(&root_event_id)?)
1035+ .await?;
1036+ Ok(Reply::Done)
1037+ }
9801038 Request::SetFavourite { room_id, favourite } => {
9811039 handle
9821040 .set_favourite(parse_room_id(&room_id)?, favourite)
Modified file: crates/malice-server/src/routes.rs+17 additions
@@ -47,12 +47,16 @@ struct SendBody {
4747 mentions: Vec<String>,
4848 #[serde(default)]
4949 kind: SendKind,
50+ #[serde(default)]
51+ thread_root: Option<String>,
5052 }
5153
5254 #[derive(Debug, Deserialize)]
5355 struct PaginateBody {
5456 #[serde(default)]
5557 room_id: String,
58+ #[serde(default)]
59+ thread_root: Option<String>,
5660 }
5761
5862 #[derive(Debug, Deserialize)]
@@ -101,6 +105,8 @@ struct ReactBody {
101105 key: String,
102106 #[serde(default)]
103107 shortcode: Option<String>,
108+ #[serde(default)]
109+ thread_root: Option<String>,
104110 }
105111
106112 #[derive(Debug, Deserialize)]
@@ -115,6 +121,8 @@ struct EditBody {
115121 formatted_body: Option<String>,
116122 #[serde(default)]
117123 mentions: Vec<String>,
124+ #[serde(default)]
125+ thread_root: Option<String>,
118126 }
119127
120128 #[derive(Debug, Deserialize)]
@@ -123,6 +131,8 @@ struct ReadBody {
123131 room_id: String,
124132 #[serde(default)]
125133 event_id: String,
134+ #[serde(default)]
135+ thread_root: Option<String>,
126136 }
127137
128138 #[derive(Debug, Deserialize)]
@@ -445,6 +455,7 @@ async fn rooms_send(
445455 reply_to: body.0.reply_to,
446456 mentions: body.0.mentions,
447457 kind: body.0.kind,
458+ thread_root: body.0.thread_root,
448459 },
449460 )
450461 .await,
@@ -576,6 +587,8 @@ async fn post_command(
576587 | Request::Invite { .. }
577588 | Request::Direct { .. }
578589 | Request::Redact { .. }
590+ | Request::OpenThread { .. }
591+ | Request::CloseThread { .. }
579592 | Request::SetFavourite { .. }
580593 | Request::ReorderSpaces { .. }
581594 | Request::SetRoomDisplayName { .. }
@@ -692,6 +705,7 @@ async fn rooms_paginate(
692705 &user.mirror,
693706 Request::Paginate {
694707 room_id: body.0.room_id,
708+ thread_root: body.0.thread_root,
695709 },
696710 )
697711 .await,
@@ -849,6 +863,7 @@ async fn rooms_edit(
849863 body: body.0.body,
850864 formatted_body: body.0.formatted_body,
851865 mentions: body.0.mentions,
866+ thread_root: body.0.thread_root,
852867 },
853868 )
854869 .await,
@@ -873,6 +888,7 @@ async fn rooms_react(
873888 event_id: body.0.event_id,
874889 key: body.0.key,
875890 shortcode: body.0.shortcode,
891+ thread_root: body.0.thread_root,
876892 },
877893 )
878894 .await,
@@ -895,6 +911,7 @@ async fn rooms_read(
895911 Request::Read {
896912 room_id: body.0.room_id,
897913 event_id: body.0.event_id,
914+ thread_root: body.0.thread_root,
898915 },
899916 )
900917 .await,
Modified file: web/src/App.svelte+155 additions−53 deletions
@@ -6,6 +6,10 @@
66 import MentionsInbox from './lib/MentionsInbox.svelte';
77 import MxcAvatar from './lib/MxcAvatar.svelte';
88 import PinButton from './lib/PinButton.svelte';
9+ import ThreadPane from './lib/ThreadPane.svelte';
10+ import RoomThreads from './lib/RoomThreads.svelte';
11+ import { loadPinnedThreads, openThread, showThread, threadsByRoom } from './lib/threads';
12+ import { threadMatches } from './lib/thread-index';
913 import ContextMenu, { type MenuItem } from './lib/ContextMenu.svelte';
1014 import StickerPicker from './lib/StickerPicker.svelte';
1115 import PackManager from './lib/PackManager.svelte';
@@ -100,13 +104,15 @@
100104 import {
101105 MEMBERS_WIDTH,
102106 SIDE_WIDTH,
107+ THREAD_WIDTH,
103108 lastSpaceRooms,
104109 lineHeight,
105110 membersWidth,
106111 messageSpacing,
107112 navFolded,
108113 railFolded,
109- sideWidth
114+ sideWidth,
115+ threadWidth
110116 } from './lib/appearance';
111117 import SettingsPanel from './lib/SettingsPanel.svelte';
112118 import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
@@ -194,6 +200,7 @@
194200 if (online && !wasOnline) {
195201 void loadQuickReactions();
196202 void loadKeywords();
203+ void loadPinnedThreads();
197204 void loadPackPreferences();
198205 void loadUrlPreviewSettings();
199206 }
@@ -224,7 +231,9 @@
224231 $: visibleRooms = query
225232 ? baseRooms.filter(
226233 (r) =>
227- r.name.toLowerCase().includes(query) || r.room_id.toLowerCase().includes(query)
234+ r.name.toLowerCase().includes(query) ||
235+ r.room_id.toLowerCase().includes(query) ||
236+ ($threadsByRoom[r.room_id] ?? []).some((t) => threadMatches(t, query))
228237 )
229238 : baseRooms;
230239 $: directRooms = pinnedFirst(visibleRooms.filter((r) => r.direct && !r.space && r.membership !== 'Invited'));
@@ -418,6 +427,7 @@
418427 void loadEncryption();
419428 void loadQuickReactions();
420429 void loadKeywords();
430+ void loadPinnedThreads();
421431 void loadPackPreferences();
422432 void loadUrlPreviewSettings();
423433 } else {
@@ -476,6 +486,7 @@
476486 void loadEncryption();
477487 void loadQuickReactions();
478488 void loadKeywords();
489+ void loadPinnedThreads();
479490 void loadPackPreferences();
480491 void loadUrlPreviewSettings();
481492 }
@@ -1481,8 +1492,29 @@
14811492 return avatarByUser[userId] ?? null;
14821493 }
14831494
1495+ $: threadView = $openThread && $openThread.room_id === $selectedRoomId ? $openThread : null;
1496+ $: if ($openThread && $openThread.room_id !== $selectedRoomId) void showThread(null);
1497+ let threadFolds: Record<string, boolean> = {};
1498+
1499+ function openThreadHere(rootEventId: string): void {
1500+ if (!$selectedRoomId) return;
1501+ membersOpen = false;
1502+ void showThread({ room_id: $selectedRoomId, root_event_id: rootEventId });
1503+ }
1504+
1505+ async function openThreadIn(roomId: string, rootEventId: string): Promise<void> {
1506+ if ($selectedRoomId !== roomId) await selectRoom(roomId);
1507+ if ($selectedRoomId === roomId) openThreadHere(rootEventId);
1508+ }
1509+
1510+ function threadFoldOpen(roomId: string, folds: Record<string, boolean>, q: string): boolean {
1511+ if (q) return ($threadsByRoom[roomId] ?? []).some((t) => threadMatches(t, q));
1512+ return !!folds[roomId];
1513+ }
1514+
14841515 function toggleMembers(): void {
14851516 membersOpen = !membersOpen;
1517+ if (membersOpen) void showThread(null);
14861518 if (membersOpen && $selectedRoomId) {
14871519 void loadMembers($selectedRoomId);
14881520 void loadLevels($selectedRoomId);
@@ -1809,15 +1841,15 @@
18091841
18101842 let resizing = false;
18111843
1812- function startResize(ev: PointerEvent, pane: 'side' | 'members'): void {
1844+ function startResize(ev: PointerEvent, pane: 'side' | 'members' | 'thread'): void {
18131845 if (ev.button !== 0) return;
18141846 ev.preventDefault();
18151847 const handle = ev.currentTarget as HTMLElement;
18161848 handle.setPointerCapture(ev.pointerId);
1817- const store = pane === 'side' ? sideWidth : membersWidth;
1818- const range = pane === 'side' ? SIDE_WIDTH : MEMBERS_WIDTH;
1849+ const store = pane === 'side' ? sideWidth : pane === 'thread' ? threadWidth : membersWidth;
1850+ const range = pane === 'side' ? SIDE_WIDTH : pane === 'thread' ? THREAD_WIDTH : MEMBERS_WIDTH;
18191851 const startX = ev.clientX;
1820- const startWidth = pane === 'side' ? $sideWidth : $membersWidth;
1852+ const startWidth = pane === 'side' ? $sideWidth : pane === 'thread' ? $threadWidth : $membersWidth;
18211853 resizing = true;
18221854 const move = (e: PointerEvent) => {
18231855 const delta = pane === 'side' ? e.clientX - startX : startX - e.clientX;
@@ -1834,8 +1866,9 @@
18341866 handle.addEventListener('pointercancel', end);
18351867 }
18361868
1837- function resetWidth(pane: 'side' | 'members'): void {
1869+ function resetWidth(pane: 'side' | 'members' | 'thread'): void {
18381870 if (pane === 'side') sideWidth.set(SIDE_WIDTH.default);
1871+ else if (pane === 'thread') threadWidth.set(THREAD_WIDTH.default);
18391872 else membersWidth.set(MEMBERS_WIDTH.default);
18401873 }
18411874
@@ -1932,10 +1965,10 @@
19321965 in:fade={{ duration: motionDuration(220) }}
19331966 class:nav-folded={$navFolded}
19341967 class:rail-folded={$railFolded && selectedRoom}
1935- class:members-open={selectedRoom && membersOpen}
1968+ class:members-open={selectedRoom && (membersOpen || threadView)}
19361969 class:resizing
19371970 style:--side-open={`${$sideWidth}px`}
1938- style:--members-open={`${$membersWidth}px`}
1971+ style:--members-open={`${threadView ? $threadWidth : $membersWidth}px`}
19391972 style:--msg-gap={`${$messageSpacing}px`}
19401973 style:--msg-line-height={$lineHeight}
19411974 >
@@ -2279,8 +2312,32 @@
22792312 </span>
22802313 {/if}
22812314 </button>
2315+ {#if ($threadsByRoom[room.room_id] ?? []).length}
2316+ <button
2317+ type="button"
2318+ class="fold-btn"
2319+ class:open={threadFoldOpen(room.room_id, threadFolds, query)}
2320+ aria-expanded={threadFoldOpen(room.room_id, threadFolds, query)}
2321+ aria-label={`Threads in ${room.name}`}
2322+ title="Threads"
2323+ on:click={() => (threadFolds = { ...threadFolds, [room.room_id]: !threadFolds[room.room_id] })}
2324+ >
2325+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M3 2l4 3-4 3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
2326+ </button>
2327+ {/if}
22822328 <PinButton pinned={room.favourite} label={room.name} onToggle={() => void togglePin(room)} />
22832329 </li>
2330+ {#if threadFoldOpen(room.room_id, threadFolds, query)}
2331+ <li>
2332+ <RoomThreads
2333+ roomId={room.room_id}
2334+ threads={$threadsByRoom[room.room_id] ?? []}
2335+ {query}
2336+ activeRoot={threadView?.room_id === room.room_id ? threadView.root_event_id : null}
2337+ onOpen={(root) => void openThreadIn(room.room_id, root)}
2338+ />
2339+ </li>
2340+ {/if}
22842341 {/each}
22852342 </ul>
22862343 {/if}
@@ -2310,8 +2367,32 @@
23102367 </span>
23112368 {/if}
23122369 </button>
2370+ {#if ($threadsByRoom[room.room_id] ?? []).length}
2371+ <button
2372+ type="button"
2373+ class="fold-btn"
2374+ class:open={threadFoldOpen(room.room_id, threadFolds, query)}
2375+ aria-expanded={threadFoldOpen(room.room_id, threadFolds, query)}
2376+ aria-label={`Threads in ${room.name}`}
2377+ title="Threads"
2378+ on:click={() => (threadFolds = { ...threadFolds, [room.room_id]: !threadFolds[room.room_id] })}
2379+ >
2380+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M3 2l4 3-4 3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
2381+ </button>
2382+ {/if}
23132383 <PinButton pinned={room.favourite} label={room.name} onToggle={() => void togglePin(room)} />
23142384 </li>
2385+ {#if threadFoldOpen(room.room_id, threadFolds, query)}
2386+ <li>
2387+ <RoomThreads
2388+ roomId={room.room_id}
2389+ threads={$threadsByRoom[room.room_id] ?? []}
2390+ {query}
2391+ activeRoot={threadView?.room_id === room.room_id ? threadView.root_event_id : null}
2392+ onOpen={(root) => void openThreadIn(room.room_id, root)}
2393+ />
2394+ </li>
2395+ {/if}
23152396 {/each}
23162397 </ul>
23172398 {/if}
@@ -2573,6 +2654,7 @@
25732654 onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}
25742655 onCloneSticker={openCloneStickerMenu}
25752656 onMore={(ev, x, y) => void openMessageMenu(ev, x, y)}
2657+ onOpenThread={selectedRoom.membership === 'Joined' || g.item.event.thread_summary ? openThreadHere : null}
25762658 onReply={selectedRoom.membership === 'Joined' ? startReply : null}
25772659 onJump={jumpTo}
25782660 onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}
@@ -2813,53 +2895,73 @@
28132895 </main>
28142896
28152897 {#if selectedRoom}
2816- <aside class="members" aria-hidden={!membersOpen} aria-label={`Members of ${selectedRoom.name}`}>
2898+ <aside class="members" aria-hidden={!membersOpen && !threadView} aria-label={threadView ? `Thread in ${selectedRoom.name}` : `Members of ${selectedRoom.name}`}>
28172899 <!-- svelte-ignore a11y_no_static_element_interactions -->
2818- <div class="pane-resize left" title="Drag to resize" on:pointerdown={(e) => startResize(e, 'members')} on:dblclick={() => resetWidth('members')}></div>
2819- <div class="members-head">
2820- <h2>Members</h2>
2821- <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
2822- <span aria-hidden="true">✕</span>
2823- </button>
2824- </div>
2825- {#if $membersLoading && members.length === 0}
2826- <p class="side-note">Loading members…</p>
2827- {:else if members.length === 0}
2828- <p class="side-note">No members loaded.</p>
2829- {#if $selectedRoomId}
2830- <button type="button" class="ghost-btn" on:click={() => $selectedRoomId && void loadMembers($selectedRoomId)}>
2831- Retry
2900+ <div class="pane-resize left" title="Drag to resize" on:pointerdown={(e) => startResize(e, threadView ? 'thread' : 'members')} on:dblclick={() => resetWidth(threadView ? 'thread' : 'members')}></div>
2901+ {#if threadView}
2902+ <ThreadPane
2903+ roomId={threadView.room_id}
2904+ rootEventId={threadView.root_event_id}
2905+ roomName={selectedRoom.name}
2906+ joined={selectedRoom.membership === 'Joined'}
2907+ self={$sessionUser ?? null}
2908+ {people}
2909+ {senderAvatarFor}
2910+ quickReactions={usableQuickReactions}
2911+ packs={roomPacks}
2912+ linkPreviews={$linkPreviews}
2913+ {loadPreview}
2914+ onQuickChange={(list) => void changeQuickReactions(list)}
2915+ onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}
2916+ onMore={(ev, x, y) => void openMessageMenu(ev, x, y)}
2917+ onClose={() => void showThread(null)}
2918+ />
2919+ {:else}
2920+ <div class="members-head">
2921+ <h2>Members</h2>
2922+ <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
2923+ <span aria-hidden="true">✕</span>
28322924 </button>
2925+ </div>
2926+ {#if $membersLoading && members.length === 0}
2927+ <p class="side-note">Loading members…</p>
2928+ {:else if members.length === 0}
2929+ <p class="side-note">No members loaded.</p>
2930+ {#if $selectedRoomId}
2931+ <button type="button" class="ghost-btn" on:click={() => $selectedRoomId && void loadMembers($selectedRoomId)}>
2932+ Retry
2933+ </button>
2934+ {/if}
2935+ {:else}
2936+ <ul class="member-list">
2937+ {#each memberGroups as group (group.label)}
2938+ <li class="member-group">
2939+ <h3 class="member-group-label">{group.label}<span>{group.members.length}</span></h3>
2940+ <ul class="member-group-list">
2941+ {#each group.members as member (member.user_id)}
2942+ <li>
2943+ <button
2944+ type="button"
2945+ class="member"
2946+ on:click={(ev) => {
2947+ const rect = ev.currentTarget.getBoundingClientRect();
2948+ void openUser(member.user_id, rect.left - 308, rect.top, false);
2949+ }}
2950+ on:contextmenu|preventDefault={(ev) => void openUser(member.user_id, ev.clientX, ev.clientY, true)}
2951+ >
2952+ <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} seed={member.user_id} size={28} />
2953+ <span class="member-names">
2954+ <span class="member-name"><EmojiText text={member.display_name || member.name} /></span>
2955+ <span class="member-id">{member.user_id}</span>
2956+ </span>
2957+ </button>
2958+ </li>
2959+ {/each}
2960+ </ul>
2961+ </li>
2962+ {/each}
2963+ </ul>
28332964 {/if}
2834- {:else}
2835- <ul class="member-list">
2836- {#each memberGroups as group (group.label)}
2837- <li class="member-group">
2838- <h3 class="member-group-label">{group.label}<span>{group.members.length}</span></h3>
2839- <ul class="member-group-list">
2840- {#each group.members as member (member.user_id)}
2841- <li>
2842- <button
2843- type="button"
2844- class="member"
2845- on:click={(ev) => {
2846- const rect = ev.currentTarget.getBoundingClientRect();
2847- void openUser(member.user_id, rect.left - 308, rect.top, false);
2848- }}
2849- on:contextmenu|preventDefault={(ev) => void openUser(member.user_id, ev.clientX, ev.clientY, true)}
2850- >
2851- <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} seed={member.user_id} size={28} />
2852- <span class="member-names">
2853- <span class="member-name"><EmojiText text={member.display_name || member.name} /></span>
2854- <span class="member-id">{member.user_id}</span>
2855- </span>
2856- </button>
2857- </li>
2858- {/each}
2859- </ul>
2860- </li>
2861- {/each}
2862- </ul>
28632965 {/if}
28642966 </aside>
28652967 {/if}
Modified file: web/src/app.css+4 additions
@@ -367,6 +367,10 @@ input, textarea { font: inherit; }
367367 .room-row:hover { background: var(--panel-2); color: var(--text); }
368368 .room-item { display: flex; align-items: center; gap: 2px; }
369369 .room-item > .room-row { flex: 1; min-width: 0; }
370+.fold-btn { flex: none; display: grid; place-items: center; width: 20px; height: 24px; padding: 0; border: none; border-radius: var(--r-sm); background: transparent; color: var(--muted); cursor: pointer; transition: color var(--pop), background var(--pop); }
371+.fold-btn svg { transition: transform var(--pop); }
372+.fold-btn.open svg { transform: rotate(90deg); }
373+.fold-btn:hover { color: var(--text); background: var(--panel-2); }
370374 .rail-space.drop-before { box-shadow: 0 -6px 0 -3px var(--accent); }
371375 .rail-space.drop-after { box-shadow: 0 6px 0 -3px var(--accent); }
372376 .room-row.active { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); box-shadow: inset 2px 0 0 var(--accent); }
Modified file: web/src/lib/MessageRow.svelte+26 additions
@@ -36,8 +36,11 @@
3636 export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
3737 export let onCloneSticker: ((eventId: string, body: string, x: number, y: number) => void) | null = null;
3838 export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
39+ export let onOpenThread: ((rootEventId: string) => void) | null = null;
3940
4041 $: lag = lagLabel(event);
42+ $: threadRoot = onOpenThread && interactive && event.event_id && !event.thread_root ? event.event_id : null;
43+ $: threadReplies = event.thread_summary?.reply_count ?? 0;
4144
4245 function lagLabel(event: TimelineEvent): string | null {
4346 const lag = event.delivery_lag_ms;
@@ -327,6 +330,17 @@
327330 ↩
328331 </button>
329332 {/if}
333+ {#if threadRoot}
334+ {@const root = threadRoot}
335+ <button
336+ type="button"
337+ title="Reply in thread"
338+ aria-label={`Reply in thread to message from ${displayName}`}
339+ on:click|stopPropagation={() => onOpenThread?.(root)}
340+ >
341+ <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 3h11v7h-6l-3 3v-3h-2z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M5 6h6M5 8h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
342+ </button>
343+ {/if}
330344 {#if onEdit && editableBody !== null}
331345 <button type="button" title="Edit" aria-label="Edit your message" on:click|stopPropagation={edit}>
332346 ✎
@@ -471,6 +485,14 @@
471485 {/each}
472486 </div>
473487 {/if}
488+ {#if threadRoot && threadReplies > 0}
489+ {@const root = threadRoot}
490+ {@const latest = event.thread_summary?.latest_event_timestamp_ms ?? null}
491+ <button type="button" class="thread-chip" on:click|stopPropagation={() => onOpenThread?.(root)}>
492+ <span class="thread-count">{threadReplies} {threadReplies === 1 ? 'reply' : 'replies'}</span>
493+ {#if latest}<time datetime={new Date(latest).toISOString()}>{formatDay(latest)} {formatClock(latest)}</time>{/if}
494+ </button>
495+ {/if}
474496 {#if sent}<span class="sent-tick" title="Sent" aria-label="Sent">✓</span>{/if}
475497 {#if interactive && sendLabel(event.send_state)}
476498 <div class="msg-foot">
@@ -494,6 +516,10 @@
494516 .msg:hover, .msg.selected { background: var(--hover-faint); }
495517 .msg:hover .hover-time, .msg.selected .hover-time { opacity: 1; }
496518 .msg-foot { display: flex; align-items: center; gap: 8px; }
519+ .thread-chip { display: inline-flex; align-items: baseline; gap: 8px; margin-top: 4px; padding: 3px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg-soft); color: var(--text); font: inherit; font-size: 12px; cursor: pointer; transition: border-color var(--pop), background var(--pop); }
520+ .thread-chip:hover { border-color: var(--accent); background: var(--hover-faint); }
521+ .thread-count { color: var(--accent); font-weight: 650; }
522+ .thread-chip time { color: var(--muted); font-size: 11px; }
497523 .sendstate { font-size: 11px; color: var(--muted); }
498524 .sent-tick { position: absolute; right: 12px; bottom: 4px; font-size: 11px; color: var(--muted); }
499525 .sendstate.failed { color: var(--danger); }
Added file: web/src/lib/RoomThreads.svelte+61 additions
@@ -0,0 +1,61 @@
1+<script lang="ts">
2+ import { slide } from 'svelte/transition';
3+ import EmojiText from './EmojiText.svelte';
4+ import PinButton from './PinButton.svelte';
5+ import { formatClock, formatDay } from './format';
6+ import { threadMatches } from './thread-index';
7+ import type { ThreadEntry } from './thread-index';
8+ import { togglePinnedThread } from './threads';
9+
10+ export let roomId: string;
11+ export let threads: ThreadEntry[];
12+ export let query = '';
13+ export let activeRoot: string | null = null;
14+ export let onOpen: (rootEventId: string) => void;
15+
16+ const COLLAPSED_LIMIT = 6;
17+ let showAll = false;
18+
19+ $: matching = threads.filter((t) => threadMatches(t, query));
20+ $: shown = query || showAll ? matching : matching.slice(0, COLLAPSED_LIMIT);
21+ $: hidden = matching.length - shown.length;
22+
23+ function label(t: ThreadEntry): string {
24+ return t.preview || (t.pinned ? 'Pinned thread' : 'Thread');
25+ }
26+</script>
27+
28+<ul class="thread-fold" transition:slide={{ duration: 140 }} aria-label="Threads">
29+ {#each shown as t (t.root_event_id)}
30+ <li class="thread-item">
31+ <button
32+ type="button"
33+ class="thread-row"
34+ class:active={activeRoot === t.root_event_id}
35+ aria-current={activeRoot === t.root_event_id ? 'true' : undefined}
36+ on:click={() => onOpen(t.root_event_id)}
37+ >
38+ <span class="thread-preview"><EmojiText text={label(t)} /></span>
39+ {#if t.reply_count > 0}<span class="thread-meta">{t.reply_count}</span>{/if}
40+ {#if t.latest_ms}<time class="thread-meta" datetime={new Date(t.latest_ms).toISOString()}>{formatDay(t.latest_ms)} {formatClock(t.latest_ms)}</time>{/if}
41+ </button>
42+ <PinButton pinned={t.pinned} label={`thread ${label(t)}`} onToggle={() => void togglePinnedThread(roomId, t.root_event_id)} />
43+ </li>
44+ {/each}
45+ {#if hidden > 0}
46+ <li><button type="button" class="thread-more" on:click={() => (showAll = true)}>{hidden} more</button></li>
47+ {/if}
48+</ul>
49+
50+<style>
51+ .thread-fold { list-style: none; margin: 0 0 2px 22px; padding: 0 0 0 8px; border-left: 1px solid var(--border); display: flex; flex-direction: column; gap: 1px; }
52+ .thread-item { display: flex; align-items: center; gap: 2px; }
53+ .thread-row { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 6px; padding: 3px 6px; border: none; border-radius: var(--r-sm); background: transparent; color: var(--muted); font: inherit; font-size: 12.5px; text-align: left; cursor: pointer; transition: background var(--pop), color var(--pop); }
54+ .thread-row:hover { background: var(--panel-2); color: var(--text); }
55+ .thread-row.active { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text); }
56+ .thread-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
57+ .thread-preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
58+ .thread-meta { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
59+ .thread-item:hover :global(.pin-btn), .thread-item :global(.pin-btn.pinned) { opacity: 1; }
60+ .thread-more { border: none; background: transparent; color: var(--accent); font: inherit; font-size: 12px; padding: 2px 6px; cursor: pointer; }
61+</style>
Added file: web/src/lib/ThreadPane.svelte+269 additions
@@ -0,0 +1,269 @@
1+<script lang="ts">
2+ import { tick } from 'svelte';
3+ import MessageRow from './MessageRow.svelte';
4+ import EmojiText from './EmojiText.svelte';
5+ import { editMessage, markRead, paginateRoom, reactToMessage, sendMessage } from './api';
6+ import { pushBanner, threadKey, threadTimelines, timelines } from './store';
7+ import { pinnedThreads, togglePinnedThread } from './threads';
8+ import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, TimelineEvent, TimelineItem } from './types';
9+
10+ export let roomId: string;
11+ export let rootEventId: string;
12+ export let roomName: string;
13+ export let joined: boolean;
14+ export let self: string | null;
15+ export let people: Record<string, Person> = {};
16+ export let senderAvatarFor: (userId: string) => string | null = () => null;
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 onQuickChange: (list: QuickReaction[]) => void = () => {};
22+ export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
23+ export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
24+ export let onClose: () => void;
25+
26+ const GROUP_GAP_MS = 5 * 60 * 1000;
27+
28+ let list: HTMLDivElement | null = null;
29+ let composerEl: HTMLTextAreaElement | null = null;
30+ let draft = '';
31+ let replyingTo: ReplyReference | null = null;
32+ let editing: { eventId: string } | null = null;
33+ let sending = false;
34+ let paginating = false;
35+ let reacting = false;
36+ let stickToBottom = true;
37+ let lastRead = '';
38+
39+ $: key = threadKey(roomId, rootEventId);
40+ $: items = $threadTimelines[key] ?? [];
41+ $: roomRoot = ($timelines[roomId] ?? []).find((item) => item.kind === 'event' && item.event.event_id === rootEventId) ?? null;
42+ $: events = threadEvents(items, roomRoot);
43+ $: hasStart = items.some((item) => item.kind === 'start');
44+ $: pinned = ($pinnedThreads[roomId] ?? []).includes(rootEventId);
45+ $: if (key) reset();
46+ $: void follow(events);
47+
48+ function threadEvents(list: TimelineItem[], root: TimelineItem | null): TimelineEvent[] {
49+ const out = list.flatMap((item) => (item.kind === 'event' ? [item.event] : []));
50+ if (root?.kind === 'event' && !out.some((event) => event.event_id === rootEventId)) out.unshift(root.event);
51+ return out;
52+ }
53+
54+ function showSender(index: number): boolean {
55+ if (index === 0) return true;
56+ const prev = events[index - 1];
57+ const cur = events[index];
58+ return prev.sender.user_id !== cur.sender.user_id || Math.abs(cur.timestamp_ms - prev.timestamp_ms) > GROUP_GAP_MS;
59+ }
60+
61+ function reset(): void {
62+ draft = '';
63+ replyingTo = null;
64+ editing = null;
65+ stickToBottom = true;
66+ lastRead = '';
67+ }
68+
69+ async function follow(_events: TimelineEvent[]): Promise<void> {
70+ await tick();
71+ if (list && stickToBottom) list.scrollTop = list.scrollHeight;
72+ sendReceipt();
73+ }
74+
75+ function sendReceipt(): void {
76+ if (document.hidden || !document.hasFocus() || !stickToBottom) return;
77+ const latest = [...events].reverse().find((event) => event.event_id);
78+ if (!latest?.event_id || latest.event_id === lastRead) return;
79+ lastRead = latest.event_id;
80+ markRead(roomId, latest.event_id, rootEventId).catch(() => undefined);
81+ }
82+
83+ function onScroll(): void {
84+ if (!list) return;
85+ const wasAtBottom = stickToBottom;
86+ stickToBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 2;
87+ if (stickToBottom && !wasAtBottom) sendReceipt();
88+ if (list.scrollTop < 60 && !hasStart) void paginate();
89+ }
90+
91+ async function paginate(): Promise<void> {
92+ if (paginating) return;
93+ paginating = true;
94+ try {
95+ await paginateRoom(roomId, rootEventId);
96+ } catch (e) {
97+ pushBanner(e instanceof Error ? e.message : 'Loading older replies failed');
98+ } finally {
99+ paginating = false;
100+ }
101+ }
102+
103+ async function react(eventId: string, emoji: string, shortcode?: string): Promise<void> {
104+ if (reacting) return;
105+ reacting = true;
106+ try {
107+ await reactToMessage(roomId, eventId, emoji, shortcode, rootEventId);
108+ } catch (e) {
109+ pushBanner(e instanceof Error ? e.message : 'Reaction failed');
110+ } finally {
111+ reacting = false;
112+ }
113+ }
114+
115+ function startReply(target: ReplyReference): void {
116+ editing = null;
117+ replyingTo = target;
118+ composerEl?.focus();
119+ }
120+
121+ function startEdit(eventId: string, body: string): void {
122+ replyingTo = null;
123+ editing = { eventId };
124+ draft = body;
125+ void tick().then(() => composerEl?.focus());
126+ }
127+
128+ async function submit(): Promise<void> {
129+ const body = draft.trim();
130+ if (!body || sending) return;
131+ sending = true;
132+ const reply = replyingTo;
133+ const edit = editing;
134+ try {
135+ if (edit) await editMessage(roomId, edit.eventId, body, [], [], rootEventId);
136+ else await sendMessage(roomId, body, reply?.event_id ?? null, [], 'text', [], rootEventId);
137+ draft = '';
138+ replyingTo = null;
139+ editing = null;
140+ stickToBottom = true;
141+ } catch (e) {
142+ pushBanner(e instanceof Error ? e.message : 'Sending to the thread failed');
143+ } finally {
144+ sending = false;
145+ }
146+ }
147+
148+ function onKey(e: KeyboardEvent): void {
149+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
150+ e.preventDefault();
151+ void submit();
152+ } else if (e.key === 'Escape' && (editing || replyingTo)) {
153+ e.preventDefault();
154+ if (editing) draft = '';
155+ editing = null;
156+ replyingTo = null;
157+ }
158+ }
159+
160+ function grow(node: HTMLTextAreaElement, _value: string) {
161+ const fit = () => {
162+ node.style.height = 'auto';
163+ node.style.height = `${Math.min(node.scrollHeight, 200)}px`;
164+ };
165+ fit();
166+ return { update: fit };
167+ }
168+</script>
169+
170+<svelte:window on:focus={sendReceipt} />
171+<svelte:document on:visibilitychange={sendReceipt} />
172+
173+<aside class="thread-pane" aria-label={`Thread in ${roomName}`}>
174+ <header class="thread-head">
175+ <h2>Thread</h2>
176+ <span class="thread-room"><EmojiText text={roomName} /></span>
177+ <span class="spacer"></span>
178+ <button
179+ type="button"
180+ class="icon-btn"
181+ class:active={pinned}
182+ aria-pressed={pinned}
183+ aria-label={pinned ? 'Unpin thread' : 'Pin thread'}
184+ title={pinned ? 'Unpin thread' : 'Pin thread'}
185+ on:click={() => void togglePinnedThread(roomId, rootEventId)}
186+ >
187+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M9 3h6l-1 6 4 4v2h-5v6l-1 1-1-1v-6H6v-2l4-4z" fill={pinned ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
188+ </button>
189+ <button type="button" class="icon-btn" aria-label="Close thread" on:click={onClose}>
190+ <span aria-hidden="true">✕</span>
191+ </button>
192+ </header>
193+ <div class="thread-list" bind:this={list} on:scroll={onScroll}>
194+ {#if !hasStart && events.length > 1}
195+ <button type="button" class="ghost-btn older" disabled={paginating} on:click={() => void paginate()}>
196+ {paginating ? 'Loading…' : 'Load older replies'}
197+ </button>
198+ {/if}
199+ {#if events.length === 0}
200+ <p class="side-note">Loading thread…</p>
201+ {/if}
202+ {#each events as event, i (event.item_id || event.event_id)}
203+ <div class:root={event.event_id === rootEventId}>
204+ <MessageRow
205+ itemId={`thread-${event.item_id || event.event_id}`}
206+ {event}
207+ showSender={showSender(i) || event.event_id === rootEventId}
208+ senderAvatar={senderAvatarFor(event.sender.user_id) ?? event.sender.avatar}
209+ {reacting}
210+ onReact={(eventId, emoji, shortcode) => void react(eventId, emoji, shortcode)}
211+ {quickReactions}
212+ {packs}
213+ {linkPreviews}
214+ {loadPreview}
215+ {onQuickChange}
216+ {people}
217+ {self}
218+ {onUser}
219+ {onMore}
220+ onReply={joined ? startReply : null}
221+ onEdit={joined ? startEdit : null}
222+ editing={editing?.eventId === event.event_id}
223+ showSent={false}
224+ />
225+ </div>
226+ {/each}
227+ </div>
228+ {#if joined}
229+ <form class="thread-composer" on:submit|preventDefault={() => void submit()}>
230+ {#if replyingTo || editing}
231+ <div class="thread-context">
232+ <span>{editing ? 'Editing' : 'Replying'}</span>
233+ <button type="button" class="icon-btn" aria-label="Cancel" on:click={() => { if (editing) draft = ''; editing = null; replyingTo = null; }}>
234+ <span aria-hidden="true">✕</span>
235+ </button>
236+ </div>
237+ {/if}
238+ <div class="thread-input">
239+ <textarea
240+ bind:this={composerEl}
241+ bind:value={draft}
242+ use:grow={draft}
243+ rows="1"
244+ placeholder="Reply in thread"
245+ aria-label="Reply in thread"
246+ on:keydown={onKey}
247+ ></textarea>
248+ <button type="submit" class="primary-btn" disabled={sending || !draft.trim()}>{editing ? 'Save' : 'Send'}</button>
249+ </div>
250+ </form>
251+ {/if}
252+</aside>
253+
254+<style>
255+ .thread-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; height: 100%; background: var(--bg-elev, var(--bg)); border-left: 1px solid var(--border); animation: pop-in var(--pop); }
256+ .thread-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
257+ .thread-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
258+ .thread-room { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
259+ .spacer { flex: 1; }
260+ .icon-btn.active { color: var(--accent); }
261+ .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 10px; }
262+ .older { display: block; margin: 0 auto 8px; }
263+ .root { padding-bottom: 6px; margin-bottom: 6px; border-bottom: 1px solid var(--border); }
264+ .thread-composer { border-top: 1px solid var(--border); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
265+ .thread-context { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: var(--muted); }
266+ .thread-input { display: flex; align-items: flex-end; gap: 8px; }
267+ .thread-input textarea { flex: 1; min-width: 0; resize: none; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); padding: 7px 9px; line-height: 1.4; }
268+ .thread-input textarea:focus { outline: none; border-color: var(--accent); }
269+</style>
Modified file: web/src/lib/api.ts+9 additions−8 deletions
@@ -46,13 +46,14 @@ export const sendMessage = (
4646 reply_to: string | null = null,
4747 mentions: Mention[] = [],
4848 kind: SendKind = 'text',
49- emotes: Emote[] = []
50-) => transport.sendMessage(room_id, body, formatBody(body, mentions, true, emotes), reply_to, mentionedUserIds(mentions), kind);
49+ emotes: Emote[] = [],
50+ thread_root: string | null = null
51+) => transport.sendMessage(room_id, body, formatBody(body, mentions, true, emotes), reply_to, mentionedUserIds(mentions), kind, thread_root);
5152 export const runCommand = (request: CommandRequest) => transport.command(request);
5253 export const uploadAvatar = (file: File) => transport.uploadAvatar(file);
5354 export const uploadRoomAvatar = (room_id: string, file: File) => transport.uploadRoomAvatar(room_id, file);
5455 export const uploadMedia = (file: File) => transport.uploadMedia(file);
55-export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);
56+export const paginateRoom = (room_id: string, thread_root: string | null = null) => transport.paginateRoom(room_id, thread_root);
5657 export const eventContext = (room_id: string, event_id: string, limit?: number): Promise<EventContext> =>
5758 transport.eventContext(room_id, event_id, limit);
5859 export const eventContextBefore = (room_id: string, event_id: string, from: string, limit?: number): Promise<EventContextPage> =>
@@ -82,11 +83,11 @@ export function createRoom(name: string, topic?: string): Promise<{ room_id: str
8283 }
8384
8485 export const leaveRoom = (room_id: string) => transport.leaveRoom(room_id);
85-export const reactToMessage = (room_id: string, event_id: string, key: string, shortcode?: string) =>
86- transport.reactToMessage(room_id, event_id, key, shortcode);
87-export const editMessage = (room_id: string, event_id: string, body: string, mentions: Mention[] = [], emotes: Emote[] = []) =>
88- transport.editMessage(room_id, event_id, body, formatBody(body, mentions, true, emotes), mentionedUserIds(mentions));
89-export const markRead = (room_id: string, event_id: string) => transport.markRead(room_id, event_id);
86+export const reactToMessage = (room_id: string, event_id: string, key: string, shortcode?: string, thread_root: string | null = null) =>
87+ transport.reactToMessage(room_id, event_id, key, shortcode, thread_root);
88+export const editMessage = (room_id: string, event_id: string, body: string, mentions: Mention[] = [], emotes: Emote[] = [], thread_root: string | null = null) =>
89+ transport.editMessage(room_id, event_id, body, formatBody(body, mentions, true, emotes), mentionedUserIds(mentions), thread_root);
90+export const markRead = (room_id: string, event_id: string, thread_root: string | null = null) => transport.markRead(room_id, event_id, thread_root);
9091
9192 export function getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {
9293 return transport.getMembers(room_id);
Modified file: web/src/lib/appearance.ts+2 additions
@@ -32,10 +32,12 @@ export const railFolded = persisted('malice.railFolded', false, isBool);
3232
3333 export const SIDE_WIDTH = { min: 200, max: 480, default: 260 };
3434 export const MEMBERS_WIDTH = { min: 200, max: 420, default: 240 };
35+export const THREAD_WIDTH = { min: 300, max: 720, default: 400 };
3536 const widthIn = (range: { min: number; max: number }) => (v: unknown): v is number =>
3637 typeof v === 'number' && Number.isFinite(v) && v >= range.min && v <= range.max;
3738 export const sideWidth = persisted('malice.sideWidth', SIDE_WIDTH.default, widthIn(SIDE_WIDTH));
3839 export const membersWidth = persisted('malice.membersWidth', MEMBERS_WIDTH.default, widthIn(MEMBERS_WIDTH));
40+export const threadWidth = persisted('malice.threadWidth', THREAD_WIDTH.default, widthIn(THREAD_WIDTH));
3941 export const messageSpacing = persisted('malice.messageSpacing', MESSAGE_SPACING_DEFAULT, isSpacing);
4042
4143 export const LINE_HEIGHT_MIN = 1.1;
Modified file: web/src/lib/store.ts+30 additions
@@ -47,6 +47,11 @@ export const rooms = writable<RoomSummary[]>([]);
4747 export const timelines = writable<Record<string, TimelineItem[]>>({});
4848 export const pinnedTimelines = writable<Record<string, TimelineItem[]>>({});
4949 export const pinnedTimelineVersions = writable<Record<string, number>>({});
50+export const threadTimelines = writable<Record<string, TimelineItem[]>>({});
51+
52+export function threadKey(roomId: string, rootEventId: string): string {
53+ return `${roomId}\u0000${rootEventId}`;
54+}
5055 export const recentHistory = writable<Record<string, RecentHistoryState>>({});
5156 export interface EventContextState {
5257 room_id: string;
@@ -85,6 +90,7 @@ export function resetClientState(): void {
8590 timelines.set({});
8691 pinnedTimelines.set({});
8792 pinnedTimelineVersions.set({});
93+ threadTimelines.set({});
8894 recentHistory.set({});
8995 eventContexts.set({});
9096 connection.set('connecting');
@@ -175,6 +181,14 @@ function handlePayload(text: string): void {
175181 return next;
176182 });
177183 }
184+ if (Array.isArray(data['threads'])) {
185+ const next: Record<string, TimelineItem[]> = {};
186+ for (const entry of data['threads']) {
187+ if (!isRecord(entry) || typeof entry['room_id'] !== 'string' || typeof entry['root_event_id'] !== 'string') continue;
188+ next[threadKey(entry['room_id'], entry['root_event_id'])] = parseTimelineList(entry['items']);
189+ }
190+ threadTimelines.set(next);
191+ }
178192 const rawPinned = data['pinned'];
179193 if (isRecord(rawPinned)) {
180194 const next: Record<string, TimelineItem[]> = {};
@@ -224,6 +238,22 @@ function handlePayload(text: string): void {
224238 }
225239 return;
226240 }
241+ if (kind === 'thread' || kind === 'thread_closed') {
242+ const roomId = data['room_id'];
243+ const root = data['root_event_id'];
244+ if (typeof roomId !== 'string' || typeof root !== 'string') return;
245+ const key = threadKey(roomId, root);
246+ if (kind === 'thread') {
247+ const items = parseTimelineList(data['items']);
248+ threadTimelines.update((all) => ({ ...all, [key]: items }));
249+ } else {
250+ threadTimelines.update((all) => {
251+ const { [key]: _closed, ...rest } = all;
252+ return rest;
253+ });
254+ }
255+ return;
256+ }
227257 if (kind === 'pinned_closed') {
228258 const roomId = data['room_id'];
229259 if (typeof roomId === 'string') {
Added file: web/src/lib/thread-index.ts+56 additions
@@ -0,0 +1,56 @@
1+import type { TimelineEvent, TimelineItem } from './types';
2+
3+export interface ThreadEntry {
4+ room_id: string;
5+ root_event_id: string;
6+ preview: string;
7+ sender: string;
8+ reply_count: number;
9+ latest_ms: number;
10+ pinned: boolean;
11+}
12+
13+function previewOf(event: TimelineEvent): string {
14+ const content = event.content;
15+ if (content.kind === 'Message') return content.message.media?.caption ?? content.message.body;
16+ if (content.kind === 'Sticker') return content.body || 'Sticker';
17+ if (content.kind === 'Poll') return content.question;
18+ return '';
19+}
20+
21+function entryFor(roomId: string, event: TimelineEvent, pinned: boolean): ThreadEntry | null {
22+ if (!event.event_id || event.thread_root) return null;
23+ return {
24+ room_id: roomId,
25+ root_event_id: event.event_id,
26+ preview: previewOf(event),
27+ sender: event.sender.display_name ?? event.sender.user_id,
28+ reply_count: event.thread_summary?.reply_count ?? 0,
29+ latest_ms: event.thread_summary?.latest_event_timestamp_ms ?? event.timestamp_ms,
30+ pinned
31+ };
32+}
33+
34+/** Pinned threads first, then by latest activity. Pins whose root isn't loaded still get a row. */
35+export function roomThreads(roomId: string, items: TimelineItem[], pinnedRoots: string[]): ThreadEntry[] {
36+ const pinned = new Set(pinnedRoots);
37+ const found = new Map<string, ThreadEntry>();
38+ for (const item of items) {
39+ if (item.kind !== 'event') continue;
40+ const id = item.event.event_id;
41+ if (!id || (!pinned.has(id) && (item.event.thread_summary?.reply_count ?? 0) === 0)) continue;
42+ const entry = entryFor(roomId, item.event, pinned.has(id));
43+ if (entry) found.set(id, entry);
44+ }
45+ for (const root of pinnedRoots) {
46+ if (!found.has(root)) {
47+ found.set(root, { room_id: roomId, root_event_id: root, preview: '', sender: '', reply_count: 0, latest_ms: 0, pinned: true });
48+ }
49+ }
50+ return [...found.values()].sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.latest_ms - a.latest_ms);
51+}
52+
53+export function threadMatches(entry: ThreadEntry, query: string): boolean {
54+ if (!query) return true;
55+ return entry.preview.toLowerCase().includes(query) || entry.sender.toLowerCase().includes(query);
56+}
Added file: web/src/lib/threads.ts+88 additions
@@ -0,0 +1,88 @@
1+import { derived, get, writable } from 'svelte/store';
2+import { getAccountData, runCommand, setAccountData } from './api';
3+import { pushBanner, sessionUser, timelines } from './store';
4+import { roomThreads } from './thread-index';
5+import type { ThreadEntry } from './thread-index';
6+
7+const PINNED_TYPE = 'dev.malice.pinned_threads';
8+
9+export interface OpenThread {
10+ room_id: string;
11+ root_event_id: string;
12+}
13+
14+export const openThread = writable<OpenThread | null>(null);
15+export const pinnedThreads = writable<Record<string, string[]>>({});
16+
17+export const threadsByRoom = derived([timelines, pinnedThreads], ([$timelines, $pinned]) => {
18+ const out: Record<string, ThreadEntry[]> = {};
19+ for (const roomId of new Set([...Object.keys($timelines), ...Object.keys($pinned)])) {
20+ const list = roomThreads(roomId, $timelines[roomId] ?? [], $pinned[roomId] ?? []);
21+ if (list.length) out[roomId] = list;
22+ }
23+ return out;
24+});
25+
26+function parsePinned(raw: unknown): Record<string, string[]> {
27+ if (typeof raw !== 'object' || raw === null) return {};
28+ const rooms = (raw as Record<string, unknown>)['rooms'];
29+ if (typeof rooms !== 'object' || rooms === null) return {};
30+ const out: Record<string, string[]> = {};
31+ for (const [roomId, list] of Object.entries(rooms)) {
32+ if (Array.isArray(list)) out[roomId] = list.filter((id): id is string => typeof id === 'string');
33+ }
34+ return out;
35+}
36+
37+export async function loadPinnedThreads(): Promise<void> {
38+ try {
39+ pinnedThreads.set(parsePinned(await getAccountData(PINNED_TYPE)));
40+ } catch {
41+ pinnedThreads.set({});
42+ }
43+}
44+
45+export async function togglePinnedThread(roomId: string, rootEventId: string): Promise<void> {
46+ const before = get(pinnedThreads);
47+ const list = before[roomId] ?? [];
48+ const nextList = list.includes(rootEventId) ? list.filter((id) => id !== rootEventId) : [...list, rootEventId];
49+ const next = { ...before, [roomId]: nextList };
50+ if (nextList.length === 0) delete next[roomId];
51+ pinnedThreads.set(next);
52+ try {
53+ await setAccountData(PINNED_TYPE, { rooms: next });
54+ } catch (e) {
55+ pinnedThreads.set(before);
56+ pushBanner(e instanceof Error ? e.message : 'Pinning the thread failed');
57+ }
58+}
59+
60+let opened: OpenThread | null = null;
61+
62+/** The room must already be open, since macltok builds thread timelines inside it. */
63+export async function showThread(target: OpenThread | null): Promise<void> {
64+ const previous = opened;
65+ if (previous && target && previous.room_id === target.room_id && previous.root_event_id === target.root_event_id) return;
66+ opened = target;
67+ openThread.set(target);
68+ if (previous) runCommand({ op: 'close_thread', ...previous }).catch(() => undefined);
69+ if (!target) return;
70+ try {
71+ await runCommand({ op: 'open_thread', ...target });
72+ } catch (e) {
73+ if (opened === target) {
74+ opened = null;
75+ openThread.set(null);
76+ }
77+ pushBanner(e instanceof Error ? e.message : 'Opening the thread failed');
78+ }
79+}
80+
81+let account: string | null | undefined;
82+sessionUser.subscribe((user) => {
83+ if (user === account) return;
84+ account = user;
85+ opened = null;
86+ openThread.set(null);
87+ pinnedThreads.set({});
88+});
Modified file: web/src/lib/transport-http.ts+11 additions−10 deletions
@@ -99,12 +99,13 @@ export class HttpTransport implements Transport {
9999 formatted_body: string | null,
100100 reply_to: string | null,
101101 mentions: string[],
102- kind: SendKind = 'text'
102+ kind: SendKind = 'text',
103+ thread_root: string | null = null
103104 ): Promise<void> {
104- return this.roomCommand(ROOM_COMMAND_PATHS.send, { room_id, body, formatted_body, reply_to, mentions, kind });
105+ return this.roomCommand(ROOM_COMMAND_PATHS.send, { room_id, body, formatted_body, reply_to, mentions, kind, thread_root });
105106 }
106- paginateRoom(room_id: string): Promise<void> {
107- return this.roomCommand(ROOM_COMMAND_PATHS.paginate, { room_id });
107+ paginateRoom(room_id: string, thread_root: string | null = null): Promise<void> {
108+ return this.roomCommand(ROOM_COMMAND_PATHS.paginate, { room_id, thread_root });
108109 }
109110 async eventContext(room_id: string, event_id: string, limit = 20): Promise<EventContext> {
110111 const res = await fetch('/api/rooms/context', {
@@ -169,14 +170,14 @@ export class HttpTransport implements Transport {
169170 leaveRoom(room_id: string): Promise<void> {
170171 return this.roomCommand(ROOM_COMMAND_PATHS.leave, { room_id });
171172 }
172- editMessage(room_id: string, event_id: string, body: string, formatted_body: string | null, mentions: string[]): Promise<void> {
173- return this.roomCommand(ROOM_COMMAND_PATHS.edit, { room_id, event_id, body, formatted_body, mentions });
173+ editMessage(room_id: string, event_id: string, body: string, formatted_body: string | null, mentions: string[], thread_root: string | null = null): Promise<void> {
174+ return this.roomCommand(ROOM_COMMAND_PATHS.edit, { room_id, event_id, body, formatted_body, mentions, thread_root });
174175 }
175- reactToMessage(room_id: string, event_id: string, key: string, shortcode?: string): Promise<void> {
176- return this.roomCommand(ROOM_COMMAND_PATHS.react, shortcode ? { room_id, event_id, key, shortcode } : { room_id, event_id, key });
176+ reactToMessage(room_id: string, event_id: string, key: string, shortcode?: string, thread_root: string | null = null): Promise<void> {
177+ return this.roomCommand(ROOM_COMMAND_PATHS.react, { room_id, event_id, key, shortcode: shortcode ?? null, thread_root });
177178 }
178- markRead(room_id: string, event_id: string): Promise<void> {
179- return this.roomCommand(ROOM_COMMAND_PATHS.read, { room_id, event_id });
179+ markRead(room_id: string, event_id: string, thread_root: string | null = null): Promise<void> {
180+ return this.roomCommand(ROOM_COMMAND_PATHS.read, { room_id, event_id, thread_root });
180181 }
181182
182183 async joinRoom(room: string): Promise<{ room_id: string }> {
Modified file: web/src/lib/transport-worker.ts+11 additions−10 deletions
@@ -225,12 +225,13 @@ export class WorkerTransport implements Transport {
225225 formatted_body: string | null,
226226 reply_to: string | null,
227227 mentions: string[],
228- kind: SendKind = 'text'
228+ kind: SendKind = 'text',
229+ thread_root: string | null = null
229230 ): Promise<void> {
230- return this.client.call('send', { room_id, body, formatted_body, reply_to, mentions, kind });
231+ return this.client.call('send', { room_id, body, formatted_body, reply_to, mentions, kind, thread_root });
231232 }
232- paginateRoom(room_id: string): Promise<void> {
233- return this.client.call('paginate', { room_id });
233+ paginateRoom(room_id: string, thread_root: string | null = null): Promise<void> {
234+ return this.client.call('paginate', { room_id, thread_root });
234235 }
235236 async mentionsPage(cursor: string | null = null, limit = 50): Promise<MentionsPage> {
236237 const parsed = parseMentionsPage(await this.client.call('mentions_page', { cursor, limit }));
@@ -284,14 +285,14 @@ export class WorkerTransport implements Transport {
284285 leaveRoom(room_id: string): Promise<void> {
285286 return this.client.call('leave', { room_id });
286287 }
287- editMessage(room_id: string, event_id: string, body: string, formatted_body: string | null, mentions: string[]): Promise<void> {
288- return this.client.call('edit', { room_id, event_id, body, formatted_body, mentions });
288+ editMessage(room_id: string, event_id: string, body: string, formatted_body: string | null, mentions: string[], thread_root: string | null = null): Promise<void> {
289+ return this.client.call('edit', { room_id, event_id, body, formatted_body, mentions, thread_root });
289290 }
290- reactToMessage(room_id: string, event_id: string, key: string, shortcode?: string): Promise<void> {
291- return this.client.call('react', shortcode ? { room_id, event_id, key, shortcode } : { room_id, event_id, key });
291+ reactToMessage(room_id: string, event_id: string, key: string, shortcode?: string, thread_root: string | null = null): Promise<void> {
292+ return this.client.call('react', { room_id, event_id, key, shortcode: shortcode ?? null, thread_root });
292293 }
293- markRead(room_id: string, event_id: string): Promise<void> {
294- return this.client.call('read', { room_id, event_id });
294+ markRead(room_id: string, event_id: string, thread_root: string | null = null): Promise<void> {
295+ return this.client.call('read', { room_id, event_id, thread_root });
295296 }
296297 getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {
297298 return this.client.call('members', { room_id });
Modified file: web/src/lib/transport.ts+8 additions−6 deletions
@@ -29,7 +29,8 @@ export type CommandRequest =
2929 | { op: 'set_avatar'; mxc: string | null }
3030 | { op: 'invite'; room_id: string; user_id: string }
3131 | { op: 'direct'; user_id: string }
32- | { op: 'redact'; room_id: string; event_id: string; reason: string | null }
32+ | { op: 'redact'; room_id: string; event_id: string; reason: string | null; thread_root?: string | null }
33+ | { op: 'open_thread' | 'close_thread'; room_id: string; root_event_id: string }
3334 | { op: 'set_favourite'; room_id: string; favourite: boolean }
3435 | { op: 'reorder_spaces'; order: string[] }
3536 | { op: 'set_room_display_name'; room_id: string; name: string | null }
@@ -111,9 +112,10 @@ export interface Transport {
111112 formatted_body: string | null,
112113 reply_to: string | null,
113114 mentions: string[],
114- kind?: SendKind
115+ kind?: SendKind,
116+ thread_root?: string | null
115117 ): Promise<void>;
116- paginateRoom(room_id: string): Promise<void>;
118+ paginateRoom(room_id: string, thread_root?: string | null): Promise<void>;
117119 eventContext(room_id: string, event_id: string, limit?: number): Promise<EventContext>;
118120 eventContextBefore(room_id: string, event_id: string, from: string, limit?: number): Promise<EventContextPage>;
119121 eventContextAfter(room_id: string, event_id: string, from: string, limit?: number): Promise<EventContextPage>;
@@ -132,9 +134,9 @@ export interface Transport {
132134 joinRoom(room: string): Promise<{ room_id: string }>;
133135 createRoom(name: string, topic?: string): Promise<{ room_id: string }>;
134136 leaveRoom(room_id: string): Promise<void>;
135- editMessage(room_id: string, event_id: string, body: string, formatted_body: string | null, mentions: string[]): Promise<void>;
136- reactToMessage(room_id: string, event_id: string, key: string, shortcode?: string): Promise<void>;
137- markRead(room_id: string, event_id: string): Promise<void>;
137+ editMessage(room_id: string, event_id: string, body: string, formatted_body: string | null, mentions: string[], thread_root?: string | null): Promise<void>;
138+ reactToMessage(room_id: string, event_id: string, key: string, shortcode?: string, thread_root?: string | null): Promise<void>;
139+ markRead(room_id: string, event_id: string, thread_root?: string | null): Promise<void>;
138140 getMembers(room_id: string): Promise<{ members: MemberSummary[] }>;
139141 getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }>;
140142 getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }>;
Added file: web/test/thread-index.test.ts+46 additions
@@ -0,0 +1,46 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { roomThreads, threadMatches } from '../src/lib/thread-index.ts';
4+import type { TimelineEvent, TimelineItem } from '../src/lib/types.ts';
5+
6+function root(id: string, body: string, replies: number, latest: number, extra: Partial<TimelineEvent> = {}): TimelineItem {
7+ return {
8+ kind: 'event',
9+ event: {
10+ item_id: id,
11+ event_id: id,
12+ timestamp_ms: 1,
13+ sender: { user_id: '@a:x', display_name: 'Alice', avatar: null, name_ambiguous: false },
14+ content: { kind: 'Message', message: { kind: 'Text', body, media: null } },
15+ thread_root: null,
16+ thread_summary: replies ? { reply_count: replies, latest_event_id: null, latest_event_timestamp_ms: latest } : null,
17+ ...extra
18+ } as TimelineEvent
19+ };
20+}
21+
22+test('threads sort by latest activity with pins first', () => {
23+ const items = [root('$a', 'old thread', 2, 100), root('$b', 'new thread', 1, 300), root('$c', 'no replies', 0, 0), root('$d', 'pinned', 4, 50)];
24+ const list = roomThreads('!r', items, ['$d']);
25+ assert.deepEqual(list.map((t) => t.root_event_id), ['$d', '$b', '$a']);
26+ assert.equal(list[0].pinned, true);
27+});
28+
29+test('pinned threads outside the loaded timeline still get a row', () => {
30+ const list = roomThreads('!r', [], ['$gone']);
31+ assert.equal(list.length, 1);
32+ assert.equal(list[0].root_event_id, '$gone');
33+ assert.equal(list[0].preview, '');
34+});
35+
36+test('replies inside a thread are not treated as roots', () => {
37+ const reply = root('$r', 'reply', 3, 10, { thread_root: '$a' });
38+ assert.equal(roomThreads('!r', [reply], []).length, 0);
39+});
40+
41+test('search matches preview text and sender', () => {
42+ const [entry] = roomThreads('!r', [root('$a', 'Deploy plan for Friday', 2, 10)], []);
43+ assert.ok(threadMatches(entry, 'deploy'));
44+ assert.ok(threadMatches(entry, 'alice'));
45+ assert.equal(threadMatches(entry, 'lunch'), false);
46+});