Commit 7ebc2472a9e3
tarball17 files changed+923-106
@@ -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 = state133
+ .timelines134
+ .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
}],@@ -23,6 +23,17 @@ fn default_avatar_size() -> u32 {
2323
AVATAR_SIZE2424
}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
- handle533
- .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
+ handle604
+ .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
handle711
- .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
- handle738
- .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
handle746
- .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
handle9721012
.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
+ handle1025
+ .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
+ handle1034
+ .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
handle9821040
.set_favourite(parse_room_id(&room_id)?, favourite)@@ -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,@@ -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
- sideWidth114
+ sideWidth,115
+ threadWidth110116
} 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 = query225232
? 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:resizing19371970
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
+ <button2317
+ 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
+ <RoomThreads2333
+ 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
+ <button2372
+ 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
+ <RoomThreads2388
+ 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
- Retry2900
+ <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
+ <ThreadPane2903
+ 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
+ Retry2933
+ </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
+ <button2944
+ 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
- <button2843
- 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}@@ -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); }@@ -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
+ <button336
+ 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); }@@ -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
+ <button32
+ 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>@@ -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
+ <button179
+ 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
+ <MessageRow205
+ 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
+ <textarea240
+ 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>@@ -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 = null51
+) => 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);@@ -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;@@ -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') {@@ -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
+ pinned31
+ };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
+}@@ -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
+});@@ -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 = null103104
): 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 }> {@@ -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 = null229230
): 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 });@@ -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?: SendKind115
+ kind?: SendKind,116
+ thread_root?: string | null115117
): 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[] }>;@@ -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
+ ...extra18
+ } as TimelineEvent19
+ };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
+});