Commit df3c0f2b3da4
tarball20 files changed+762-83
@@ -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
handle177
- .send_message(TimelineId::Room(room_id), MessageContent::text(body))201
+ .send_message(TimelineId::Room(room_id), content)178202
.await?;179203
Ok(Reply::Done)180204
}@@ -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,@@ -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
<button680739
type="button"@@ -913,6 +972,15 @@
913972
>914973
<span aria-hidden="true">๐ก</span>915974
</button>975
+ <button976
+ 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
+ <button1040
+ 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
<MessageRow11261208
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
<input11521246
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
+ <SettingsPanel1340
+ 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">@@ -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; }@@ -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;@@ -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:selected193209
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
+ <button245
+ 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
+ <button294
+ 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 unavailable272307
{/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); }@@ -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>@@ -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
<div7980
class="picker"81
+ class:embedded8082
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; }@@ -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; }@@ -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
+ theme15
+ } 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
+ <button47
+ 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
+ <input71
+ 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
+ <button80
+ type="button"81
+ class="ghost-btn"82
+ disabled={$messageSpacing === MESSAGE_SPACING_DEFAULT}83
+ on:click={() => messageSpacing.set(MESSAGE_SPACING_DEFAULT)}84
+ >85
+ Reset86
+ </button>87
+ </div>88
+ <h3 class="sec-h">Line height</h3>89
+ <div class="range">90
+ <input91
+ 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
+ <button100
+ type="button"101
+ class="ghost-btn"102
+ disabled={$lineHeight === LINE_HEIGHT_DEFAULT}103
+ on:click={() => lineHeight.set(LINE_HEIGHT_DEFAULT)}104
+ >105
+ Reset106
+ </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
+ Reset116
+ </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>@@ -1,6 +1,7 @@
11
// Backend-agnostic API surface. Every call forwards to whichever transport22
// 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);@@ -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
+}@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');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 segments49
+ .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
+}@@ -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(),@@ -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 {@@ -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 });@@ -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 });@@ -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>;@@ -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;@@ -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<y<br><code>a&b</code><br><pre><code class="language-js"><p></code></pre>'37
+ );38
+});