Commit 7cca1d5628d1
tarball21 files changed+495-54
@@ -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::NotFound5657
}5758
ErrorKind::MediaTooLarge => Failure::TooLarge,59
+ ErrorKind::Forbidden => Failure::Forbidden,5860
ErrorKind::Invalid | ErrorKind::EmptyBody => Failure::BadRequest,5961
ErrorKind::RoomNotOpen6062
| ErrorKind::TimelineNotOpen@@ -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
+ handle671
+ .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 = order680
+ .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
+ handle692
+ .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
+ handle699
+ .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
+ handle716
+ .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.@@ -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,@@ -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))@@ -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 request173
+ if let Request::Upload { bytes: slot, .. }174
+ | Request::UploadAvatar { bytes: slot }175
+ | Request::UploadRoomAvatar { bytes: slot, .. } = &mut request175176
{176177
*slot = bytes.map(|array| array.to_vec()).unwrap_or_default();177178
}@@ -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",@@ -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
editMessage3033
} 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
- <button1155
- 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
+ <button1243
+ 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
<button11671266
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
<button12891397
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
<button13181427
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
<button13481458
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>@@ -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); }@@ -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}>@@ -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 {@@ -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
+<button8
+ type="button"9
+ class="pin-btn"10
+ class:pinned11
+ 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
+ <path18
+ 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>@@ -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 = false45
-) => 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);@@ -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];@@ -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);@@ -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
+}@@ -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 = false99
+ 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: file247
+ });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);@@ -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 = false225
+ 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(@@ -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?: boolean68
+ kind?: SendKind6169
): 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 unsubscribe8695
* function. onDisconnect fires whenever the underlying connection drops;@@ -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'] === true135
+ 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
@@ -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: 4091213
};1314
@@ -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
+});