atagen

malice

public
Star0
Watch0

Commit 7cca1d5628d1

tarball

atagen <boss@atagen.co>2026-10-24 07:44 UTC

21 files changed+495-54
web: pin rooms, reorder the space rail and finish the command set

Pins are the m.favourite tag and rail order is MSC3230 space_order, so both round-trip with Element. A dragged order is shown from a local override until the next room summary carries matching orders, which avoids the rail snapping back while reorder_spaces is in flight. Mute and unmute leave the power-level policy to macltok mute_user, and its Forbidden error now reaches the UI as a 403 instead of a generic upstream failure.
Modified file: crates/malice-core/src/error.rs+2 additions
@@ -4,6 +4,7 @@ use serde::Serialize;
44 pub enum Failure {
55 BadRequest,
66 Unauthorized,
7+ Forbidden,
78 NotFound,
89 Conflict,
910 Busy,
@@ -55,6 +56,7 @@ impl From<macltok::Error> for CoreError {
5556 Failure::NotFound
5657 }
5758 ErrorKind::MediaTooLarge => Failure::TooLarge,
59+ ErrorKind::Forbidden => Failure::Forbidden,
5860 ErrorKind::Invalid | ErrorKind::EmptyBody => Failure::BadRequest,
5961 ErrorKind::RoomNotOpen
6062 | ErrorKind::TimelineNotOpen
Modified file: crates/malice-core/src/request.rs+116 additions−11 deletions
@@ -44,7 +44,7 @@ pub enum Request {
4444 #[serde(default)]
4545 mentions: Vec<String>,
4646 #[serde(default)]
47- emote: bool,
47+ kind: SendKind,
4848 },
4949 Edit {
5050 #[serde(default)]
@@ -194,6 +194,48 @@ pub enum Request {
194194 #[serde(default)]
195195 reason: Option<String>,
196196 },
197+ SetFavourite {
198+ room_id: String,
199+ favourite: bool,
200+ },
201+ ReorderSpaces {
202+ order: Vec<String>,
203+ },
204+ SetRoomDisplayName {
205+ room_id: String,
206+ #[serde(default)]
207+ name: Option<String>,
208+ },
209+ SetRoomAvatar {
210+ room_id: String,
211+ #[serde(default)]
212+ mxc: Option<String>,
213+ },
214+ UploadRoomAvatar {
215+ room_id: String,
216+ #[serde(skip)]
217+ bytes: Vec<u8>,
218+ },
219+ Mute {
220+ room_id: String,
221+ user_id: String,
222+ muted: bool,
223+ },
224+ Ignore {
225+ user_id: String,
226+ },
227+ Unignore {
228+ user_id: String,
229+ },
230+}
231+
232+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
233+#[serde(rename_all = "snake_case")]
234+pub enum SendKind {
235+ #[default]
236+ Text,
237+ Emote,
238+ Notice,
197239 }
198240
199241 pub enum Reply {
@@ -240,7 +282,7 @@ pub async fn dispatch(
240282 formatted_body,
241283 reply_to,
242284 mentions,
243- emote,
285+ kind,
244286 } => {
245287 let room_id = parse_room_id(&room_id)?;
246288 if body.trim().is_empty() {
@@ -255,10 +297,11 @@ pub async fn dispatch(
255297 };
256298 let mentions = parse_mentions(&mentions)?;
257299 let content = match reply_to {
258- _ if emote => MessageContent::Emote {
300+ _ if kind == SendKind::Emote => MessageContent::Emote {
259301 content: text,
260302 mentions,
261303 },
304+ _ if kind == SendKind::Notice => MessageContent::notice(text, mentions),
262305 Some(event_id) => MessageContent::reply(parse_event_id(&event_id)?, text, mentions),
263306 None => MessageContent::Text {
264307 content: text,
@@ -595,14 +638,7 @@ pub async fn dispatch(
595638 Ok(Reply::Done)
596639 }
597640 Request::UploadAvatar { bytes } => {
598- if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 {
599- return Err(CoreError::bad_request(
600- "avatar must be between 1 byte and 10 MiB",
601- ));
602- }
603- let Some(content_type) = image_content_type(&bytes) else {
604- return Err(CoreError::unsupported("avatar is not a supported image"));
605- };
641+ let content_type = avatar_content_type(&bytes)?;
606642 handle.upload_avatar(content_type, bytes).await?;
607643 Ok(Reply::Done)
608644 }
@@ -630,7 +666,76 @@ pub async fn dispatch(
630666 .await?;
631667 Ok(Reply::Done)
632668 }
669+ Request::SetFavourite { room_id, favourite } => {
670+ handle
671+ .set_favourite(parse_room_id(&room_id)?, favourite)
672+ .await?;
673+ Ok(Reply::Done)
674+ }
675+ Request::ReorderSpaces { order } => {
676+ if order.len() > 1000 {
677+ return Err(CoreError::bad_request("too many spaces"));
678+ }
679+ let order = order
680+ .iter()
681+ .map(|id| parse_room_id(id))
682+ .collect::<Result<Vec<_>, _>>()?;
683+ handle.reorder_spaces(order).await?;
684+ Ok(Reply::Done)
685+ }
686+ Request::SetRoomDisplayName { room_id, name } => {
687+ let name = name.map(|n| n.trim().to_owned()).filter(|n| !n.is_empty());
688+ if name.as_ref().is_some_and(|n| n.len() > 256) {
689+ return Err(CoreError::bad_request("display name is too long"));
690+ }
691+ handle
692+ .set_room_display_name(parse_room_id(&room_id)?, name)
693+ .await?;
694+ Ok(Reply::Done)
695+ }
696+ Request::SetRoomAvatar { room_id, mxc } => {
697+ let mxc = mxc.as_deref().map(parse_mxc).transpose()?;
698+ handle
699+ .set_room_avatar(parse_room_id(&room_id)?, mxc)
700+ .await?;
701+ Ok(Reply::Done)
702+ }
703+ Request::UploadRoomAvatar { room_id, bytes } => {
704+ let room_id = parse_room_id(&room_id)?;
705+ let content_type = avatar_content_type(&bytes)?;
706+ let mxc = handle.upload_media(content_type, bytes).await?;
707+ handle.set_room_avatar(room_id, Some(mxc)).await?;
708+ Ok(Reply::Done)
709+ }
710+ Request::Mute {
711+ room_id,
712+ user_id,
713+ muted,
714+ } => {
715+ handle
716+ .mute_user(parse_room_id(&room_id)?, parse_user_id(&user_id)?, muted)
717+ .await?;
718+ Ok(Reply::Done)
719+ }
720+ Request::Ignore { user_id } => {
721+ handle.ignore_user(parse_user_id(&user_id)?).await?;
722+ Ok(Reply::Done)
723+ }
724+ Request::Unignore { user_id } => {
725+ handle.unignore_user(parse_user_id(&user_id)?).await?;
726+ Ok(Reply::Done)
727+ }
728+ }
729+}
730+
731+fn avatar_content_type(bytes: &[u8]) -> Result<&'static str, CoreError> {
732+ if bytes.is_empty() || bytes.len() > 10 * 1024 * 1024 {
733+ return Err(CoreError::bad_request(
734+ "avatar must be between 1 byte and 10 MiB",
735+ ));
633736 }
737+ image_content_type(bytes)
738+ .ok_or_else(|| CoreError::unsupported("avatar is not a supported image"))
634739 }
635740
636741 // The spec's power level range is a canonical-JSON integer, which tops out at 2^53 - 1.
Modified file: crates/malice-server/src/error.rs+1 additions
@@ -59,6 +59,7 @@ impl From<CoreError> for ApiError {
5959 let status = match error.kind {
6060 Failure::BadRequest => StatusCode::BAD_REQUEST,
6161 Failure::Unauthorized => StatusCode::UNAUTHORIZED,
62+ Failure::Forbidden => StatusCode::FORBIDDEN,
6263 Failure::NotFound => StatusCode::NOT_FOUND,
6364 Failure::Conflict => StatusCode::CONFLICT,
6465 Failure::Busy => StatusCode::TOO_MANY_REQUESTS,
Modified file: crates/malice-server/src/routes.rs+42 additions−3 deletions
@@ -13,7 +13,7 @@ use macltok::Retain;
1313 use malice_core::{
1414 feed::subscribe,
1515 login::LoginInput,
16- request::{Reply, Request, dispatch},
16+ request::{Reply, Request, SendKind, dispatch},
1717 };
1818 use serde::{Deserialize, Serialize};
1919 use tower_http::{
@@ -46,7 +46,7 @@ struct SendBody {
4646 #[serde(default)]
4747 mentions: Vec<String>,
4848 #[serde(default)]
49- emote: bool,
49+ kind: SendKind,
5050 }
5151
5252 #[derive(Debug, Deserialize)]
@@ -417,7 +417,7 @@ async fn rooms_send(
417417 formatted_body: body.0.formatted_body,
418418 reply_to: body.0.reply_to,
419419 mentions: body.0.mentions,
420- emote: body.0.emote,
420+ kind: body.0.kind,
421421 },
422422 )
423423 .await,
@@ -541,6 +541,13 @@ async fn post_command(
541541 | Request::Invite { .. }
542542 | Request::Direct { .. }
543543 | Request::Redact { .. }
544+ | Request::SetFavourite { .. }
545+ | Request::ReorderSpaces { .. }
546+ | Request::SetRoomDisplayName { .. }
547+ | Request::SetRoomAvatar { .. }
548+ | Request::Mute { .. }
549+ | Request::Ignore { .. }
550+ | Request::Unignore { .. }
544551 ) {
545552 return ApiError::bad_request("op is not available as a command").into_response();
546553 }
@@ -568,6 +575,34 @@ async fn profile_avatar(
568575 )
569576 }
570577
578+#[derive(Debug, Deserialize)]
579+struct RoomAvatarQuery {
580+ room_id: String,
581+}
582+
583+async fn rooms_avatar(
584+ State(state): State<AppState>,
585+ headers: HeaderMap,
586+ Query(query): Query<RoomAvatarQuery>,
587+ bytes: axum::body::Bytes,
588+) -> Response {
589+ let (user, _) = match auth(&state, &headers).await {
590+ Ok(pair) => pair,
591+ Err(error) => return error.into_response(),
592+ };
593+ respond(
594+ dispatch(
595+ &user.handle,
596+ &user.mirror,
597+ Request::UploadRoomAvatar {
598+ room_id: query.room_id,
599+ bytes: bytes.to_vec(),
600+ },
601+ )
602+ .await,
603+ )
604+}
605+
571606 async fn rooms_paginate(
572607 State(state): State<AppState>,
573608 headers: HeaderMap,
@@ -997,6 +1032,10 @@ pub fn router(state: AppState, web_dir: Option<String>) -> Router {
9971032 post(profile_avatar).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
9981033 )
9991034 .route("/command", post(post_command))
1035+ .route(
1036+ "/rooms/avatar",
1037+ post(rooms_avatar).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
1038+ )
10001039 .route("/media", get(get_media))
10011040 .route("/message-media", get(get_message_media))
10021041 .route("/spaces/children", get(get_space_children))
Modified file: crates/malice-worker/src/lib.rs+3 additions−2 deletions
@@ -170,8 +170,9 @@ impl Host {
170170 };
171171 let mut request: Request = serde_json::from_str(&request_json)
172172 .map_err(|error| error_value("bad_request", error.to_string()))?;
173- if let Request::Upload { bytes: slot, .. } | Request::UploadAvatar { bytes: slot } =
174- &mut request
173+ if let Request::Upload { bytes: slot, .. }
174+ | Request::UploadAvatar { bytes: slot }
175+ | Request::UploadRoomAvatar { bytes: slot, .. } = &mut request
175176 {
176177 *slot = bytes.map(|array| array.to_vec()).unwrap_or_default();
177178 }
Modified file: crates/malice-worker/src/wire.rs+1 additions
@@ -15,6 +15,7 @@ pub fn failure_kind(failure: Failure) -> &'static str {
1515 match failure {
1616 Failure::BadRequest => "bad_request",
1717 Failure::Unauthorized => "unauthorized",
18+ Failure::Forbidden => "forbidden",
1819 Failure::NotFound => "not_found",
1920 Failure::Conflict => "conflict",
2021 Failure::Busy => "busy",
Modified file: web/src/App.svelte+135 additions−24 deletions
@@ -3,6 +3,8 @@
33 import LoginPanel from './lib/LoginPanel.svelte';
44 import MessageRow from './lib/MessageRow.svelte';
55 import MxcAvatar from './lib/MxcAvatar.svelte';
6+ import PinButton from './lib/PinButton.svelte';
7+ import { moveBefore, pinnedFirst, sortSpaces } from './lib/rail-order';
68 import ImageLightbox from './lib/ImageLightbox.svelte';
79 import { lightbox } from './lib/lightbox';
810 import SecurityPanel from './lib/SecurityPanel.svelte';
@@ -26,6 +28,7 @@
2628 sendMessage,
2729 uploadAttachment,
2830 uploadAvatar,
31+ uploadRoomAvatar,
2932 editMessage
3033 } from './lib/api';
3134 import { isKnownCommand, parseCommand, splitTarget, unescapeCommand } from './lib/commands';
@@ -93,6 +96,7 @@
9396 let composerEl: HTMLTextAreaElement | null = null;
9497 let fileInput: HTMLInputElement | null = null;
9598 let avatarInput: HTMLInputElement | null = null;
99+ let avatarRoom: string | null = null;
96100 let uploadBusy = false;
97101 let uploadName: string | null = null;
98102 interface StagedAttachment {
@@ -139,6 +143,11 @@
139143 $: spaceRooms = $rooms.filter((r) => r.space);
140144 $: spaceParents = parentSpaceMap(spaceRooms);
141145 $: railSpaces = spaceRooms.filter((s) => !spaceParents.has(s.room_id));
146+ let railOverride: string[] | null = null;
147+ $: if (railOverride && sortSpaces(railSpaces, null).map((s) => s.room_id).join() === railOverride.join()) railOverride = null;
148+ $: orderedRail = sortSpaces(railSpaces, railOverride);
149+ $: pinnedRail = orderedRail.filter((s) => s.favourite);
150+ $: otherRail = orderedRail.filter((s) => !s.favourite);
142151 $: railSelected = selectedSpaceId ? topSpaceOf(selectedSpaceId, spaceParents) : null;
143152 $: invites = $rooms.filter((r) => r.membership === 'Invited');
144153 $: query = search.trim().toLowerCase();
@@ -152,11 +161,9 @@
152161 r.name.toLowerCase().includes(query) || r.room_id.toLowerCase().includes(query)
153162 )
154163 : baseRooms;
155- $: directRooms = visibleRooms.filter((r) => r.direct && !r.space && r.membership !== 'Invited');
156- $: groupRooms = visibleRooms.filter(
157- (r) => !r.direct && !r.space && r.membership !== 'Invited'
158- );
159- $: spaceList = visibleRooms.filter((r) => r.space && r.membership !== 'Invited');
164+ $: directRooms = pinnedFirst(visibleRooms.filter((r) => r.direct && !r.space && r.membership !== 'Invited'));
165+ $: groupRooms = pinnedFirst(visibleRooms.filter((r) => !r.direct && !r.space && r.membership !== 'Invited'));
166+ $: spaceList = pinnedFirst(visibleRooms.filter((r) => r.space && r.membership !== 'Invited'));
160167 $: channelRooms = baseRooms.filter((r) => !r.space && r.membership !== 'Invited');
161168 $: recoveryBanner = $encryption && ($encryption.recovery === 'Disabled' || $encryption.recovery === 'Incomplete') ? $encryption : null;
162169 $: verificationBanner = $verification && $verification.kind !== 'Done' ? $verification : null;
@@ -646,7 +653,8 @@
646653 try {
647654 switch (name) {
648655 case 'me':
649- await sendMessage(roomId, required('something to say'), null, mentions, $markdownEnabled, true);
656+ case 'notice':
657+ await sendMessage(roomId, required('something to say'), null, mentions, $markdownEnabled, name === 'me' ? 'emote' : 'notice');
650658 break;
651659 case 'msg': {
652660 const { user_id, rest } = target();
@@ -681,6 +689,27 @@
681689 await runCommand({ op: 'set_power_level', room_id: roomId, user_id, level });
682690 break;
683691 }
692+ case 'mute':
693+ case 'unmute':
694+ await runCommand({ op: 'mute', room_id: roomId, user_id: target().user_id, muted: name === 'mute' });
695+ break;
696+ case 'ignore':
697+ case 'unignore':
698+ await runCommand({ op: name, user_id: target().user_id });
699+ break;
700+ case 'roomnick':
701+ await runCommand({ op: 'set_room_display_name', room_id: roomId, name: args || null });
702+ break;
703+ case 'roomavatar':
704+ if (!args) {
705+ avatarRoom = roomId;
706+ avatarInput?.click();
707+ } else if (args.startsWith('mxc://')) {
708+ await runCommand({ op: 'set_room_avatar', room_id: roomId, mxc: args });
709+ } else {
710+ throw new Error('/roomavatar takes an mxc:// link, or nothing to pick a file');
711+ }
712+ break;
684713 case 'delete': {
685714 const eventId = reply?.event_id ?? lastOwnEventId();
686715 if (!eventId) throw new Error('Nothing of yours to delete here');
@@ -693,6 +722,7 @@
693722 break;
694723 case 'globalavatar':
695724 if (!args) {
725+ avatarRoom = null;
696726 avatarInput?.click();
697727 } else if (args.startsWith('mxc://')) {
698728 await runCommand({ op: 'set_avatar', mxc: args });
@@ -712,8 +742,12 @@
712742 if (avatarInput) avatarInput.value = '';
713743 if (!file) return;
714744 try {
715- await uploadAvatar(file);
716- await loadProfile();
745+ if (avatarRoom) {
746+ await uploadRoomAvatar(avatarRoom, file);
747+ } else {
748+ await uploadAvatar(file);
749+ await loadProfile();
750+ }
717751 } catch (e) {
718752 pushBanner(e instanceof Error ? e.message : 'Avatar upload failed');
719753 }
@@ -1088,6 +1122,55 @@
10881122 return 'dot-off';
10891123 }
10901124
1125+ let dragSpaceId: string | null = null;
1126+ let dropTarget: { id: string; after: boolean } | null = null;
1127+ let railMenu: { room: RoomSummary; x: number; y: number } | null = null;
1128+ let railMenuEl: HTMLDivElement | null = null;
1129+
1130+ async function togglePin(room: RoomSummary): Promise<void> {
1131+ railMenu = null;
1132+ try {
1133+ await runCommand({ op: 'set_favourite', room_id: room.room_id, favourite: !room.favourite });
1134+ } catch (e) {
1135+ pushBanner(e instanceof Error ? e.message : 'Could not change the pin');
1136+ }
1137+ }
1138+
1139+ function startRailDrag(ev: DragEvent, id: string): void {
1140+ dragSpaceId = id;
1141+ if (ev.dataTransfer) {
1142+ ev.dataTransfer.effectAllowed = 'move';
1143+ ev.dataTransfer.setData('text/plain', id);
1144+ }
1145+ }
1146+
1147+ function overRailSpace(ev: DragEvent, space: RoomSummary): void {
1148+ const dragged = orderedRail.find((s) => s.room_id === dragSpaceId);
1149+ if (!dragged || dragged.room_id === space.room_id || dragged.favourite !== space.favourite) return;
1150+ ev.preventDefault();
1151+ const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
1152+ dropTarget = { id: space.room_id, after: ev.clientY > rect.top + rect.height / 2 };
1153+ }
1154+
1155+ async function dropRailSpace(): Promise<void> {
1156+ const moved = dragSpaceId;
1157+ const target = dropTarget;
1158+ dragSpaceId = null;
1159+ dropTarget = null;
1160+ if (!moved || !target) return;
1161+ const ids = orderedRail.map((s) => s.room_id);
1162+ const before = target.after ? (ids[ids.indexOf(target.id) + 1] ?? null) : target.id;
1163+ const order = moveBefore(ids, moved, before === moved ? null : before);
1164+ if (order.join() === ids.join()) return;
1165+ railOverride = order;
1166+ try {
1167+ await runCommand({ op: 'reorder_spaces', order });
1168+ } catch (e) {
1169+ railOverride = null;
1170+ pushBanner(e instanceof Error ? e.message : 'Could not reorder spaces');
1171+ }
1172+ }
1173+
10911174 let roomMenuOpen = false;
10921175 let roomMenuEl: HTMLDivElement | null = null;
10931176
@@ -1098,11 +1181,13 @@
10981181
10991182 function closeRoomMenuOutside(ev: PointerEvent): void {
11001183 if (roomMenuOpen && !roomMenuEl?.contains(ev.target as Node)) roomMenuOpen = false;
1184+ if (railMenu && !railMenuEl?.contains(ev.target as Node)) railMenu = null;
11011185 }
11021186
11031187 function onKeydown(ev: KeyboardEvent): void {
11041188 if (ev.key === 'Escape') {
11051189 if (roomMenuOpen) roomMenuOpen = false;
1190+ else if (railMenu) railMenu = null;
11061191 else if (dialog) dialog = null;
11071192 else if (membersOpen && window.innerWidth < 1100) membersOpen = false;
11081193 else if (sidebarOpen) sidebarOpen = false;
@@ -1149,19 +1234,33 @@
11491234 >
11501235 <span aria-hidden="true">⌂</span>
11511236 </button>
1152- <div class="rail-sep" aria-hidden="true"></div>
1153- {#each railSpaces as space (space.room_id)}
1154- <button
1155- type="button"
1156- class="rail-btn rail-space"
1157- class:active={railSelected === space.room_id}
1158- title={space.name}
1159- aria-label={`Show channels in space ${space.name}`}
1160- aria-pressed={selectedSpaceId === space.room_id}
1161- on:click={() => void selectSpace(space.room_id)}
1162- >
1163- <MxcAvatar mxc={space.avatar} label={space.name} size={44} shape="rounded" />
1164- </button>
1237+ {#each [pinnedRail, otherRail] as group}
1238+ {#if group.length > 0}
1239+ <div class="rail-sep" aria-hidden="true"></div>
1240+ {/if}
1241+ {#each group as space (space.room_id)}
1242+ <button
1243+ type="button"
1244+ class="rail-btn rail-space"
1245+ class:active={railSelected === space.room_id}
1246+ class:drop-before={dropTarget?.id === space.room_id && !dropTarget.after}
1247+ class:drop-after={dropTarget?.id === space.room_id && dropTarget.after}
1248+ class:pinned={space.favourite}
1249+ title={space.name}
1250+ aria-label={`Show channels in space ${space.name}`}
1251+ aria-pressed={selectedSpaceId === space.room_id}
1252+ draggable="true"
1253+ on:click={() => void selectSpace(space.room_id)}
1254+ on:contextmenu|preventDefault={(ev) => (railMenu = { room: space, x: ev.clientX, y: ev.clientY })}
1255+ on:dragstart={(ev) => startRailDrag(ev, space.room_id)}
1256+ on:dragover={(ev) => overRailSpace(ev, space)}
1257+ on:dragleave={() => dropTarget?.id === space.room_id && (dropTarget = null)}
1258+ on:drop|preventDefault={() => void dropRailSpace()}
1259+ on:dragend={() => ((dragSpaceId = null), (dropTarget = null))}
1260+ >
1261+ <MxcAvatar mxc={space.avatar} label={space.name} size={44} shape="rounded" />
1262+ </button>
1263+ {/each}
11651264 {/each}
11661265 <button
11671266 type="button"
@@ -1175,6 +1274,15 @@
11751274 </button>
11761275 </nav>
11771276
1277+ {#if railMenu}
1278+ {@const menuRoom = railMenu.room}
1279+ <div class="room-menu rail-menu" role="menu" style={`left:${railMenu.x}px;top:${railMenu.y}px`} bind:this={railMenuEl}>
1280+ <button type="button" role="menuitem" on:click={() => void togglePin(menuRoom)}>
1281+ {menuRoom.favourite ? 'Unpin' : 'Pin to top'}
1282+ </button>
1283+ </div>
1284+ {/if}
1285+
11781286 <aside class="sidebar" class:open={sidebarOpen} aria-label="Rooms">
11791287 <div class="side-search">
11801288 <label class="visually-hidden" for="room-search">Search rooms</label>
@@ -1284,7 +1392,7 @@
12841392 <h2 class="side-h">Direct messages</h2>
12851393 <ul class="room-list">
12861394 {#each directRooms as room (room.room_id)}
1287- <li>
1395+ <li class="room-item">
12881396 <button
12891397 type="button"
12901398 class="room-row"
@@ -1304,6 +1412,7 @@
13041412 </span>
13051413 {/if}
13061414 </button>
1415+ <PinButton pinned={room.favourite} label={room.name} onToggle={() => void togglePin(room)} />
13071416 </li>
13081417 {/each}
13091418 </ul>
@@ -1313,7 +1422,7 @@
13131422 <h2 class="side-h">{selectedSpace ? 'Channels' : 'Rooms'}</h2>
13141423 <ul class="room-list">
13151424 {#each groupRooms as room (room.room_id)}
1316- <li>
1425+ <li class="room-item">
13171426 <button
13181427 type="button"
13191428 class="room-row"
@@ -1334,6 +1443,7 @@
13341443 </span>
13351444 {/if}
13361445 </button>
1446+ <PinButton pinned={room.favourite} label={room.name} onToggle={() => void togglePin(room)} />
13371447 </li>
13381448 {/each}
13391449 </ul>
@@ -1343,7 +1453,7 @@
13431453 <h2 class="side-h">{selectedSpace ? 'Subspaces' : 'Spaces'}</h2>
13441454 <ul class="room-list">
13451455 {#each spaceList as room (room.room_id)}
1346- <li>
1456+ <li class="room-item">
13471457 <button
13481458 type="button"
13491459 class="room-row"
@@ -1355,6 +1465,7 @@
13551465 <span class="room-hash" aria-hidden="true">◇</span>
13561466 <span class="room-name">{room.name}</span>
13571467 </button>
1468+ <PinButton pinned={room.favourite} label={room.name} onToggle={() => void togglePin(room)} />
13581469 </li>
13591470 {/each}
13601471 </ul>
Modified file: web/src/app.css+5 additions
@@ -287,6 +287,11 @@ input, textarea { font: inherit; }
287287 font-size: 13.5px;
288288 }
289289 .room-row:hover { background: var(--panel-2); }
290+.room-item { display: flex; align-items: center; gap: 2px; }
291+.room-item > .room-row { flex: 1; min-width: 0; }
292+.rail-space.drop-before { box-shadow: 0 -6px 0 -3px var(--accent); }
293+.rail-space.drop-after { box-shadow: 0 6px 0 -3px var(--accent); }
294+.rail-menu { position: fixed; top: 0; right: auto; z-index: 70; }
290295 .room-row.active { background: color-mix(in srgb, var(--accent) 20%, transparent); }
291296 .room-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
292297 .room-hash { color: var(--muted); }
Modified file: web/src/lib/ImageLightbox.svelte+19 additions
@@ -3,6 +3,19 @@
33 import { lightbox } from './lightbox';
44
55 let closeBtn: HTMLButtonElement | null = null;
6+ let copied = false;
7+ let copiedTimer: number | null = null;
8+
9+ async function copyMxc(mxc: string): Promise<void> {
10+ try {
11+ await navigator.clipboard.writeText(mxc);
12+ } catch {
13+ return;
14+ }
15+ copied = true;
16+ if (copiedTimer !== null) window.clearTimeout(copiedTimer);
17+ copiedTimer = window.setTimeout(() => (copied = false), 1500);
18+ }
619 let returnFocus: HTMLElement | null = null;
720
821 $: if ($lightbox) void focusClose();
@@ -34,6 +47,12 @@
3447 <div class="lightbox" role="dialog" tabindex="-1" aria-modal="true" aria-label={$lightbox.filename} on:click|self={close}>
3548 <div class="lightbox-bar">
3649 <span class="lightbox-name">{$lightbox.filename}</span>
50+ {#if $lightbox.mxc}
51+ {@const mxc = $lightbox.mxc}
52+ <button type="button" class="ghost-btn" title={mxc} on:click={() => void copyMxc(mxc)}>
53+ {copied ? 'Copied' : 'Copy mxc'}
54+ </button>
55+ {/if}
3756 <a class="ghost-btn" href={$lightbox.src} target="_blank" rel="noopener noreferrer">Open original</a>
3857 <a class="ghost-btn" href={$lightbox.src} download={$lightbox.filename}>Download</a>
3958 <button type="button" class="icon-btn" aria-label="Close image" title="Close (Esc)" bind:this={closeBtn} on:click={close}>
Modified file: web/src/lib/MediaAttachmentView.svelte+1 additions−1 deletions
@@ -22,7 +22,7 @@
2222 function openLightbox(ev: MouseEvent): void {
2323 if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
2424 ev.preventDefault();
25- lightbox.set({ src: fullUrl, alt: attachment.caption || attachment.filename, filename: attachment.filename });
25+ lightbox.set({ src: fullUrl, alt: attachment.caption || attachment.filename, filename: attachment.filename, mxc: attachment.mxc });
2626 }
2727
2828 function sizeLabel(): string {
Added file: web/src/lib/PinButton.svelte+45 additions
@@ -0,0 +1,45 @@
1+<script lang="ts">
2+ export let pinned: boolean;
3+ export let label: string;
4+ export let onToggle: () => void;
5+</script>
6+
7+<button
8+ type="button"
9+ class="pin-btn"
10+ class:pinned
11+ aria-pressed={pinned}
12+ aria-label={pinned ? `Unpin ${label}` : `Pin ${label}`}
13+ title={pinned ? 'Unpin' : 'Pin'}
14+ on:click={onToggle}
15+>
16+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
17+ <path
18+ d="M9 3h6l-1 6 4 4v2h-5v6l-1 1-1-1v-6H6v-2l4-4z"
19+ fill={pinned ? 'currentColor' : 'none'}
20+ stroke="currentColor"
21+ stroke-width="1.8"
22+ stroke-linejoin="round"
23+ />
24+ </svg>
25+</button>
26+
27+<style>
28+ .pin-btn {
29+ flex: none;
30+ display: grid;
31+ place-items: center;
32+ width: 24px;
33+ height: 24px;
34+ padding: 0;
35+ border: none;
36+ border-radius: 6px;
37+ background: transparent;
38+ color: var(--muted);
39+ cursor: pointer;
40+ opacity: 0;
41+ }
42+ .pin-btn.pinned { opacity: 1; color: var(--accent); }
43+ .pin-btn:hover { background: var(--hover); color: var(--text); }
44+ :global(.room-item:hover) .pin-btn, :global(.room-item:focus-within) .pin-btn, .pin-btn:focus-visible { opacity: 1; }
45+</style>
Modified file: web/src/lib/api.ts+4 additions−3 deletions
@@ -4,7 +4,7 @@ import type { MemberSummary, RoomSummary } from './types';
44 import { formatBody } from './markdown';
55 import { mentionedUserIds, type Mention } from './mentions';
66 import { HttpTransport } from './transport-http';
7-import type { CommandRequest, SessionInfo, Transport, VerifyAction } from './transport';
7+import type { CommandRequest, SendKind, SessionInfo, Transport, VerifyAction } from './transport';
88 import { createDeviceTransport } from 'malice:device-transport';
99
1010 export { ApiError } from './transport';
@@ -41,10 +41,11 @@ export const sendMessage = (
4141 reply_to: string | null = null,
4242 mentions: Mention[] = [],
4343 markdown = false,
44- emote = false
45-) => transport.sendMessage(room_id, body, formatBody(body, mentions, markdown), reply_to, mentionedUserIds(mentions), emote);
44+ kind: SendKind = 'text'
45+) => transport.sendMessage(room_id, body, formatBody(body, mentions, markdown), reply_to, mentionedUserIds(mentions), kind);
4646 export const runCommand = (request: CommandRequest) => transport.command(request);
4747 export const uploadAvatar = (file: File) => transport.uploadAvatar(file);
48+export const uploadRoomAvatar = (room_id: string, file: File) => transport.uploadRoomAvatar(room_id, file);
4849 export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);
4950 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
5051 export const getAccountData = (eventType: string) => transport.getAccountData(eventType);
Modified file: web/src/lib/commands.ts+7 additions
@@ -7,6 +7,7 @@ export interface ParsedCommand {
77
88 export const COMMANDS = [
99 'me',
10+ 'notice',
1011 'msg',
1112 'join',
1213 'leave',
@@ -16,8 +17,14 @@ export const COMMANDS = [
1617 'ban',
1718 'unban',
1819 'powerlevel',
20+ 'mute',
21+ 'unmute',
22+ 'ignore',
23+ 'unignore',
1924 'delete',
25+ 'roomnick',
2026 'globalnick',
27+ 'roomavatar',
2128 'globalavatar'
2229 ] as const;
2330 export type CommandName = (typeof COMMANDS)[number];
Modified file: web/src/lib/lightbox.ts+1 additions
@@ -4,6 +4,7 @@ export interface LightboxImage {
44 src: string;
55 alt: string;
66 filename: string;
7+ mxc: string | null;
78 }
89
910 export const lightbox = writable<LightboxImage | null>(null);
Added file: web/src/lib/rail-order.ts+44 additions
@@ -0,0 +1,44 @@
1+interface Orderable {
2+ room_id: string;
3+ space_order: string | null;
4+}
5+
6+interface Pinnable {
7+ favourite: boolean;
8+}
9+
10+function byCodeUnit(a: string, b: string): number {
11+ return a < b ? -1 : a > b ? 1 : 0;
12+}
13+
14+/** MSC3230: spaces with an order first, compared by code unit, then the rest by room id. */
15+export function compareSpaceOrder(a: Orderable, b: Orderable): number {
16+ if (a.space_order !== null && b.space_order !== null) {
17+ return byCodeUnit(a.space_order, b.space_order) || byCodeUnit(a.room_id, b.room_id);
18+ }
19+ if (a.space_order !== null) return -1;
20+ if (b.space_order !== null) return 1;
21+ return byCodeUnit(a.room_id, b.room_id);
22+}
23+
24+export function sortSpaces<T extends Orderable>(spaces: T[], override: string[] | null): T[] {
25+ if (override) {
26+ const rank = new Map(override.map((id, i) => [id, i]));
27+ return [...spaces].sort(
28+ (a, b) => (rank.get(a.room_id) ?? Infinity) - (rank.get(b.room_id) ?? Infinity) || compareSpaceOrder(a, b)
29+ );
30+ }
31+ return [...spaces].sort(compareSpaceOrder);
32+}
33+
34+export function pinnedFirst<T extends Pinnable>(list: T[]): T[] {
35+ return [...list.filter((r) => r.favourite), ...list.filter((r) => !r.favourite)];
36+}
37+
38+/** `ids` with `moved` placed just before `before`, or at the end when `before` is null. */
39+export function moveBefore(ids: string[], moved: string, before: string | null): string[] {
40+ const rest = ids.filter((id) => id !== moved);
41+ const at = before === null ? rest.length : rest.indexOf(before);
42+ if (at < 0) return ids;
43+ return [...rest.slice(0, at), moved, ...rest.slice(at)];
44+}
Modified file: web/src/lib/transport-http.ts+13 additions−3 deletions
@@ -1,5 +1,5 @@
11 import type { MemberSummary, RoomSummary } from './types';
2-import { ApiError, type CommandRequest, type SessionInfo, type Transport, type UploadExtras, type VerifyAction } from './transport';
2+import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type Transport, type UploadExtras, type VerifyAction } from './transport';
33
44 async function parseFailure(res: Response, fallback: string): Promise<never> {
55 let message = fallback;
@@ -96,9 +96,9 @@ export class HttpTransport implements Transport {
9696 formatted_body: string | null,
9797 reply_to: string | null,
9898 mentions: string[],
99- emote = false
99+ kind: SendKind = 'text'
100100 ): Promise<void> {
101- return this.roomCommand(ROOM_COMMAND_PATHS.send, { room_id, body, formatted_body, reply_to, mentions, emote });
101+ return this.roomCommand(ROOM_COMMAND_PATHS.send, { room_id, body, formatted_body, reply_to, mentions, kind });
102102 }
103103 paginateRoom(room_id: string): Promise<void> {
104104 return this.roomCommand(ROOM_COMMAND_PATHS.paginate, { room_id });
@@ -238,6 +238,16 @@ export class HttpTransport implements Transport {
238238 return json(res, 'Avatar upload failed');
239239 }
240240
241+ async uploadRoomAvatar(room_id: string, file: File): Promise<void> {
242+ const res = await fetch(`/api/rooms/avatar?room_id=${encodeURIComponent(room_id)}`, {
243+ method: 'POST',
244+ credentials: 'same-origin',
245+ headers: { 'content-type': file.type || 'application/octet-stream' },
246+ body: file
247+ });
248+ return json(res, 'Avatar upload failed');
249+ }
250+
241251 async uploadAttachment(room_id: string, file: File, extras: UploadExtras): Promise<void> {
242252 const form = new FormData();
243253 form.append('room_id', room_id);
Modified file: web/src/lib/transport-worker.ts+7 additions−3 deletions
@@ -1,5 +1,5 @@
11 import type { MemberSummary, RoomSummary } from './types';
2-import { ApiError, type CommandRequest, type SessionInfo, type SoftLogout, type Transport, type UploadExtras, type VerifyAction } from './transport';
2+import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type SoftLogout, type Transport, type UploadExtras, type VerifyAction } from './transport';
33 import { statusForKind } from './wire-status';
44
55 function toApiError(error: { kind: string; message: string }): ApiError {
@@ -222,9 +222,9 @@ export class WorkerTransport implements Transport {
222222 formatted_body: string | null,
223223 reply_to: string | null,
224224 mentions: string[],
225- emote = false
225+ kind: SendKind = 'text'
226226 ): Promise<void> {
227- return this.client.call('send', { room_id, body, formatted_body, reply_to, mentions, emote });
227+ return this.client.call('send', { room_id, body, formatted_body, reply_to, mentions, kind });
228228 }
229229 paginateRoom(room_id: string): Promise<void> {
230230 return this.client.call('paginate', { room_id });
@@ -289,6 +289,10 @@ export class WorkerTransport implements Transport {
289289 await this.client.call('upload_avatar', {}, await file.arrayBuffer());
290290 }
291291
292+ async uploadRoomAvatar(room_id: string, file: File): Promise<void> {
293+ await this.client.call('upload_room_avatar', { room_id }, await file.arrayBuffer());
294+ }
295+
292296 async uploadAttachment(room_id: string, file: File, extras: UploadExtras): Promise<void> {
293297 const bytes = await file.arrayBuffer();
294298 await this.client.call(
Modified file: web/src/lib/transport.ts+11 additions−2 deletions
@@ -28,7 +28,15 @@ export type CommandRequest =
2828 | { op: 'set_avatar'; mxc: string | null }
2929 | { op: 'invite'; room_id: string; user_id: string }
3030 | { op: 'direct'; user_id: string }
31- | { op: 'redact'; room_id: string; event_id: string; reason: string | null };
31+ | { op: 'redact'; room_id: string; event_id: string; reason: string | null }
32+ | { op: 'set_favourite'; room_id: string; favourite: boolean }
33+ | { op: 'reorder_spaces'; order: string[] }
34+ | { op: 'set_room_display_name'; room_id: string; name: string | null }
35+ | { op: 'set_room_avatar'; room_id: string; mxc: string | null }
36+ | { op: 'mute'; room_id: string; user_id: string; muted: boolean }
37+ | { op: 'ignore' | 'unignore'; user_id: string };
38+
39+export type SendKind = 'text' | 'emote' | 'notice';
3240
3341 export interface UploadExtras {
3442 caption: string | null;
@@ -57,7 +65,7 @@ export interface Transport {
5765 formatted_body: string | null,
5866 reply_to: string | null,
5967 mentions: string[],
60- emote?: boolean
68+ kind?: SendKind
6169 ): Promise<void>;
6270 paginateRoom(room_id: string): Promise<void>;
6371 setTyping(room_id: string, typing: boolean): Promise<void>;
@@ -81,6 +89,7 @@ export interface Transport {
8189 /** A core request with no bytes, as `{ op, ...fields }`. */
8290 command(request: CommandRequest): Promise<unknown>;
8391 uploadAvatar(file: File): Promise<void>;
92+ uploadRoomAvatar(room_id: string, file: File): Promise<void>;
8493 /**
8594 * Wire events as raw JSON text, snapshot first. Returns an unsubscribe
8695 * function. onDisconnect fires whenever the underlying connection drops;
Modified file: web/src/lib/types.ts+6 additions−2 deletions
@@ -40,6 +40,7 @@ export interface RoomSummary {
4040 inviter: string | null;
4141 parent_spaces: string[];
4242 children: string[];
43+ space_order: string | null;
4344 }
4445
4546 export interface MemberSummary {
@@ -112,6 +113,7 @@ export interface MediaAttachment {
112113 duration_ms: number | null;
113114 blurhash: string | null;
114115 has_thumbnail: boolean;
116+ mxc: string | null;
115117 }
116118
117119 export function parseMediaAttachment(raw: unknown): MediaAttachment | null {
@@ -130,7 +132,8 @@ export function parseMediaAttachment(raw: unknown): MediaAttachment | null {
130132 height: optionalNumber(raw, 'height'),
131133 duration_ms: optionalNumber(raw, 'duration_ms'),
132134 blurhash: optionalString(raw, 'blurhash'),
133- has_thumbnail: raw['has_thumbnail'] === true
135+ has_thumbnail: raw['has_thumbnail'] === true,
136+ mxc: optionalString(raw, 'mxc')
134137 };
135138 }
136139
@@ -360,7 +363,8 @@ export function parseRoomSummary(raw: unknown): RoomSummary | null {
360363 successor: optionalString(raw, 'successor'),
361364 inviter: optionalString(raw, 'inviter'),
362365 parent_spaces: stringList(raw['parent_spaces']),
363- children: stringList(raw['children'])
366+ children: stringList(raw['children']),
367+ space_order: optionalString(raw, 'space_order')
364368 };
365369 }
366370
Modified file: web/src/lib/wire-status.ts+1 additions
@@ -8,6 +8,7 @@ export const STATUS_BY_KIND: Record<string, number> = {
88 engine_stopped: 502,
99 upstream: 502,
1010 unauthorized: 401,
11+ forbidden: 403,
1112 store_in_use: 409
1213 };
1314
Added file: web/test/rail-order.test.ts+31 additions
@@ -0,0 +1,31 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { moveBefore, pinnedFirst, sortSpaces } from '../src/lib/rail-order.ts';
4+
5+const space = (room_id: string, space_order: string | null) => ({ room_id, space_order });
6+
7+test('orders spaces the MSC3230 way', () => {
8+ const spaces = [space('!d', null), space('!c', 'b'), space('!b', null), space('!a', 'a'), space('!e', 'b')];
9+ assert.deepEqual(sortSpaces(spaces, null).map((s) => s.room_id), ['!a', '!c', '!e', '!b', '!d']);
10+});
11+
12+test('a local override wins until the server catches up', () => {
13+ const spaces = [space('!a', 'a'), space('!b', 'b'), space('!c', null)];
14+ assert.deepEqual(sortSpaces(spaces, ['!c', '!a', '!b']).map((s) => s.room_id), ['!c', '!a', '!b']);
15+});
16+
17+test('moves an id before another or to the end', () => {
18+ assert.deepEqual(moveBefore(['a', 'b', 'c', 'd'], 'd', 'b'), ['a', 'd', 'b', 'c']);
19+ assert.deepEqual(moveBefore(['a', 'b', 'c'], 'a', null), ['b', 'c', 'a']);
20+ assert.deepEqual(moveBefore(['a', 'b'], 'a', 'zz'), ['a', 'b']);
21+});
22+
23+test('pinned rooms come first and keep their relative order', () => {
24+ const rooms = [
25+ { id: 1, favourite: false },
26+ { id: 2, favourite: true },
27+ { id: 3, favourite: false },
28+ { id: 4, favourite: true }
29+ ];
30+ assert.deepEqual(pinnedFirst(rooms).map((r) => r.id), [2, 4, 1, 3]);
31+});