atagen

malice

public
Star0
Watch0

Commit df3c0f2b3da4

tarball

atagen <boss@atagen.co>2026-10-23 06:26 UTC

20 files changed+762-83
web: add replies, themes, code formatting, pasted image uploads and a settings dialog

Send gains optional formatted_body and reply_to so code spans reach other clients as HTML and replies are real m.relates_to replies rather than plain text. Hard-coded colours moved onto CSS variables so light, black and nord themes render without per-component overrides. Read receipts now render as avatars sourced from timeline senders as well as the member list, since members only load when the panel is open. Quick reaction edits apply optimistically because the old save-then-set flow dropped rapid consecutive picks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modified file: crates/malice-core/src/request.rs+28 additionsโˆ’4 deletions
@@ -1,7 +1,7 @@
11 use macltok::ruma::{events::GlobalAccountDataEventType, serde::Raw};
22 use macltok::{
33 AttachmentUpload, ClientHandle, CreateRoomOptions, MediaId, MediaSize, MemberFilter,
4- MessageContent, MirrorHandle, TimelineId, VerificationAction, VerificationTarget,
4+ MessageContent, MessageMentions, MirrorHandle, RichText, TimelineId, VerificationAction, VerificationTarget,
55 };
66 use serde::Deserialize;
77 use serde_json::json;
@@ -33,6 +33,10 @@ pub enum Request {
3333 room_id: String,
3434 #[serde(default)]
3535 body: String,
36+ #[serde(default)]
37+ formatted_body: Option<String>,
38+ #[serde(default)]
39+ reply_to: Option<String>,
3640 },
3741 Upload {
3842 room_id: String,
@@ -165,16 +169,36 @@ pub async fn dispatch(
165169 handle.close_room(room_id).await?;
166170 Ok(Reply::Done)
167171 }
168- Request::Send { room_id, body } => {
172+ Request::Send {
173+ room_id,
174+ body,
175+ formatted_body,
176+ reply_to,
177+ } => {
169178 let room_id = parse_room_id(&room_id)?;
170179 if body.trim().is_empty() {
171180 return Err(CoreError::bad_request("body must not be empty"));
172181 }
173- if body.len() > 64 * 1024 {
182+ if body.len() + formatted_body.as_ref().map_or(0, String::len) > 64 * 1024 {
174183 return Err(CoreError::bad_request("body is too large"));
175184 }
185+ let text = match formatted_body {
186+ Some(html) => RichText::html(body, html),
187+ None => RichText::plain(body),
188+ };
189+ let content = match reply_to {
190+ Some(event_id) => MessageContent::reply(
191+ parse_event_id(&event_id)?,
192+ text,
193+ MessageMentions::default(),
194+ ),
195+ None => MessageContent::Text {
196+ content: text,
197+ mentions: MessageMentions::default(),
198+ },
199+ };
176200 handle
177- .send_message(TimelineId::Room(room_id), MessageContent::text(body))
201+ .send_message(TimelineId::Room(room_id), content)
178202 .await?;
179203 Ok(Reply::Done)
180204 }
Modified file: crates/malice-server/src/routes.rs+6 additions
@@ -39,6 +39,10 @@ struct SendBody {
3939 room_id: String,
4040 #[serde(default)]
4141 body: String,
42+ #[serde(default)]
43+ formatted_body: Option<String>,
44+ #[serde(default)]
45+ reply_to: Option<String>,
4246 }
4347
4448 #[derive(Debug, Deserialize)]
@@ -383,6 +387,8 @@ async fn rooms_send(
383387 Request::Send {
384388 room_id: body.0.room_id,
385389 body: body.0.body,
390+ formatted_body: body.0.formatted_body,
391+ reply_to: body.0.reply_to,
386392 },
387393 )
388394 .await,
Modified file: web/src/App.svelte+114 additionsโˆ’12 deletions
@@ -54,8 +54,10 @@
5454 } from './lib/link-previews';
5555 import { notifyTyping, stopTyping } from './lib/typing';
5656 import { formatDay } from './lib/format';
57- import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState } from './lib/types';
58- import type { MemberSummary, OwnProfile, QuickReaction, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
57+ import { lineHeight, messageSpacing, navFolded } from './lib/appearance';
58+ import SettingsPanel from './lib/SettingsPanel.svelte';
59+ import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
60+ import type { MemberSummary, OwnProfile, Person, QuickReaction, ReplyReference, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
5961
6062 let roomsLoading = false;
6163 let booted = false;
@@ -63,7 +65,8 @@
6365 let search = '';
6466 let sidebarOpen = false;
6567 let membersOpen = false;
66- let dialog: 'join' | 'create' | 'security' | null = null;
68+ let replyingTo: ReplyReference | null = null;
69+ let dialog: 'join' | 'create' | 'security' | 'settings' | null = null;
6770 let joinTarget = '';
6871 let joinBusy = false;
6972 let createName = '';
@@ -130,8 +133,10 @@
130133 $: verificationBanner = $verification && $verification.kind !== 'Done' ? $verification : null;
131134 $: items = $selectedRoomId ? ($timelines[$selectedRoomId] ?? []) : [];
132135 $: members = $selectedRoomId ? ($membersByRoom[$selectedRoomId] ?? []) : [];
136+ $: people = peopleIn(items, members);
133137 $: hasStart = items.some((i) => i.kind === 'start');
134138 $: grouped = buildGroups(items);
139+ $: lastSentId = lastOwnSent(items);
135140 $: pendingItems = $selectedRoomId ? ($pendingByRoom[$selectedRoomId] ?? []) : [];
136141 $: lastRealOwn = lastSenderIsOwn(items);
137142
@@ -166,7 +171,7 @@
166171 kind: 'Message',
167172 message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: null, edited: false, media: null }
168173 },
169- reply_to: null,
174+ reply_to: p.replyTo,
170175 thread_root: null,
171176 thread_summary: null,
172177 reactions: [],
@@ -181,6 +186,14 @@
181186 showSender: boolean;
182187 }
183188
189+ function lastOwnSent(list: TimelineItem[]): string | null {
190+ for (let i = list.length - 1; i >= 0; i--) {
191+ const item = list[i];
192+ if (item.kind === 'event' && item.event.own && item.event.send_state.kind === 'Sent') return item.event.item_id;
193+ }
194+ return null;
195+ }
196+
184197 function buildGroups(list: TimelineItem[]): Group[] {
185198 const out: Group[] = [];
186199 let prevSender: string | null = null;
@@ -370,6 +383,7 @@
370383 openedRoom = roomId;
371384 selectedRoomId.set(roomId);
372385 composer = drafts[roomId] ?? '';
386+ replyingTo = null;
373387 stickToBottom = true;
374388 lastReadKey = '';
375389 sidebarOpen = false;
@@ -452,10 +466,29 @@
452466 composer = '';
453467 if (roomId in drafts) delete drafts[roomId];
454468 stickToBottom = true;
455- queueSend(roomId, body);
469+ queueSend(roomId, body, replyingTo);
470+ replyingTo = null;
456471 composerEl?.focus();
457472 }
458473
474+ function startReply(target: ReplyReference): void {
475+ replyingTo = target;
476+ composerEl?.focus();
477+ }
478+
479+ function jumpTo(eventId: string): void {
480+ const el = timelineEl?.querySelector<HTMLElement>(`[data-event="${CSS.escape(eventId)}"]`);
481+ if (!el) {
482+ pushBanner('The original message is not loaded. Scroll up to load older messages.');
483+ return;
484+ }
485+ stickToBottom = false;
486+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
487+ el.classList.remove('flash');
488+ void el.offsetWidth;
489+ el.classList.add('flash');
490+ }
491+
459492 let typedText = '';
460493 let typedRoom: string | null = null;
461494 $: trackTypingInput(composer, $selectedRoomId, openReady);
@@ -482,6 +515,9 @@
482515 if (ev.key === 'Enter' && !ev.shiftKey) {
483516 ev.preventDefault();
484517 send();
518+ } else if (ev.key === 'Escape' && replyingTo) {
519+ ev.stopPropagation();
520+ replyingTo = null;
485521 }
486522 }
487523
@@ -492,13 +528,26 @@
492528 }
493529
494530 async function onFileChosen(): Promise<void> {
531+ const file = fileInput?.files?.[0];
532+ if (file) await uploadFile(file);
533+ if (fileInput) fileInput.value = '';
534+ }
535+
536+ function onComposerPaste(ev: ClipboardEvent): void {
537+ const data = ev.clipboardData;
538+ // Office apps put a rendered image next to the copied text, so only take the image when there is no plain text.
539+ if (!data || data.types.includes('text/plain')) return;
540+ const image = Array.from(data.files).find((f) => f.type.startsWith('image/'));
541+ if (!image) return;
542+ ev.preventDefault();
543+ void uploadFile(image);
544+ }
545+
546+ async function uploadFile(file: File): Promise<void> {
495547 const roomId = $selectedRoomId;
496- const files = fileInput?.files;
497- if (!roomId || !files || files.length === 0) return;
498- const file = files[0];
548+ if (!roomId || uploadBusy || !openReady[roomId]) return;
499549 if (file.size === 0 || file.size > 20 * 1024 * 1024) {
500550 pushBanner('File must be between 1 byte and 20 MiB.');
501- if (fileInput) fileInput.value = '';
502551 return;
503552 }
504553 uploadBusy = true;
@@ -511,7 +560,6 @@
511560 } finally {
512561 uploadBusy = false;
513562 uploadName = null;
514- if (fileInput) fileInput.value = '';
515563 }
516564 }
517565 async function react(eventId: string, key: string, shortcode?: string): Promise<void> {
@@ -581,6 +629,17 @@
581629 return next;
582630 }
583631
632+ function peopleIn(list: TimelineItem[], roster: MemberSummary[]): Record<string, Person> {
633+ const out: Record<string, Person> = {};
634+ for (const item of list) {
635+ if (item.kind !== 'event') continue;
636+ const s = item.event.sender;
637+ out[s.user_id] = { user_id: s.user_id, name: senderLabel(s), avatar: s.avatar };
638+ }
639+ for (const m of roster) out[m.user_id] = { user_id: m.user_id, name: m.name, avatar: m.avatar };
640+ return out;
641+ }
642+
584643 function senderAvatarFor(userId: string): string | null {
585644 return avatarByUser[userId] ?? null;
586645 }
@@ -674,7 +733,7 @@
674733 {:else if $sessionUser == null}
675734 <LoginPanel softLogout={$softLogout} on:done={(ev) => afterLogin(ev.detail)} />
676735 {:else}
677- <div class="app">
736+ <div class="app" class:nav-folded={$navFolded && selectedRoom} style:--msg-gap={`${$messageSpacing}px`} style:--msg-line-height={$lineHeight}>
678737 <nav class="rail" aria-label="Servers and spaces">
679738 <button
680739 type="button"
@@ -913,6 +972,15 @@
913972 >
914973 <span aria-hidden="true">๐Ÿ›ก</span>
915974 </button>
975+ <button
976+ type="button"
977+ class="icon-btn"
978+ title="Settings"
979+ aria-label="Settings"
980+ on:click={() => (dialog = 'settings')}
981+ >
982+ <span aria-hidden="true">โš™</span>
983+ </button>
916984 <button type="button" class="icon-btn" title="Log out" aria-label="Log out" on:click={() => void doLogout()}>
917985 <span aria-hidden="true">โป</span>
918986 </button>
@@ -968,6 +1036,16 @@
9681036 >
9691037 <span aria-hidden="true">โ˜ฐ</span>
9701038 </button>
1039+ <button
1040+ type="button"
1041+ class="icon-btn only-desktop"
1042+ aria-pressed={!$navFolded}
1043+ aria-label={$navFolded ? 'Show spaces and rooms' : 'Hide spaces and rooms'}
1044+ title={$navFolded ? 'Show spaces and rooms' : 'Hide spaces and rooms'}
1045+ on:click={() => navFolded.update((v) => !v)}
1046+ >
1047+ <span aria-hidden="true">{$navFolded ? 'ยป' : 'ยซ'}</span>
1048+ </button>
9711049 <div class="head-title">
9721050 <div class="head-room">
9731051 <MxcAvatar mxc={selectedRoom.avatar} label={selectedRoom.name} size={36} shape={selectedRoom.space ? 'rounded' : 'circle'} />
@@ -1117,6 +1195,10 @@
11171195 linkPreviews={$linkPreviews}
11181196 {loadPreview}
11191197 onQuickChange={(list) => void changeQuickReactions(list)}
1198+ {people}
1199+ onReply={selectedRoom.membership === 'Joined' ? startReply : null}
1200+ onJump={jumpTo}
1201+ showSent={g.item.event.item_id === lastSentId && pendingItems.length === 0}
11201202 />
11211203 {/if}
11221204 {/each}
@@ -1125,6 +1207,7 @@
11251207 <MessageRow
11261208 itemId={`pending-${p.id}`}
11271209 event={pendingToEvent(p)}
1210+ onJump={jumpTo}
11281211 showSender={pendingShowSender(i)}
11291212 senderAvatar={senderAvatarFor($sessionUser ?? '')}
11301213 {reacting}
@@ -1146,7 +1229,18 @@
11461229 </div>
11471230
11481231 {#if selectedRoom.membership === 'Joined'}
1149- <form class="composer" on:submit|preventDefault={() => send()}>
1232+ {#if replyingTo && replyingTo.details.kind === 'Ready'}
1233+ <div class="reply-bar" role="status">
1234+ <span class="reply-bar-label">
1235+ Replying to <strong>{senderLabel(replyingTo.details.sender)}</strong>
1236+ <span class="reply-bar-text">{contentPreview(replyingTo.details.content)}</span>
1237+ </span>
1238+ <button type="button" class="icon-btn" aria-label="Cancel reply" title="Cancel reply (Esc)" on:click={() => (replyingTo = null)}>
1239+ <span aria-hidden="true">โœ•</span>
1240+ </button>
1241+ </div>
1242+ {/if}
1243+ <form class="composer" class:replying={replyingTo} on:submit|preventDefault={() => send()}>
11501244 <label class="visually-hidden" for="composer">Message {selectedRoom.name}</label>
11511245 <input
11521246 type="file"
@@ -1173,6 +1267,7 @@
11731267 placeholder={`Message ${selectedRoom.name}`}
11741268 aria-label={`Message ${selectedRoom.name}. Enter to send, Shift Enter for a new line.`}
11751269 on:keydown={onComposerKey}
1270+ on:paste={onComposerPaste}
11761271 disabled={!($selectedRoomId && openReady[$selectedRoomId])}
11771272 ></textarea>
11781273 <button
@@ -1240,6 +1335,13 @@
12401335 onEncryptedPreviews={(enabled) => void changeEncryptedPreviews(enabled)}
12411336 on:close={() => (dialog = null)}
12421337 />
1338+ {:else if dialog === 'settings'}
1339+ <SettingsPanel
1340+ quick={$quickReactions}
1341+ packs={roomPacks}
1342+ onQuickChange={(list) => void changeQuickReactions(list)}
1343+ on:close={() => (dialog = null)}
1344+ />
12431345 {:else}
12441346 <div class="modal" role="dialog" aria-modal="true" aria-label={dialog === 'join' ? 'Join a room' : 'Create a room'}>
12451347 <div class="modal-tabs" role="tablist" aria-label="Room actions">
Modified file: web/src/app.css+116 additionsโˆ’17 deletions
@@ -1,17 +1,96 @@
1-:root {
1+:root,
2+:root[data-theme='dark'] {
23 --bg: #14151c;
34 --bg-soft: #1b1d27;
45 --panel: #1e2029;
56 --panel-2: #232634;
7+ --rail-bg: #101117;
68 --border: #2e3142;
79 --text: #e6e8f0;
810 --muted: #9aa0b4;
911 --accent: #5865f2;
12+ --accent-2: #7c5cff;
13+ --glow: #1c2440;
14+ --hover: rgba(255, 255, 255, 0.06);
15+ --hover-faint: rgba(255, 255, 255, 0.018);
16+ --name-lightness: 72%;
17+ --ok: #3fb97f;
18+ --ok-text: #9fe3c3;
1019 --danger: #f26d6d;
20+ --danger-bg: #3a1d22;
21+ --danger-border: #6b2a33;
22+ --danger-text: #ffc9ce;
1123 --warn: #e8b25a;
24+ --warn-bg: #33300f;
25+ --warn-border: #5c551f;
26+ --warn-text: #ecd98a;
1227 color-scheme: dark;
1328 }
1429
30+:root[data-theme='black'] {
31+ --bg: #000;
32+ --bg-soft: #0b0b0e;
33+ --panel: #08080a;
34+ --panel-2: #141418;
35+ --rail-bg: #000;
36+ --border: #222228;
37+ --glow: #0d1020;
38+ --hover: rgba(255, 255, 255, 0.07);
39+ --hover-faint: rgba(255, 255, 255, 0.025);
40+}
41+
42+:root[data-theme='nord'] {
43+ --bg: #2e3440;
44+ --bg-soft: #353c4a;
45+ --panel: #3b4252;
46+ --panel-2: #434c5e;
47+ --rail-bg: #282d38;
48+ --border: #4c566a;
49+ --text: #eceff4;
50+ --muted: #a3acbf;
51+ --accent: #5e81ac;
52+ --accent-2: #88c0d0;
53+ --glow: #3b4a66;
54+ --ok: #a3be8c;
55+ --ok-text: #d3e5c3;
56+ --danger: #bf616a;
57+ --danger-bg: #4a3439;
58+ --danger-border: #7a4850;
59+ --danger-text: #f2c9cd;
60+ --warn: #ebcb8b;
61+ --warn-bg: #4a4536;
62+ --warn-border: #7a6d4a;
63+ --warn-text: #f3e2bd;
64+}
65+
66+:root[data-theme='light'] {
67+ --bg: #f7f7fa;
68+ --bg-soft: #eef0f4;
69+ --panel: #ffffff;
70+ --panel-2: #e9ebf1;
71+ --rail-bg: #e3e5ec;
72+ --border: #d5d8e1;
73+ --text: #1d2030;
74+ --muted: #5e6478;
75+ --accent: #4f5bd5;
76+ --accent-2: #7c5cff;
77+ --glow: #dfe4ff;
78+ --hover: rgba(0, 0, 0, 0.05);
79+ --hover-faint: rgba(0, 0, 0, 0.025);
80+ --name-lightness: 38%;
81+ --ok: #23955f;
82+ --ok-text: #17663f;
83+ --danger: #d43c3c;
84+ --danger-bg: #fde8e9;
85+ --danger-border: #f2b3b8;
86+ --danger-text: #8c1d26;
87+ --warn: #b7791f;
88+ --warn-bg: #fdf3d8;
89+ --warn-border: #ecd28c;
90+ --warn-text: #6b4a0b;
91+ color-scheme: light;
92+}
93+
1594 * { box-sizing: border-box; }
1695
1796 html, body, #app {
@@ -50,7 +129,7 @@ input, textarea { font: inherit; }
50129 border-radius: 16px;
51130 display: grid; place-items: center;
52131 font-size: 26px; font-weight: 800; color: #fff;
53- background: linear-gradient(135deg, var(--accent), #7c5cff);
132+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
54133 }
55134
56135 .app {
@@ -60,10 +139,13 @@ input, textarea { font: inherit; }
60139 overflow: hidden;
61140 }
62141 .app:has(.members) { grid-template-columns: 68px 300px 1fr 260px; }
142+.app.nav-folded { grid-template-columns: 1fr; }
143+.app.nav-folded:has(.members) { grid-template-columns: 1fr 260px; }
144+.app.nav-folded > .rail, .app.nav-folded > .sidebar { display: none; }
63145
64146 /* โ€”โ€”โ€” Rail โ€”โ€”โ€” */
65147 .rail {
66- background: #101117;
148+ background: var(--rail-bg);
67149 display: flex;
68150 flex-direction: column;
69151 align-items: center;
@@ -108,13 +190,13 @@ input, textarea { font: inherit; }
108190 gap: 10px;
109191 margin: 10px 16px 0;
110192 padding: 8px 12px;
111- background: rgba(88, 101, 242, 0.14);
193+ background: color-mix(in srgb, var(--accent) 14%, transparent);
112194 border: 1px solid var(--accent);
113195 color: var(--text);
114196 border-radius: 8px;
115197 font-size: 12.5px;
116198 }
117-.sec-banner.warn { background: #33300f; border-color: #5c551f; color: #ecd98a; }
199+.sec-banner.warn { background: var(--warn-bg); border-color: var(--warn-border); color: var(--warn-text); }
118200 .sec-banner button {
119201 margin-left: auto;
120202 flex: none;
@@ -129,7 +211,7 @@ input, textarea { font: inherit; }
129211 .composer-note { margin: 0; padding: 0 16px 10px; font-size: 11.5px; color: var(--muted); }
130212 .head-room { display: flex; align-items: center; gap: 12px; min-width: 0; }
131213 .head-room-text { min-width: 0; flex: 1; }
132-.conn-banner.error { background: #3a1d22; border-color: #6b2a33; color: #ffc9ce; }
214+.conn-banner.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-text); }
133215 /* โ€”โ€”โ€” Sidebar โ€”โ€”โ€” */
134216 .sidebar {
135217 background: var(--panel);
@@ -179,7 +261,7 @@ input, textarea { font: inherit; }
179261 font-size: 13.5px;
180262 }
181263 .room-row:hover { background: var(--panel-2); }
182-.room-row.active { background: rgba(88, 101, 242, 0.2); }
264+.room-row.active { background: color-mix(in srgb, var(--accent) 20%, transparent); }
183265 .room-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
184266 .room-hash { color: var(--muted); }
185267 .room-name {
@@ -309,7 +391,7 @@ input, textarea { font: inherit; }
309391 }
310392 .conn { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
311393 .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; }
312-.dot-online { background: #3fb97f; }
394+.dot-online { background: var(--ok); }
313395 .dot-busy { background: var(--warn); }
314396 .dot-off { background: var(--danger); }
315397 .head-actions { display: flex; align-items: center; gap: 8px; }
@@ -346,9 +428,9 @@ input, textarea { font: inherit; }
346428 display: flex;
347429 align-items: center;
348430 gap: 10px;
349- background: #3a1d22;
350- border: 1px solid #6b2a33;
351- color: #ffc9ce;
431+ background: var(--danger-bg);
432+ border: 1px solid var(--danger-border);
433+ color: var(--danger-text);
352434 border-radius: 8px;
353435 padding: 8px 12px;
354436 font-size: 13px;
@@ -365,9 +447,9 @@ input, textarea { font: inherit; }
365447 .conn-banner {
366448 margin: 10px 16px 0;
367449 padding: 7px 12px;
368- background: #33300f;
369- border: 1px solid #5c551f;
370- color: #ecd98a;
450+ background: var(--warn-bg);
451+ border: 1px solid var(--warn-border);
452+ color: var(--warn-text);
371453 border-radius: 8px;
372454 font-size: 12.5px;
373455 }
@@ -404,7 +486,7 @@ input, textarea { font: inherit; }
404486 font-size: 12px;
405487 font-weight: 600;
406488 }
407-.new-row::before, .new-row::after { content: ''; flex: 1; height: 1px; background: #6b2a33; }
489+.new-row::before, .new-row::after { content: ''; flex: 1; height: 1px; background: var(--danger-border); }
408490 .unknown-note { color: var(--muted); font-size: 12.5px; font-style: italic; }
409491 .pending-actions {
410492 display: flex;
@@ -422,6 +504,21 @@ input, textarea { font: inherit; }
422504 border-top: 1px solid var(--border);
423505 background: var(--panel);
424506 }
507+.reply-bar {
508+ display: flex;
509+ align-items: center;
510+ gap: 8px;
511+ padding: 6px 16px;
512+ border-top: 1px solid var(--border);
513+ border-left: 3px solid var(--accent);
514+ background: var(--panel);
515+ font-size: 12.5px;
516+ color: var(--muted);
517+}
518+.reply-bar strong { color: var(--text); font-weight: 650; }
519+.reply-bar-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
520+.reply-bar-text { margin-left: 6px; }
521+.composer.replying { border-top: none; }
425522 .composer textarea {
426523 flex: 1;
427524 resize: none;
@@ -512,7 +609,8 @@ input, textarea { font: inherit; }
512609 .only-mobile { display: none; }
513610
514611 @media (max-width: 1100px) {
515- .app, .app:has(.members) { grid-template-columns: 60px 1fr; }
612+ .app, .app:has(.members), .app.nav-folded, .app.nav-folded:has(.members) { grid-template-columns: 60px 1fr; }
613+ .app.nav-folded > .rail, .app.nav-folded > .sidebar { display: flex; }
516614 .sidebar {
517615 position: fixed;
518616 top: 0; bottom: 0; left: 60px;
@@ -541,6 +639,7 @@ input, textarea { font: inherit; }
541639 box-shadow: -8px 0 32px rgba(0, 0, 0, 0.5);
542640 }
543641 .only-mobile { display: inline-block; }
642+ .only-desktop { display: none; }
544643 .head-actions .ghost-btn { display: none; }
545644 .timeline { padding: 10px 12px 14px; }
546645 .chat-head { padding: 8px 12px; }
@@ -548,7 +647,7 @@ input, textarea { font: inherit; }
548647 }
549648
550649 @media (max-width: 640px) {
551- .app, .app:has(.members) { grid-template-columns: 52px 1fr; }
650+ .app, .app:has(.members), .app.nav-folded, .app.nav-folded:has(.members) { grid-template-columns: 52px 1fr; }
552651 .sidebar { left: 52px; transform: translateX(calc(-100% - 52px)); }
553652 .scrim { left: 52px; }
554653 .rail-btn { width: 40px; height: 40px; font-size: 16px; }
Modified file: web/src/lib/LoginPanel.svelte+5 additionsโˆ’5 deletions
@@ -113,7 +113,7 @@
113113 align-items: center;
114114 justify-content: center;
115115 padding: 24px;
116- background: radial-gradient(1200px 600px at 70% -10%, #1c2440 0%, var(--bg) 55%);
116+ background: radial-gradient(1200px 600px at 70% -10%, var(--glow) 0%, var(--bg) 55%);
117117 }
118118 .login-card {
119119 width: min(400px, 100%);
@@ -134,14 +134,14 @@
134134 font-weight: 800;
135135 font-size: 22px;
136136 color: #fff;
137- background: linear-gradient(135deg, var(--accent), #7c5cff);
137+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
138138 }
139139 h1 { font-size: 24px; margin: 2px 0 0; }
140140 .login-sub { color: var(--muted); font-size: 13.5px; margin: 0 0 6px; }
141141 .login-error {
142- background: #3a1d22;
143- border: 1px solid #6b2a33;
144- color: #ffb4bc;
142+ background: var(--danger-bg);
143+ border: 1px solid var(--danger-border);
144+ color: var(--danger-text);
145145 border-radius: 8px;
146146 padding: 8px 10px;
147147 font-size: 13px;
Modified file: web/src/lib/MessageRow.svelte+79 additionsโˆ’22 deletions
@@ -3,10 +3,11 @@
33 import MxcAvatar from './MxcAvatar.svelte';
44 import Emoji from './Emoji.svelte';
55 import EmojiText from './EmojiText.svelte';
6+ import MessageText from './MessageText.svelte';
67 import ReactionPicker from './ReactionPicker.svelte';
78 import LinkCard from './LinkCard.svelte';
89 import { mediaUrl, messageMediaUrl } from './api';
9- import type { ImagePack, LinkPreview, QuickReaction, SendState, TimelineContent, TimelineEvent } from './types';
10+ import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, SendState, TimelineContent, TimelineEvent } from './types';
1011 import { senderLabel } from './types';
1112 import { formatClock, formatDay, initialsOf, senderHue } from './format';
1213 import { findUrls } from './urls';
@@ -22,10 +23,15 @@
2223 export let onQuickChange: (list: QuickReaction[]) => void = () => {};
2324 export let linkPreviews: Record<string, LinkPreview | null> = {};
2425 export let loadPreview: ((url: string) => void) | null = null;
26+ export let people: Record<string, Person> = {};
27+ export let onReply: ((target: ReplyReference) => void) | null = null;
28+ export let onJump: ((eventId: string) => void) | null = null;
29+ export let showSent = true;
2530
2631 const PICKER_WIDTH = 320;
2732 const PICKER_HEIGHT = 380;
2833 const PICKER_MARGIN = 8;
34+ const MAX_READERS = 3;
2935
3036 let row: HTMLDivElement;
3137 let pickerPop: HTMLDivElement | undefined;
@@ -52,6 +58,7 @@
5258
5359 $: name = senderLabel(event.sender);
5460 $: displayName = event.sender.name_ambiguous ? `${name} (${event.sender.user_id})` : name;
61+ $: readers = event.read_by.map((id) => people[id] ?? { user_id: id, name: id, avatar: null });
5562 $: interactive = event.content.kind === 'Message' || event.content.kind === 'Sticker' || event.content.kind === 'Poll';
5663 $: previews = event.content.kind === 'Message' ? findUrls(event.content.message.body) : [];
5764 $: bundled = event.content.kind === 'Message' ? event.content.message.link_previews : [];
@@ -64,7 +71,7 @@
6471 function sendLabel(state: SendState): string {
6572 if (state.kind === 'Failed') return state.will_retry ? 'failed โ€” will retry' : 'failed โ€” not delivered';
6673 if (state.kind === 'Sending') return 'sendingโ€ฆ';
67- if (event.own && state.kind === 'Sent' && event.event_id) return 'โœ“ sent';
74+ if (showSent && event.own && state.kind === 'Sent' && event.event_id) return 'โœ“ sent';
6875 return '';
6976 }
7077
@@ -106,6 +113,14 @@
106113 else onReact(event.event_id ?? '', key);
107114 }
108115
116+ function reply() {
117+ if (!event.event_id || !onReply) return;
118+ onReply({
119+ event_id: event.event_id,
120+ details: { kind: 'Ready', sender: event.sender, content: event.content, timestamp_ms: event.timestamp_ms }
121+ });
122+ }
123+
109124 function pick(key: string, shortcode: string | null) {
110125 pickerOpen = false;
111126 react(key, shortcode);
@@ -189,8 +204,10 @@
189204 class="msg"
190205 class:own={event.own}
191206 class:system={!interactive}
207+ class:has-readers={interactive && readers.length > 0}
192208 class:selected
193209 data-item={itemId}
210+ data-event={event.event_id}
194211 bind:this={row}
195212 on:click={toggleSelected}
196213 >
@@ -223,6 +240,16 @@
223240 >
224241 โ˜บ+
225242 </button>
243+ {#if onReply}
244+ <button
245+ type="button"
246+ title="Reply"
247+ aria-label={`Reply to message from ${displayName}`}
248+ on:click|stopPropagation={reply}
249+ >
250+ โ†ฉ
251+ </button>
252+ {/if}
226253 </div>
227254 {#if pickerOpen}
228255 <div class="picker-pop" style={pickerStyle} bind:this={pickerPop}>
@@ -250,7 +277,7 @@
250277 <div class="msg-body">
251278 {#if showSender}
252279 <div class="msg-head">
253- <span class="sender" style={`color:hsl(${senderHue(event.sender.user_id)} 70% 72%)`}>
280+ <span class="sender" style={`color:hsl(${senderHue(event.sender.user_id)} 70% var(--name-lightness))`}>
254281 {displayName}
255282 </span>
256283 <time class="time" datetime={new Date(event.timestamp_ms).toISOString()}>
@@ -262,22 +289,33 @@
262289 </div>
263290 {/if}
264291 {#if event.reply_to}
265- <div class="reply-quote">
292+ {@const target = event.reply_to.event_id}
293+ <button
294+ type="button"
295+ class="reply-quote"
296+ disabled={!onJump}
297+ title="Jump to the original message"
298+ on:click|stopPropagation={() => onJump?.(target)}
299+ >
266300 {#if event.reply_to.details.kind === 'Ready'}
267- Replying to {senderLabel(event.reply_to.details.sender)}: <EmojiText text={replyPreview(event.reply_to.details.content)} />
301+ <span class="reply-sender">{senderLabel(event.reply_to.details.sender)}</span>
302+ <span class="reply-text"><EmojiText text={replyPreview(event.reply_to.details.content)} /></span>
268303 {:else if event.reply_to.details.kind === 'Pending'}
269304 Loading replyโ€ฆ
270305 {:else}
271306 Reply unavailable
272307 {/if}
273- </div>
308+ </button>
274309 {/if}
275310
276311 {#if event.content.kind === 'Message'}
277312 {@const m = event.content.message}
278- <p class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>
279- {#if m.kind === 'Emote'}* {displayName} {/if}<EmojiText text={m.body} jumbo={m.kind === 'Text'} />
280- </p>
313+ {@const text = m.media ? m.media.caption : m.body}
314+ {#if text}
315+ <div class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>
316+ {#if m.kind === 'Emote'}* {displayName} {/if}<MessageText {text} jumbo={m.kind === 'Text'} />
317+ </div>
318+ {/if}
281319 {#if m.media}
282320 <MediaAttachmentView attachment={m.media} />
283321 {/if}
@@ -322,22 +360,26 @@
322360 {/each}
323361 </div>
324362 {/if}
325- {#if interactive && (sendLabel(event.send_state) || event.read_by.length > 0)}
363+ {#if interactive && sendLabel(event.send_state)}
326364 <div class="msg-foot">
327- {#if sendLabel(event.send_state)}
328- <span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>
329- {/if}
330- {#if event.read_by.length > 0}
331- <span class="readby" title={event.read_by.join(', ')}>โœ“โœ“ {event.read_by.length}</span>
332- {/if}
365+ <span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>
366+ </div>
367+ {/if}
368+ {#if interactive && readers.length > 0}
369+ {@const seenBy = `Seen by ${readers.map((r) => r.name).join(', ')}`}
370+ <div class="readby" title={seenBy} aria-label={seenBy} role="img">
371+ {#each readers.slice(0, MAX_READERS) as r (r.user_id)}
372+ <MxcAvatar mxc={r.avatar} label={r.name} size={16} />
373+ {/each}
374+ {#if readers.length > MAX_READERS}<span class="more">+{readers.length - MAX_READERS}</span>{/if}
333375 </div>
334376 {/if}
335377 </div>
336378 </div>
337379
338380 <style>
339- .msg { position: relative; display: flex; gap: 10px; padding: 3px 0; }
340- .msg:hover, .msg.selected { background: rgba(255, 255, 255, 0.018); }
381+ .msg { position: relative; display: flex; gap: 10px; padding: var(--msg-gap, 3px) 0; }
382+ .msg:hover, .msg.selected { background: var(--hover-faint); }
341383 .msg:hover .hover-time, .msg.selected .hover-time { opacity: 1; }
342384 .msg-foot { display: flex; align-items: center; gap: 8px; }
343385 .sendstate { font-size: 11px; color: var(--muted); }
@@ -357,18 +399,29 @@
357399 .sender { font-weight: 650; font-size: 13.5px; }
358400 .time, .edited { color: var(--muted); font-size: 11.5px; }
359401 .reply-quote {
402+ display: flex; gap: 6px; align-items: baseline;
403+ max-width: min(100%, 520px);
404+ border: none;
360405 border-left: 2px solid var(--accent);
361406 padding: 2px 8px;
362407 margin: 2px 0 4px;
363408 color: var(--muted);
364409 font-size: 12.5px;
410+ text-align: left;
365411 background: var(--bg-soft);
366412 border-radius: 0 6px 6px 0;
413+ cursor: pointer;
367414 }
415+ .reply-quote:disabled { cursor: default; }
416+ .reply-quote:hover:not(:disabled) { color: var(--text); }
417+ .reply-sender { flex: none; font-weight: 650; }
418+ .reply-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
419+ :global(.msg.flash) { animation: reply-flash 1.2s ease-out; }
420+ @keyframes -global-reply-flash { from { background: color-mix(in srgb, var(--accent) 22%, transparent); } }
368421 .text {
369422 margin: 1px 0 2px;
370423 font-size: 14px;
371- line-height: 1.5;
424+ line-height: var(--msg-line-height, 1.5);
372425 white-space: pre-wrap;
373426 overflow-wrap: anywhere;
374427 }
@@ -387,9 +440,13 @@
387440 color: var(--text);
388441 cursor: pointer;
389442 }
390- .reaction.mine { border-color: var(--accent); background: rgba(88, 101, 242, 0.16); }
443+ .reaction.mine { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
391444 .reaction:disabled { cursor: default; opacity: 0.85; }
392- .readby { font-size: 11px; color: var(--muted); }
445+ .msg.has-readers .msg-body { padding-right: 72px; }
446+ .readby { position: absolute; right: 12px; bottom: 4px; display: flex; align-items: center; }
447+ .readby > :global(* + *) { margin-left: -4px; }
448+ .readby :global(.mxc-avatar), .readby :global(.mxc-fallback) { box-shadow: 0 0 0 1.5px var(--bg); }
449+ .readby .more { margin-left: 3px; font-size: 10.5px; color: var(--muted); }
393450 .actions {
394451 position: absolute; top: -14px; right: 12px; z-index: 2;
395452 display: flex; gap: 2px;
@@ -410,7 +467,7 @@
410467 font-size: 14px;
411468 padding: 1px 5px;
412469 }
413- .actions button:hover:not(:disabled) { border-color: var(--border); background: rgba(255, 255, 255, 0.06); }
470+ .actions button:hover:not(:disabled) { border-color: var(--border); background: var(--hover); }
414471 .actions button:disabled { opacity: 0.4; cursor: default; }
415472 .emote { width: 1.3em; height: 1.3em; object-fit: contain; vertical-align: middle; }
416473 .picker-pop { position: fixed; z-index: 60; width: 320px; max-width: calc(100vw - 16px); }
Added file: web/src/lib/MessageText.svelte+35 additions
@@ -0,0 +1,35 @@
1+<script lang="ts">
2+ import EmojiText from './EmojiText.svelte';
3+ import { hasCode, parseCode } from './code';
4+
5+ export let text: string;
6+ export let jumbo = false;
7+
8+ $: segments = parseCode(text);
9+ $: plain = !hasCode(segments);
10+</script>
11+
12+{#if plain}<EmojiText {text} {jumbo} />{:else}{#each segments as s}{#if s.kind === 'inline'}<code class="inline">{s.text}</code>{:else if s.kind === 'block'}<pre class="block"><code>{s.text}</code></pre>{:else}<EmojiText text={s.text} />{/if}{/each}{/if}
13+
14+<style>
15+ code {
16+ font-family: ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, monospace;
17+ font-size: 0.9em;
18+ }
19+ .inline {
20+ padding: 1px 5px;
21+ border-radius: 4px;
22+ background: var(--bg-soft);
23+ border: 1px solid var(--border);
24+ }
25+ .block {
26+ margin: 4px 0;
27+ padding: 8px 10px;
28+ border-radius: 6px;
29+ background: var(--bg-soft);
30+ border: 1px solid var(--border);
31+ overflow-x: auto;
32+ white-space: pre;
33+ line-height: 1.4;
34+ }
35+</style>
Modified file: web/src/lib/ReactionPicker.svelte+13 additionsโˆ’8 deletions
@@ -12,16 +12,17 @@
1212 export let onPick: (key: string, shortcode: string | null) => void;
1313 export let onQuickChange: (list: QuickReaction[]) => void;
1414 export let onClose: () => void;
15+ export let embedded = false;
1516
1617 let root: HTMLDivElement;
1718 let input: HTMLInputElement;
1819 let query = '';
19- let customising = false;
20+ let customising = embedded;
2021 let groups: EmojiGroup[] | null = null;
2122 let loadFailed = false;
2223
2324 onMount(() => {
24- input.focus();
25+ if (!embedded) input.focus();
2526 loadEmoji().then(
2627 (g) => (groups = g),
2728 () => (loadFailed = true)
@@ -69,7 +70,7 @@
6970 }
7071
7172 function closeOutside(e: PointerEvent): void {
72- if (!root.contains(e.target as Node)) onClose();
73+ if (!embedded && !root.contains(e.target as Node)) onClose();
7374 }
7475 </script>
7576
@@ -77,6 +78,7 @@
7778
7879 <div
7980 class="picker"
81+ class:embedded
8082 role="dialog"
8183 tabindex="-1"
8284 aria-label={customising ? 'Customise quick reactions' : 'Pick a reaction'}
@@ -182,13 +184,16 @@
182184 {#if customising}
183185 <span class="muted">{full ? 'Quick reactions are full. Remove one to add another.' : 'Select emoji to add or remove them.'}</span>
184186 {/if}
185- <button type="button" class="mode" aria-pressed={customising} on:click={() => (customising = !customising)}>
186- {customising ? 'Done' : 'Customise quick reactions'}
187- </button>
187+ {#if !embedded}
188+ <button type="button" class="mode" aria-pressed={customising} on:click={() => (customising = !customising)}>
189+ {customising ? 'Done' : 'Customise quick reactions'}
190+ </button>
191+ {/if}
188192 </div>
189193 </div>
190194
191195 <style>
196+ .picker.embedded { width: 100%; height: 340px; box-shadow: none; }
192197 .picker {
193198 outline: none;
194199 display: flex; flex-direction: column; gap: 6px;
@@ -237,8 +242,8 @@
237242 font-size: 20px; cursor: pointer; color: var(--text);
238243 }
239244 .cell img { width: 24px; height: 24px; object-fit: contain; }
240- .cell:hover:not(:disabled), .cell:focus-visible { background: rgba(255, 255, 255, 0.08); border-color: var(--border); outline: none; }
241- .cell.chosen { border-color: var(--accent); background: rgba(88, 101, 242, 0.16); }
245+ .cell:hover:not(:disabled), .cell:focus-visible { background: var(--hover); border-color: var(--border); outline: none; }
246+ .cell.chosen { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
242247 .cell:disabled { opacity: 0.3; cursor: default; }
243248 .status { color: var(--muted); font-size: 13px; text-align: center; margin: 16px 0; }
244249 .foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
Modified file: web/src/lib/SecurityPanel.svelte+2 additionsโˆ’2 deletions
@@ -219,8 +219,8 @@
219219 .sec-line { font-size: 13px; margin: 0 0 8px; overflow-wrap: anywhere; }
220220 .sec-line.muted, .sec-note { color: var(--muted); font-size: 12.5px; }
221221 .sec-note { margin: 8px 0 0; }
222- .pill.ok { background: rgba(63, 185, 127, 0.18); border-color: #3fb97f; color: #9fe3c3; }
223- .pill.warn { background: rgba(232, 178, 90, 0.15); border-color: var(--warn); color: var(--warn); }
222+ .pill.ok { background: color-mix(in srgb, var(--ok) 18%, transparent); border-color: var(--ok); color: var(--ok-text); }
223+ .pill.warn { background: color-mix(in srgb, var(--warn) 15%, transparent); border-color: var(--warn); color: var(--warn); }
224224 code { font-size: 12px; background: var(--bg-soft); padding: 1px 6px; border-radius: 6px; }
225225 .check { display: flex; align-items: center; gap: 8px; font-size: 13px; }
226226 .row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
Added file: web/src/lib/SettingsPanel.svelte+156 additions
@@ -0,0 +1,156 @@
1+<script lang="ts">
2+ import { createEventDispatcher } from 'svelte';
3+ import {
4+ LINE_HEIGHT_DEFAULT,
5+ LINE_HEIGHT_MAX,
6+ LINE_HEIGHT_MIN,
7+ MESSAGE_SPACING_DEFAULT,
8+ MESSAGE_SPACING_MAX,
9+ MESSAGE_SPACING_MIN,
10+ THEMES,
11+ accent,
12+ lineHeight,
13+ messageSpacing,
14+ theme
15+ } from './appearance';
16+ import { DEFAULT_QUICK_REACTIONS } from './quick-reactions';
17+ import ReactionPicker from './ReactionPicker.svelte';
18+ import type { ImagePack, QuickReaction } from './types';
19+
20+ export let quick: QuickReaction[];
21+ export let packs: ImagePack[] = [];
22+ export let onQuickChange: (list: QuickReaction[]) => void;
23+
24+ const dispatch = createEventDispatcher<{ close: void }>();
25+
26+ $: quickIsDefault =
27+ quick.length === DEFAULT_QUICK_REACTIONS.length && quick.every((r, i) => r.key === DEFAULT_QUICK_REACTIONS[i].key);
28+
29+ function themeAccent(): string {
30+ return getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#5865f2';
31+ }
32+</script>
33+
34+<div class="modal settings" role="dialog" aria-modal="true" aria-label="Settings">
35+ <div class="modal-tabs" role="tablist" aria-label="Settings">
36+ <button type="button" role="tab" aria-selected="true" class="active">Settings</button>
37+ <button type="button" class="icon-btn modal-x" aria-label="Close settings" on:click={() => dispatch('close')}>
38+ <span aria-hidden="true">โœ•</span>
39+ </button>
40+ </div>
41+
42+ <section aria-label="Theme">
43+ <h3 class="sec-h">Theme</h3>
44+ <div class="themes" role="radiogroup" aria-label="Theme">
45+ {#each THEMES as t (t.id)}
46+ <button
47+ type="button"
48+ role="radio"
49+ aria-checked={$theme === t.id}
50+ class="theme-opt"
51+ class:active={$theme === t.id}
52+ data-swatch={t.id}
53+ on:click={() => theme.set(t.id)}
54+ >
55+ <span class="swatch" aria-hidden="true"></span>
56+ {t.label}
57+ </button>
58+ {/each}
59+ </div>
60+ <div class="range accent-row">
61+ <label for="accent-colour">Accent colour</label>
62+ <input id="accent-colour" type="color" value={$accent ?? themeAccent()} on:input={(e) => accent.set(e.currentTarget.value)} />
63+ <button type="button" class="ghost-btn" disabled={$accent === null} on:click={() => accent.set(null)}>Use theme colour</button>
64+ </div>
65+ </section>
66+
67+ <section aria-label="Message spacing">
68+ <h3 class="sec-h">Space between messages</h3>
69+ <div class="range">
70+ <input
71+ type="range"
72+ min={MESSAGE_SPACING_MIN}
73+ max={MESSAGE_SPACING_MAX}
74+ step="1"
75+ bind:value={$messageSpacing}
76+ aria-label="Space between messages in pixels"
77+ />
78+ <span class="value">{$messageSpacing}px</span>
79+ <button
80+ type="button"
81+ class="ghost-btn"
82+ disabled={$messageSpacing === MESSAGE_SPACING_DEFAULT}
83+ on:click={() => messageSpacing.set(MESSAGE_SPACING_DEFAULT)}
84+ >
85+ Reset
86+ </button>
87+ </div>
88+ <h3 class="sec-h">Line height</h3>
89+ <div class="range">
90+ <input
91+ type="range"
92+ min={LINE_HEIGHT_MIN}
93+ max={LINE_HEIGHT_MAX}
94+ step="0.05"
95+ bind:value={$lineHeight}
96+ aria-label="Line height within messages"
97+ />
98+ <span class="value">{$lineHeight.toFixed(2)}</span>
99+ <button
100+ type="button"
101+ class="ghost-btn"
102+ disabled={$lineHeight === LINE_HEIGHT_DEFAULT}
103+ on:click={() => lineHeight.set(LINE_HEIGHT_DEFAULT)}
104+ >
105+ Reset
106+ </button>
107+ </div>
108+ <p class="sec-note">Theme and spacing are saved on this device only.</p>
109+ </section>
110+
111+ <section aria-label="Quick reactions">
112+ <div class="sec-head">
113+ <h3 class="sec-h">Quick reactions</h3>
114+ <button type="button" class="ghost-btn" disabled={quickIsDefault} on:click={() => onQuickChange(DEFAULT_QUICK_REACTIONS)}>
115+ Reset
116+ </button>
117+ </div>
118+ <ReactionPicker embedded {quick} {packs} {onQuickChange} onPick={() => {}} onClose={() => dispatch('close')} />
119+ <p class="sec-note">Synced to your account. Emoji from this room's packs can be added too.</p>
120+ </section>
121+</div>
122+
123+<style>
124+ .settings { max-height: calc(100dvh - 32px); overflow-y: auto; }
125+ section + section { margin-top: 18px; }
126+ .sec-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
127+ .sec-head .sec-h { margin: 0; }
128+ .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 6px; }
129+ .range + .sec-h { margin-top: 12px; }
130+ .sec-note { color: var(--muted); font-size: 12.5px; margin: 8px 0 0; }
131+ .range { display: flex; align-items: center; gap: 10px; }
132+ .range input[type='range'] { flex: 1; accent-color: var(--accent); }
133+ .value { font-size: 13px; min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
134+ .themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 6px; }
135+ .theme-opt {
136+ display: flex; align-items: center; gap: 8px;
137+ padding: 6px 8px;
138+ background: var(--bg-soft);
139+ border: 1px solid var(--border);
140+ border-radius: 8px;
141+ color: var(--text);
142+ font-size: 13px;
143+ cursor: pointer;
144+ text-align: left;
145+ }
146+ .theme-opt.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
147+ .swatch { flex: none; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border); }
148+ [data-swatch='system'] .swatch { background: linear-gradient(135deg, #14151c 50%, #f7f7fa 50%); }
149+ [data-swatch='dark'] .swatch { background: #14151c; }
150+ [data-swatch='light'] .swatch { background: #f7f7fa; }
151+ [data-swatch='black'] .swatch { background: #000; }
152+ [data-swatch='nord'] .swatch { background: #2e3440; }
153+ .accent-row { margin-top: 10px; font-size: 13px; }
154+ .accent-row label { flex: 1; }
155+ .accent-row input[type='color'] { width: 36px; height: 26px; padding: 0; border: 1px solid var(--border); border-radius: 6px; background: none; cursor: pointer; }
156+</style>
Modified file: web/src/lib/api.ts+3 additionsโˆ’1 deletions
@@ -1,6 +1,7 @@
11 // Backend-agnostic API surface. Every call forwards to whichever transport
22 // this build was compiled for, hosted (fetch + SSE) or on-device (worker).
33 import type { MemberSummary, RoomSummary } from './types';
4+import { codeToHtml } from './code';
45 import { HttpTransport } from './transport-http';
56 import type { SessionInfo, Transport, VerifyAction } from './transport';
67 import { createDeviceTransport } from 'malice:device-transport';
@@ -33,7 +34,8 @@ export function getRooms(): Promise<{ rooms: RoomSummary[] }> {
3334
3435 export const openRoom = (room_id: string) => transport.openRoom(room_id);
3536 export const closeRoom = (room_id: string) => transport.closeRoom(room_id);
36-export const sendMessage = (room_id: string, body: string) => transport.sendMessage(room_id, body);
37+export const sendMessage = (room_id: string, body: string, reply_to: string | null = null) =>
38+ transport.sendMessage(room_id, body, codeToHtml(body), reply_to);
3739 export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);
3840 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
3941 export const getAccountData = (eventType: string) => transport.getAccountData(eventType);
Added file: web/src/lib/appearance.ts+73 additions
@@ -0,0 +1,73 @@
1+import { writable, type Writable } from 'svelte/store';
2+
3+export const MESSAGE_SPACING_MIN = 0;
4+export const MESSAGE_SPACING_MAX = 16;
5+export const MESSAGE_SPACING_DEFAULT = 3;
6+
7+function persisted<T>(key: string, fallback: T, valid: (v: unknown) => v is T): Writable<T> {
8+ let initial = fallback;
9+ try {
10+ const raw = localStorage.getItem(key);
11+ if (raw !== null) {
12+ const parsed: unknown = JSON.parse(raw);
13+ if (valid(parsed)) initial = parsed;
14+ }
15+ } catch {}
16+ const store = writable(initial);
17+ store.subscribe((value) => {
18+ try {
19+ localStorage.setItem(key, JSON.stringify(value));
20+ } catch {}
21+ });
22+ return store;
23+}
24+
25+const isBool = (v: unknown): v is boolean => typeof v === 'boolean';
26+const isSpacing = (v: unknown): v is number =>
27+ typeof v === 'number' && Number.isFinite(v) && v >= MESSAGE_SPACING_MIN && v <= MESSAGE_SPACING_MAX;
28+
29+export const navFolded = persisted('malice.navFolded', false, isBool);
30+export const messageSpacing = persisted('malice.messageSpacing', MESSAGE_SPACING_DEFAULT, isSpacing);
31+
32+export const LINE_HEIGHT_MIN = 1.1;
33+export const LINE_HEIGHT_MAX = 2;
34+export const LINE_HEIGHT_DEFAULT = 1.5;
35+
36+const isLineHeight = (v: unknown): v is number =>
37+ typeof v === 'number' && Number.isFinite(v) && v >= LINE_HEIGHT_MIN && v <= LINE_HEIGHT_MAX;
38+
39+export const lineHeight = persisted('malice.lineHeight', LINE_HEIGHT_DEFAULT, isLineHeight);
40+
41+export const THEMES = [
42+ { id: 'system', label: 'Match system' },
43+ { id: 'dark', label: 'Dark' },
44+ { id: 'light', label: 'Light' },
45+ { id: 'black', label: 'Black' },
46+ { id: 'nord', label: 'Nord' }
47+] as const;
48+
49+export type ThemeId = (typeof THEMES)[number]['id'];
50+
51+const isTheme = (v: unknown): v is ThemeId => THEMES.some((t) => t.id === v);
52+const isAccent = (v: unknown): v is string | null => v === null || (typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v));
53+
54+export const theme = persisted<ThemeId>('malice.theme', 'dark', isTheme);
55+export const accent = persisted<string | null>('malice.accent', null, isAccent);
56+
57+if (typeof document !== 'undefined') {
58+ const root = document.documentElement;
59+ const prefersLight = window.matchMedia('(prefers-color-scheme: light)');
60+ let current: ThemeId = 'system';
61+ const apply = () => {
62+ root.dataset.theme = current === 'system' ? (prefersLight.matches ? 'light' : 'dark') : current;
63+ };
64+ theme.subscribe((t) => {
65+ current = t;
66+ apply();
67+ });
68+ prefersLight.addEventListener('change', apply);
69+ accent.subscribe((a) => {
70+ if (a) root.style.setProperty('--accent', a);
71+ else root.style.removeProperty('--accent');
72+ });
73+}
Added file: web/src/lib/code.ts+58 additions
@@ -0,0 +1,58 @@
1+export type CodeSegment =
2+ | { kind: 'text'; text: string }
3+ | { kind: 'inline'; text: string }
4+ | { kind: 'block'; text: string; lang: string | null };
5+
6+const FENCE = /```([^\s`]*)[^\S\n]*\n?([\s\S]*?)\n?```/g;
7+const INLINE = /`([^`\n]+)`/g;
8+
9+function splitInline(text: string, out: CodeSegment[]): void {
10+ let last = 0;
11+ for (const m of text.matchAll(INLINE)) {
12+ if (m.index > last) out.push({ kind: 'text', text: text.slice(last, m.index) });
13+ out.push({ kind: 'inline', text: m[1] });
14+ last = m.index + m[0].length;
15+ }
16+ if (last < text.length) out.push({ kind: 'text', text: text.slice(last) });
17+}
18+
19+export function parseCode(text: string): CodeSegment[] {
20+ const out: CodeSegment[] = [];
21+ if (!text.includes('`')) {
22+ if (text) out.push({ kind: 'text', text });
23+ return out;
24+ }
25+ let last = 0;
26+ for (const m of text.matchAll(FENCE)) {
27+ splitInline(text.slice(last, m.index), out);
28+ out.push({ kind: 'block', text: m[2], lang: m[1] || null });
29+ last = m.index + m[0].length;
30+ if (text[last] === '\n') last += 1;
31+ }
32+ splitInline(text.slice(last), out);
33+ return out;
34+}
35+
36+export function hasCode(segments: CodeSegment[]): boolean {
37+ return segments.some((s) => s.kind !== 'text');
38+}
39+
40+function escapeHtml(text: string): string {
41+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
42+}
43+
44+/** Matrix `formatted_body` for a plain body, or null when it has no code to format. */
45+export function codeToHtml(body: string): string | null {
46+ const segments = parseCode(body);
47+ if (!hasCode(segments)) return null;
48+ return segments
49+ .map((s) => {
50+ if (s.kind === 'inline') return `<code>${escapeHtml(s.text)}</code>`;
51+ if (s.kind === 'block') {
52+ const cls = s.lang ? ` class="language-${escapeHtml(s.lang)}"` : '';
53+ return `<pre><code${cls}>${escapeHtml(s.text)}</code></pre>`;
54+ }
55+ return escapeHtml(s.text).replace(/\n/g, '<br>');
56+ })
57+ .join('');
58+}
Modified file: web/src/lib/pending.ts+6 additionsโˆ’4 deletions
@@ -1,11 +1,12 @@
11 import { get, writable } from 'svelte/store';
22 import { sendMessage } from './api';
33 import { timelines } from './store';
4-import type { TimelineItem } from './types';
4+import type { ReplyReference, TimelineItem } from './types';
55
66 export interface PendingMessage {
77 id: string;
88 body: string;
9+ replyTo: ReplyReference | null;
910 status: 'sending' | 'failed';
1011 error: string | null;
1112 createdAt: number;
@@ -37,7 +38,7 @@ function ownIdentities(items: TimelineItem[]): Set<string> {
3738 }
3839
3940 function toPublic(record: PendingRecord): PendingMessage {
40- return { id: record.id, body: record.body, status: record.status, error: record.error, createdAt: record.createdAt };
41+ return { id: record.id, body: record.body, replyTo: record.replyTo, status: record.status, error: record.error, createdAt: record.createdAt };
4142 }
4243
4344 function roomList(roomId: string): PendingMessage[] {
@@ -71,7 +72,7 @@ async function attemptSend(record: PendingRecord): Promise<void> {
7172 if (!records.has(record.id)) return;
7273 updateRecord(record.id, { status: 'sending', error: null });
7374 try {
74- await sendMessage(record.roomId, record.body);
75+ await sendMessage(record.roomId, record.body, record.replyTo?.event_id ?? null);
7576 } catch (e) {
7677 updateRecord(record.id, { status: 'failed', error: e instanceof Error ? e.message : 'Message could not be sent' });
7778 }
@@ -83,11 +84,12 @@ function enqueue(record: PendingRecord): void {
8384 queues.set(record.roomId, next.catch(() => undefined));
8485 }
8586
86-export function queueSend(roomId: string, body: string): void {
87+export function queueSend(roomId: string, body: string, replyTo: ReplyReference | null = null): void {
8788 const record: PendingRecord = {
8889 id: nextId(),
8990 roomId,
9091 body,
92+ replyTo,
9193 status: 'sending',
9294 error: null,
9395 createdAt: Date.now(),
Modified file: web/src/lib/quick-reactions.ts+8 additionsโˆ’2 deletions
@@ -1,4 +1,4 @@
1-import { writable } from 'svelte/store';
1+import { get, writable } from 'svelte/store';
22 import { getAccountData, setAccountData } from './api';
33 import { MAX_QUICK_REACTIONS } from './types';
44 import type { QuickReaction } from './types';
@@ -42,8 +42,14 @@ export async function loadQuickReactions(): Promise<void> {
4242
4343 export async function saveQuickReactions(list: QuickReaction[]): Promise<void> {
4444 const reactions = list.slice(0, MAX_QUICK_REACTIONS);
45- await setAccountData(EVENT_TYPE, { reactions });
45+ const previous = get(quickReactions);
4646 quickReactions.set(reactions);
47+ try {
48+ await setAccountData(EVENT_TYPE, { reactions });
49+ } catch (e) {
50+ if (get(quickReactions) === reactions) quickReactions.set(previous);
51+ throw e;
52+ }
4753 }
4854
4955 export function resetQuickReactions(): void {
Modified file: web/src/lib/transport-http.ts+3 additionsโˆ’3 deletions
@@ -72,7 +72,7 @@ export class HttpTransport implements Transport {
7272
7373 private async roomCommand(
7474 path: (typeof ROOM_COMMAND_PATHS)[keyof typeof ROOM_COMMAND_PATHS],
75- payload: Record<string, string | boolean>
75+ payload: Record<string, string | boolean | null>
7676 ): Promise<void> {
7777 const res = await fetch(path, {
7878 method: 'POST',
@@ -89,8 +89,8 @@ export class HttpTransport implements Transport {
8989 closeRoom(room_id: string): Promise<void> {
9090 return this.roomCommand(ROOM_COMMAND_PATHS.close, { room_id });
9191 }
92- sendMessage(room_id: string, body: string): Promise<void> {
93- return this.roomCommand(ROOM_COMMAND_PATHS.send, { room_id, body });
92+ sendMessage(room_id: string, body: string, formatted_body: string | null, reply_to: string | null): Promise<void> {
93+ return this.roomCommand(ROOM_COMMAND_PATHS.send, { room_id, body, formatted_body, reply_to });
9494 }
9595 paginateRoom(room_id: string): Promise<void> {
9696 return this.roomCommand(ROOM_COMMAND_PATHS.paginate, { room_id });
Modified file: web/src/lib/transport-worker.ts+2 additionsโˆ’2 deletions
@@ -215,8 +215,8 @@ export class WorkerTransport implements Transport {
215215 closeRoom(room_id: string): Promise<void> {
216216 return this.client.call('close', { room_id });
217217 }
218- sendMessage(room_id: string, body: string): Promise<void> {
219- return this.client.call('send', { room_id, body });
218+ sendMessage(room_id: string, body: string, formatted_body: string | null, reply_to: string | null): Promise<void> {
219+ return this.client.call('send', { room_id, body, formatted_body, reply_to });
220220 }
221221 paginateRoom(room_id: string): Promise<void> {
222222 return this.client.call('paginate', { room_id });
Modified file: web/src/lib/transport.ts+1 additionsโˆ’1 deletions
@@ -35,7 +35,7 @@ export interface Transport {
3535 getRooms(): Promise<{ rooms: RoomSummary[] }>;
3636 openRoom(room_id: string): Promise<void>;
3737 closeRoom(room_id: string): Promise<void>;
38- sendMessage(room_id: string, body: string): Promise<void>;
38+ sendMessage(room_id: string, body: string, formatted_body: string | null, reply_to: string | null): Promise<void>;
3939 paginateRoom(room_id: string): Promise<void>;
4040 setTyping(room_id: string, typing: boolean): Promise<void>;
4141 getAccountData(eventType: string): Promise<unknown | null>;
Modified file: web/src/lib/types.ts+16 additions
@@ -383,6 +383,12 @@ export interface Reaction {
383383 image: string | null;
384384 }
385385
386+export interface Person {
387+ user_id: string;
388+ name: string;
389+ avatar: string | null;
390+}
391+
386392 export interface Sender {
387393 user_id: string;
388394 display_name: string | null;
@@ -568,6 +574,16 @@ function parseReplyReference(raw: unknown): ReplyReference | null {
568574 return { event_id: eventId, details: parseReplyDetails(raw['details']) };
569575 }
570576
577+export function contentPreview(content: TimelineContent): string {
578+ if (content.kind === 'Message') {
579+ const media = content.message.media;
580+ return media ? (media.caption ?? media.filename) : content.message.body;
581+ }
582+ if (content.kind === 'Sticker') return content.body || 'Sticker';
583+ if (content.kind === 'Poll') return `Poll: ${content.question}`;
584+ return 'Message';
585+}
586+
571587 export interface TimelineEvent {
572588 item_id: string;
573589 event_id: string | null;
Added file: web/test/code.test.ts+38 additions
@@ -0,0 +1,38 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { codeToHtml, parseCode } from '../src/lib/code.ts';
4+
5+test('plain text has no formatted body', () => {
6+ assert.equal(codeToHtml('hello <b>'), null);
7+});
8+
9+test('splits inline code', () => {
10+ assert.deepEqual(parseCode('run `ls -l` now'), [
11+ { kind: 'text', text: 'run ' },
12+ { kind: 'inline', text: 'ls -l' },
13+ { kind: 'text', text: ' now' }
14+ ]);
15+});
16+
17+test('parses fenced blocks with language', () => {
18+ assert.deepEqual(parseCode('see\n```rust\nfn main() {}\n```\nok'), [
19+ { kind: 'text', text: 'see\n' },
20+ { kind: 'block', text: 'fn main() {}', lang: 'rust' },
21+ { kind: 'text', text: 'ok' }
22+ ]);
23+});
24+
25+test('backticks inside a block stay literal', () => {
26+ assert.deepEqual(parseCode('```\na `b` c\n```'), [{ kind: 'block', text: 'a `b` c', lang: null }]);
27+});
28+
29+test('unclosed backtick is text', () => {
30+ assert.deepEqual(parseCode('a ` b'), [{ kind: 'text', text: 'a ` b' }]);
31+});
32+
33+test('html escapes and keeps line breaks', () => {
34+ assert.equal(
35+ codeToHtml('x<y\n`a&b`\n```js\n<p>\n```'),
36+ 'x&lt;y<br><code>a&amp;b</code><br><pre><code class="language-js">&lt;p&gt;</code></pre>'
37+ );
38+});