atagen

malice

public
Star0
Watch0

Commit 20fe182f3204

tarball

atagen <boss@atagen.co>2026-10-26 05:50 UTC

28 files changed+672-96
web: set up recovery, trace the device worker, and fix timeline edge cases

Recovery setup shows the generated key once and refuses every close path until it's saved, and KeyBackupExists travels as an explicit error kind instead of a 409 guess. The device worker gets a tracing layer enabled by a filter in localStorage, passed through the SharedWorker name because workers can't read localStorage. Rooms whose stored events don't fill the screen now keep paginating, own sends stay pinned when a growing pending row shifts the scroll, and refreshRooms parses summaries like the event path does.
Modified file: .mcp.json+5 additions
@@ -13,6 +13,11 @@
1313 "type": "stdio",
1414 "command": "npx",
1515 "args": ["-y", "chrome-devtools-mcp@latest", "--executablePath", "${CHROMIUM_PATH}"]
16+ },
17+ "chromium-nested": {
18+ "type": "stdio",
19+ "command": "npx",
20+ "args": ["-y", "chrome-devtools-mcp@latest", "--browserUrl", "http://127.0.0.1:9333"]
1621 }
1722 }
1823 }
Modified file: crates/malice-core/src/error.rs+2 additions
@@ -7,6 +7,7 @@ pub enum Failure {
77 Forbidden,
88 NotFound,
99 Conflict,
10+ KeyBackupExists,
1011 Busy,
1112 TooLarge,
1213 Unsupported,
@@ -66,6 +67,7 @@ impl From<macltok::Error> for CoreError {
6667 | ErrorKind::VerificationInactive
6768 | ErrorKind::VerificationUnsupported
6869 | ErrorKind::NoCrossSigningIdentity => Failure::Conflict,
70+ ErrorKind::KeyBackupExists => Failure::KeyBackupExists,
6971 ErrorKind::RecoveryRejected => Failure::BadRequest,
7072 ErrorKind::InteractiveAuthRequired => Failure::Forbidden,
7173 ErrorKind::Unauthenticated
Modified file: crates/malice-core/src/request.rs+55 additions−1 deletions
@@ -2,7 +2,7 @@ use macltok::ruma::{OwnedTransactionId, events::GlobalAccountDataEventType, serd
22 use macltok::{
33 AttachmentUpload, ClientHandle, CreateRoomOptions, ImagePackSource, MediaId, MediaSize,
44 MemberFilter, MentionKey, MentionsBoundary, MessageContent, MessageEditContent, MirrorHandle,
5- PackImageInfo, PackUsage, Patch, RichText, SearchScope, TimelineId, VerificationAction,
5+ PackImageInfo, PackUsage, Patch, RichText, RoomNotificationMode, SearchScope, TimelineId, VerificationAction,
66 VerificationTarget,
77 };
88 use serde::Deserialize;
@@ -253,6 +253,18 @@ pub enum Request {
253253 RecoverKeyBackup {
254254 recovery_key: String,
255255 },
256+ EnableRecovery {
257+ #[serde(default)]
258+ passphrase: Option<String>,
259+ },
260+ ResetRecoveryKey {
261+ #[serde(default)]
262+ passphrase: Option<String>,
263+ },
264+ DisableRecovery,
265+ VerifyRecoveryKey {
266+ recovery_key: String,
267+ },
256268 StartVerification,
257269 Verify {
258270 action: String,
@@ -370,6 +382,11 @@ pub enum Request {
370382 user_id: String,
371383 muted: bool,
372384 },
385+ SetRoomNotificationMode {
386+ room_id: String,
387+ #[serde(default)]
388+ mode: Option<RoomNotificationMode>,
389+ },
373390 NotificationKeywords,
374391 AddNotificationKeyword {
375392 keyword: String,
@@ -565,6 +582,10 @@ pub enum Reply {
565582 },
566583 }
567584
585+fn non_empty(passphrase: Option<String>) -> Option<String> {
586+ passphrase.filter(|value| !value.trim().is_empty())
587+}
588+
568589 pub async fn dispatch(
569590 handle: &ClientHandle,
570591 mirror: &MirrorHandle,
@@ -953,6 +974,25 @@ pub async fn dispatch(
953974 handle.recover_key_backup(recovery_key).await?;
954975 Ok(Reply::Done)
955976 }
977+ Request::EnableRecovery { passphrase } => {
978+ let key = handle.enable_recovery(non_empty(passphrase)).await?;
979+ Ok(Reply::Json(json!({ "recovery_key": key.as_str() })))
980+ }
981+ Request::ResetRecoveryKey { passphrase } => {
982+ let key = handle.reset_recovery_key(non_empty(passphrase)).await?;
983+ Ok(Reply::Json(json!({ "recovery_key": key.as_str() })))
984+ }
985+ Request::DisableRecovery => {
986+ handle.disable_recovery().await?;
987+ Ok(Reply::Done)
988+ }
989+ Request::VerifyRecoveryKey { recovery_key } => {
990+ if recovery_key.trim().is_empty() {
991+ return Err(CoreError::bad_request("recovery_key is required"));
992+ }
993+ let valid = handle.verify_recovery_key(recovery_key.trim()).await?;
994+ Ok(Reply::Json(json!({ "valid": valid })))
995+ }
956996 Request::StartVerification => {
957997 handle
958998 .start_verification(VerificationTarget::OwnIdentity)
@@ -1222,6 +1262,14 @@ pub async fn dispatch(
12221262 .await?;
12231263 Ok(Reply::Done)
12241264 }
1265+ Request::SetRoomNotificationMode { room_id, mode } => {
1266+ let room_id = parse_room_id(&room_id)?;
1267+ match mode {
1268+ Some(mode) => handle.set_room_notification_mode(room_id, mode).await?,
1269+ None => handle.reset_room_notification_mode(room_id).await?,
1270+ }
1271+ Ok(Reply::Done)
1272+ }
12251273 Request::NotificationKeywords => Ok(Reply::Json(
12261274 json!({ "keywords": handle.notification_keywords().await }),
12271275 )),
@@ -1521,6 +1569,12 @@ mod tests {
15211569 let recover: Request =
15221570 serde_json::from_str(r#"{"op":"recover_key_backup","recovery_key":"k"}"#).unwrap();
15231571 assert!(matches!(recover, Request::RecoverKeyBackup { .. }));
1572+ let enable: Request = serde_json::from_str(r#"{"op":"enable_recovery"}"#).unwrap();
1573+ assert!(matches!(
1574+ enable,
1575+ Request::EnableRecovery { passphrase: None }
1576+ ));
1577+ assert_eq!(non_empty(Some(" ".into())), None);
15241578 }
15251579
15261580 #[test]
Modified file: crates/malice-server/src/error.rs+10 additions−3 deletions
@@ -12,6 +12,7 @@ use crate::csrf::clear_cookie_value;
1212 pub struct ApiError {
1313 status: StatusCode,
1414 message: String,
15+ kind: Option<&'static str>,
1516 /// When set, the response clears the session cookie (stale credential).
1617 clear_session: Option<bool>,
1718 }
@@ -21,6 +22,7 @@ impl ApiError {
2122 Self {
2223 status,
2324 message: message.into(),
25+ kind: None,
2426 clear_session: None,
2527 }
2628 }
@@ -38,6 +40,7 @@ impl ApiError {
3840 Self {
3941 status: StatusCode::UNAUTHORIZED,
4042 message: message.into(),
43+ kind: None,
4144 clear_session: Some(cookie_secure),
4245 }
4346 }
@@ -61,14 +64,18 @@ impl From<CoreError> for ApiError {
6164 Failure::Unauthorized => StatusCode::UNAUTHORIZED,
6265 Failure::Forbidden => StatusCode::FORBIDDEN,
6366 Failure::NotFound => StatusCode::NOT_FOUND,
64- Failure::Conflict => StatusCode::CONFLICT,
67+ Failure::Conflict | Failure::KeyBackupExists => StatusCode::CONFLICT,
6568 Failure::Busy => StatusCode::TOO_MANY_REQUESTS,
6669 Failure::TooLarge => StatusCode::PAYLOAD_TOO_LARGE,
6770 Failure::Unsupported => StatusCode::UNSUPPORTED_MEDIA_TYPE,
6871 Failure::EngineStopped => return Self::engine_stopped(),
6972 Failure::Upstream => StatusCode::BAD_GATEWAY,
7073 };
71- Self::new(status, error.message)
74+ let mut api = Self::new(status, error.message);
75+ if error.kind == Failure::KeyBackupExists {
76+ api.kind = Some("key_backup_exists");
77+ }
78+ api
7279 }
7380 }
7481
@@ -83,7 +90,7 @@ impl IntoResponse for ApiError {
8390 (
8491 self.status,
8592 headers,
86- axum::Json(json!({ "message": self.message })),
93+ axum::Json(json!({ "message": self.message, "kind": self.kind })),
8794 )
8895 .into_response()
8996 }
Modified file: crates/malice-server/src/routes.rs+5 additions
@@ -570,6 +570,10 @@ async fn post_command(
570570 | Request::EnableKeyBackup
571571 | Request::DisableKeyBackup { .. }
572572 | Request::RecoverKeyBackup { .. }
573+ | Request::EnableRecovery { .. }
574+ | Request::ResetRecoveryKey { .. }
575+ | Request::DisableRecovery
576+ | Request::VerifyRecoveryKey { .. }
573577 | Request::RetryRecentHistory { .. }
574578 | Request::EventContext { .. }
575579 | Request::EventContextBefore { .. }
@@ -599,6 +603,7 @@ async fn post_command(
599603 | Request::SetRoomDisplayName { .. }
600604 | Request::SetRoomAvatar { .. }
601605 | Request::Mute { .. }
606+ | Request::SetRoomNotificationMode { .. }
602607 | Request::NotificationKeywords
603608 | Request::AddNotificationKeyword { .. }
604609 | Request::RemoveNotificationKeyword { .. }
Modified file: crates/malice-worker/Cargo.lock+11 additions−9 deletions
@@ -1999,7 +1999,7 @@ checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
19991999 [[package]]
20002000 name = "macltok"
20012001 version = "0.1.0"
2002-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=417da9e719bcfbb8b60e4ff4f1a09f3d6b0d0160#417da9e719bcfbb8b60e4ff4f1a09f3d6b0d0160"
2002+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=419c65426a62191a26b47d07a0e0661e047317da#419c65426a62191a26b47d07a0e0661e047317da"
20032003 dependencies = [
20042004 "futures-util",
20052005 "getrandom 0.2.17",
@@ -2153,6 +2153,8 @@ dependencies = [
21532153 "matrix-sdk",
21542154 "ruma",
21552155 "serde_json",
2156+ "tracing",
2157+ "tracing-subscriber",
21562158 "wasm-bindgen",
21572159 "wasm-bindgen-futures",
21582160 "wasm-bindgen-test",
@@ -2210,7 +2212,7 @@ dependencies = [
22102212 [[package]]
22112213 name = "matrix-sdk"
22122214 version = "0.18.0"
2213-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
2215+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=0f2f982989765b79c39efdc30e86eaafccf69e4a#0f2f982989765b79c39efdc30e86eaafccf69e4a"
22142216 dependencies = [
22152217 "anymap2",
22162218 "aquamarine",
@@ -2270,7 +2272,7 @@ dependencies = [
22702272 [[package]]
22712273 name = "matrix-sdk-base"
22722274 version = "0.18.0"
2273-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
2275+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=0f2f982989765b79c39efdc30e86eaafccf69e4a#0f2f982989765b79c39efdc30e86eaafccf69e4a"
22742276 dependencies = [
22752277 "as_variant",
22762278 "async-trait",
@@ -2296,7 +2298,7 @@ dependencies = [
22962298 [[package]]
22972299 name = "matrix-sdk-common"
22982300 version = "0.18.0"
2299-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
2301+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=0f2f982989765b79c39efdc30e86eaafccf69e4a#0f2f982989765b79c39efdc30e86eaafccf69e4a"
23002302 dependencies = [
23012303 "eyeball-im",
23022304 "futures-core",
@@ -2319,7 +2321,7 @@ dependencies = [
23192321 [[package]]
23202322 name = "matrix-sdk-crypto"
23212323 version = "0.18.0"
2322-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
2324+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=0f2f982989765b79c39efdc30e86eaafccf69e4a#0f2f982989765b79c39efdc30e86eaafccf69e4a"
23232325 dependencies = [
23242326 "aes",
23252327 "aquamarine",
@@ -2359,7 +2361,7 @@ dependencies = [
23592361 [[package]]
23602362 name = "matrix-sdk-indexeddb"
23612363 version = "0.18.0"
2362-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
2364+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=0f2f982989765b79c39efdc30e86eaafccf69e4a#0f2f982989765b79c39efdc30e86eaafccf69e4a"
23632365 dependencies = [
23642366 "async-trait",
23652367 "base64 0.22.1",
@@ -2391,7 +2393,7 @@ dependencies = [
23912393 [[package]]
23922394 name = "matrix-sdk-sqlite"
23932395 version = "0.18.0"
2394-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
2396+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=0f2f982989765b79c39efdc30e86eaafccf69e4a#0f2f982989765b79c39efdc30e86eaafccf69e4a"
23952397 dependencies = [
23962398 "as_variant",
23972399 "async-trait",
@@ -2418,7 +2420,7 @@ dependencies = [
24182420 [[package]]
24192421 name = "matrix-sdk-store-encryption"
24202422 version = "0.18.0"
2421-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
2423+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=0f2f982989765b79c39efdc30e86eaafccf69e4a#0f2f982989765b79c39efdc30e86eaafccf69e4a"
24222424 dependencies = [
24232425 "base64 0.22.1",
24242426 "blake3",
@@ -2439,7 +2441,7 @@ dependencies = [
24392441 [[package]]
24402442 name = "matrix-sdk-ui"
24412443 version = "0.18.0"
2442-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
2444+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=0f2f982989765b79c39efdc30e86eaafccf69e4a#0f2f982989765b79c39efdc30e86eaafccf69e4a"
24432445 dependencies = [
24442446 "as_variant",
24452447 "async-rx",
Modified file: crates/malice-worker/Cargo.toml+4 additions−2 deletions
@@ -14,7 +14,7 @@ publish = false
1414 crate-type = ["cdylib", "rlib"]
1515
1616 [target.'cfg(target_family = "wasm")'.dependencies]
17-macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "417da9e719bcfbb8b60e4ff4f1a09f3d6b0d0160", features = ["indexeddb", "serde", "mirror"] }
17+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "419c65426a62191a26b47d07a0e0661e047317da", features = ["indexeddb", "serde", "mirror"] }
1818 malice-core = { path = "../malice-core" }
1919 ruma = { version = "0.16", default-features = false }
2020 futures-util = { version = "0.3", default-features = false, features = ["std"] }
@@ -25,9 +25,11 @@ serde_json = "1"
2525 wasm-bindgen = "=0.2.128"
2626 wasm-bindgen-futures = "=0.4.78"
2727 js-sys = "=0.3.105"
28+tracing = "0.1"
29+tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "registry"] }
2830
2931 [dev-dependencies]
30-matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "ec026bc9ea2393fd0cce6212b950d91ab6d36097", default-features = false }
32+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "0f2f982989765b79c39efdc30e86eaafccf69e4a", default-features = false }
3133 wasm-bindgen-test = "0.3"
3234 web-sys = { version = "0.3", features = ["SharedWorkerGlobalScope"] }
3335 gloo-timers = { version = "0.3", features = ["futures"] }
Modified file: crates/malice-worker/src/lib.rs+2 additions−1 deletions
@@ -1,6 +1,7 @@
11 #![cfg(target_family = "wasm")]
22
33 mod account;
4+mod trace;
45 mod wire;
56
67 use std::cell::RefCell;
@@ -151,7 +152,7 @@ impl Host {
151152 .get(&account)
152153 .map(|entry| entry.handle.clone());
153154 if let Some(handle) = handle {
154- let _ = handle.logout().await;
155+ let _ = handle.logout(Vec::new()).await;
155156 }
156157 self.0.remove_account(&account, Retain::Nothing).await;
157158 Ok(())
Added file: crates/malice-worker/src/trace.rs+67 additions
@@ -0,0 +1,67 @@
1+use std::fmt::{self, Write};
2+
3+use tracing::field::{Field, Visit};
4+use tracing::{Event, Level, Subscriber};
5+use tracing_subscriber::filter::Targets;
6+use tracing_subscriber::layer::{Context, SubscriberExt};
7+use tracing_subscriber::util::SubscriberInitExt;
8+use tracing_subscriber::{Layer, Registry};
9+use wasm_bindgen::prelude::*;
10+
11+#[wasm_bindgen]
12+extern "C" {
13+ #[wasm_bindgen(js_namespace = console, js_name = log)]
14+ fn console_log(line: &str);
15+ #[wasm_bindgen(js_namespace = console, js_name = warn)]
16+ fn console_warn(line: &str);
17+ #[wasm_bindgen(js_namespace = console, js_name = error)]
18+ fn console_error(line: &str);
19+}
20+
21+#[derive(Default)]
22+struct Line(String);
23+
24+impl Visit for Line {
25+ fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
26+ if !self.0.is_empty() {
27+ self.0.push(' ');
28+ }
29+ if field.name() == "message" {
30+ let _ = write!(self.0, "{value:?}");
31+ } else {
32+ let _ = write!(self.0, "{}={value:?}", field.name());
33+ }
34+ }
35+}
36+
37+struct ConsoleLayer;
38+
39+impl<S: Subscriber> Layer<S> for ConsoleLayer {
40+ fn on_event(&self, event: &Event<'_>, _: Context<'_, S>) {
41+ let mut line = Line::default();
42+ event.record(&mut line);
43+ let meta = event.metadata();
44+ let text = format!("{} {}: {}", meta.level(), meta.target(), line.0);
45+ match *meta.level() {
46+ Level::ERROR => console_error(&text),
47+ Level::WARN => console_warn(&text),
48+ _ => console_log(&text),
49+ }
50+ }
51+}
52+
53+/// Installs a console subscriber for the given `target=level,...` directives. Blank filters leave tracing off.
54+#[wasm_bindgen]
55+pub fn init_tracing(filter: &str) {
56+ let filter = filter.trim();
57+ if filter.is_empty() {
58+ return;
59+ }
60+ let Ok(targets) = filter.parse::<Targets>() else {
61+ console_warn(&format!("malice.trace ignored, cannot parse {filter:?}"));
62+ return;
63+ };
64+ let _ = Registry::default()
65+ .with(ConsoleLayer.with_filter(targets))
66+ .try_init();
67+}
Modified file: crates/malice-worker/src/wire.rs+1 additions
@@ -18,6 +18,7 @@ pub fn failure_kind(failure: Failure) -> &'static str {
1818 Failure::Forbidden => "forbidden",
1919 Failure::NotFound => "not_found",
2020 Failure::Conflict => "conflict",
21+ Failure::KeyBackupExists => "key_backup_exists",
2122 Failure::Busy => "busy",
2223 Failure::TooLarge => "too_large",
2324 Failure::Unsupported => "unsupported",
Modified file: web/src/App.svelte+158 additions−29 deletions
@@ -3,6 +3,7 @@
33 import { fade, fly } from 'svelte/transition';
44 import LoginPanel from './lib/LoginPanel.svelte';
55 import MentionsInbox from './lib/MentionsInbox.svelte';
6+ import MutedIcon from './lib/MutedIcon.svelte';
67 import MxcAvatar from './lib/MxcAvatar.svelte';
78 import PinButton from './lib/PinButton.svelte';
89 import ThreadPane from './lib/ThreadPane.svelte';
@@ -27,6 +28,7 @@
2728 import { moveBefore, pinnedFirst, sortSpaces } from './lib/rail-order';
2829 import ImageLightbox from './lib/ImageLightbox.svelte';
2930 import { lightbox } from './lib/lightbox';
31+ import { backStack } from './lib/back-stack';
3032 import SecurityPanel from './lib/SecurityPanel.svelte';
3133 import StorageBanner from './lib/StorageBanner.svelte';
3234 import { canDismissRecovery, recoveryPrompt } from './lib/protection-model';
@@ -86,7 +88,8 @@
8688 timelines,
8789 pinnedTimelines,
8890 pinnedTimelineVersions,
89- verification
91+ verification,
92+ setRooms
9093 } from './lib/store';
9194 import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
9295 import type { PendingMessage } from './lib/pending';
@@ -97,6 +100,9 @@
97100 import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
98101 import { loadKeywords } from './lib/keywords';
99102 import { startNotifications } from './lib/notifications';
103+ import { setRoomMode, setSpaceMuted, spaceMutedRooms, startMuteSync } from './lib/mute';
104+ import { isRoomMuted, mutingSpace, type MutedSpaces } from './lib/mute-model';
105+ import { loadMutedSpaces, mutedSpaces } from './lib/muted-spaces';
100106 import {
101107 linkPreviews,
102108 loadUrlPreviewSettings,
@@ -119,6 +125,7 @@
119125 threadWidth
120126 } from './lib/appearance';
121127 import SettingsPanel from './lib/SettingsPanel.svelte';
128+ import { finePointer, longpress, type PressEvent } from './lib/longpress';
122129 import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
123130 import type { ImagePack, MemberSummary, OwnProfile, PackImage, Person, QuickReaction, ReplyReference, RoomSummary, TimelineEvent, TimelineItem } from './lib/types';
124131
@@ -130,6 +137,8 @@
130137 let membersOpen = false;
131138 let replyingTo: ReplyReference | null = null;
132139 let dialog: 'join' | 'create' | 'security' | 'settings' | 'packs' | null = null;
140+ let recoveryKeyShown = false;
141+ $: if (dialog !== 'security') recoveryKeyShown = false;
133142 let cloneSource: CloneSource | null = null;
134143 let joinTarget = '';
135144 let joinBusy = false;
@@ -190,6 +199,7 @@
190199 void loadKeywords();
191200 void loadPinnedThreads();
192201 void loadPackPreferences();
202+ void loadMutedSpaces();
193203 void loadUrlPreviewSettings();
194204 }
195205 wasOnline = online;
@@ -209,13 +219,14 @@
209219 $: orderedRail = sortSpaces(railSpaces, railOverride);
210220 $: pinnedRail = orderedRail.filter((s) => s.favourite);
211221 $: otherRail = orderedRail.filter((s) => !s.favourite);
212- $: unreadSpaces = spacesWithUnread($rooms, spaceParents);
222+ $: unreadSpaces = spacesWithUnread($rooms, spaceParents, $spaceMutedRooms);
213223 $: homeUnread =
214224 ($mentionsSnapshot?.unread_count ?? 0) > 0 ||
215- $rooms.some((r) => r.direct && !r.space && r.membership === 'Joined' && roomUnread(r) > 0);
225+ $rooms.some((r) => r.direct && !r.space && r.membership === 'Joined' && roomUnread(r, $spaceMutedRooms) > 0);
216226 $: railSelected = selectedSpaceId ? topSpaceOf(selectedSpaceId, spaceParents) : null;
217227 $: invites = $rooms.filter((r) => r.membership === 'Invited');
218228 $: query = search.trim().toLowerCase();
229+ $: selectedRoomMute = selectedRoom ? muteItem(selectedRoom, $mutedSpaces, $rooms) : null;
219230 $: selectedSpace = selectedSpaceId ? ($rooms.find((r) => r.room_id === selectedSpaceId) ?? null) : null;
220231 // While a space's children load (or fail), show no channels rather than the
221232 // full list — listing everything would falsely imply it belongs to the space.
@@ -246,8 +257,9 @@
246257 $: hasStart = items.some((i) => i.kind === 'start');
247258 $: pendingItems = $selectedRoomId ? ($pendingByRoom[$selectedRoomId] ?? []) : [];
248259
249- function roomUnread(r: RoomSummary): number {
250- return r.unread_messages + r.unread_mentions;
260+ function roomUnread(r: RoomSummary, spaceMuted: Set<string>): number {
261+ if (isRoomMuted(r, spaceMuted)) return 0;
262+ return r.notification_mode === 'MentionsAndKeywordsOnly' ? r.unread_mentions : r.unread_messages + r.unread_mentions;
251263 }
252264
253265 function recordRoute(spaceId: string | null, roomId: string | null, view?: string): void {
@@ -261,7 +273,7 @@
261273 if (roomId) url.searchParams.set('room', roomId);
262274 const next = `${url.pathname}${url.search}${url.hash}`;
263275 const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
264- if (next !== current) window.history.pushState({ spaceId, roomId }, '', next);
276+ if (next !== current) backStack.route({ spaceId, roomId }, next);
265277 }
266278
267279 async function restoreRoute(): Promise<void> {
@@ -297,7 +309,9 @@
297309 }
298310
299311 onMount(() => {
300- const onPopState = () => void restoreRoute();
312+ const onPopState = (ev: PopStateEvent) => {
313+ if (!backStack.pop(ev.state)) void restoreRoute();
314+ };
301315 const onAttention = () => queueReadReceipt();
302316 const hidePointer = (e: KeyboardEvent) => {
303317 if (e.ctrlKey || e.metaKey || e.altKey || !isEditable(e.target)) return;
@@ -309,12 +323,14 @@
309323 const movePointer = (e: PointerEvent) => {
310324 if (e.movementX !== 0 || e.movementY !== 0) showPointer();
311325 };
326+ backStack.init();
312327 window.addEventListener('popstate', onPopState);
313328 window.addEventListener('focus', onAttention);
314329 document.addEventListener('visibilitychange', onAttention);
315330 window.addEventListener('keydown', hidePointer, true);
316331 window.addEventListener('pointermove', movePointer);
317332 window.addEventListener('pointerdown', showPointer);
333+ const stopMuteSync = startMuteSync();
318334 const stopNotifications = startNotifications((roomId) => {
319335 selectedSpaceId = null;
320336 spaceChildIds = null;
@@ -323,6 +339,7 @@
323339 void boot();
324340 return () => {
325341 stopNotifications();
342+ stopMuteSync();
326343 window.removeEventListener('popstate', onPopState);
327344 window.removeEventListener('focus', onAttention);
328345 document.removeEventListener('visibilitychange', onAttention);
@@ -352,6 +369,7 @@
352369 void loadKeywords();
353370 void loadPinnedThreads();
354371 void loadPackPreferences();
372+ void loadMutedSpaces();
355373 void loadUrlPreviewSettings();
356374 } else {
357375 softLogout.set(session.soft_logout ?? null);
@@ -368,7 +386,7 @@
368386 roomsLoading = true;
369387 try {
370388 const res = await getRooms();
371- rooms.set(res.rooms);
389+ setRooms(res.rooms);
372390 pruneSpaceFilter();
373391 } catch (e) {
374392 pushBanner(e instanceof Error ? e.message : 'Could not load rooms');
@@ -411,6 +429,7 @@
411429 void loadKeywords();
412430 void loadPinnedThreads();
413431 void loadPackPreferences();
432+ void loadMutedSpaces();
414433 void loadUrlPreviewSettings();
415434 }
416435
@@ -476,12 +495,12 @@
476495 return parents;
477496 }
478497
479- function spacesWithUnread(all: RoomSummary[], parents: Map<string, string>): Set<string> {
498+ function spacesWithUnread(all: RoomSummary[], parents: Map<string, string>, spaceMuted: Set<string>): Set<string> {
480499 const spaces = all.filter((r) => r.space);
481500 const ids = new Set(spaces.map((s) => s.room_id));
482501 const out = new Set<string>();
483502 for (const room of all) {
484- if (room.space || room.membership !== 'Joined' || roomUnread(room) === 0) continue;
503+ if (room.space || room.membership !== 'Joined' || roomUnread(room, spaceMuted) === 0) continue;
485504 const owners = [...room.parent_spaces, ...spaces.filter((s) => s.children.includes(room.room_id)).map((s) => s.room_id)];
486505 for (const id of owners) if (ids.has(id)) out.add(topSpaceOf(id, parents));
487506 }
@@ -730,13 +749,18 @@
730749 }
731750
732751 const WINDOW_EDGE = 600;
752+ let lastScrollTop = 0;
733753
734754 function onTimelineScroll(): void {
735755 if (!timelineEl) return;
756+ const top = timelineEl.scrollTop;
757+ const scrolledUp = top < lastScrollTop - 1;
758+ lastScrollTop = top;
736759 if (timelineViewport?.consumeScroll()) return;
737- const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;
760+ const gap = timelineEl.scrollHeight - top - timelineEl.clientHeight;
738761 const wasAtBottom = stickToBottom;
739- stickToBottom = gap < 2 && (timelineRows?.atTail() ?? true);
762+ stickToBottom = (gap < 2 || (wasAtBottom && !scrolledUp)) && (timelineRows?.atTail() ?? true);
763+ if (stickToBottom && gap >= 2) timelineViewport?.scrollToBottom(false);
740764 farFromLatest = gap > 240 || !(timelineRows?.atTail() ?? true);
741765 if (stickToBottom && !wasAtBottom) queueReadReceipt();
742766 timelineViewport?.capture();
@@ -764,13 +788,41 @@
764788
765789 $: void trackTimeline($selectedRoomId, items);
766790 async function trackTimeline(roomId: string | null, list: TimelineItem[]): Promise<void> {
767- if (!roomId || list.length === 0) return;
768- if (roomId !== $selectedRoomId) return;
791+ if (!roomId || roomId !== $selectedRoomId) return;
769792 await tick();
770- if (roomId !== $selectedRoomId || timelineViewport?.roomId !== roomId) return;
771- if (stickToBottom) timelineViewport.scrollToBottom(!motionActive());
772- else timelineViewport.restore();
773- queueReadReceipt();
793+ if (roomId !== $selectedRoomId) return;
794+ if (list.length > 0 && timelineViewport?.roomId === roomId) {
795+ if (stickToBottom) timelineViewport.scrollToBottom(!motionActive());
796+ else timelineViewport.restore();
797+ queueReadReceipt();
798+ }
799+ fillTimeline(roomId);
800+ }
801+
802+ const FILL_PAGES = 20;
803+ let fillRoom: string | null = null;
804+ let fillPages = 0;
805+
806+ function fillTimeline(roomId: string): void {
807+ if (!timelineEl || roomId !== $selectedRoomId || hasStart || paginateBusy) return;
808+ if (timelineEl.scrollHeight - timelineEl.clientHeight > WINDOW_EDGE) return;
809+ if (timelineRows?.grow('up')) {
810+ anchorGrowth();
811+ void tick().then(() => fillTimeline(roomId));
812+ return;
813+ }
814+ if (fillRoom !== roomId) {
815+ fillRoom = roomId;
816+ fillPages = 0;
817+ }
818+ if (fillPages >= FILL_PAGES) return;
819+ fillPages += 1;
820+ const before = ($timelines[roomId] ?? []).length;
821+ void paginate()
822+ .then(() => tick())
823+ .then(() => {
824+ if (($timelines[roomId] ?? []).length > before) fillTimeline(roomId);
825+ });
774826 }
775827
776828 function queueReadReceipt(): void {
@@ -1606,13 +1658,43 @@
16061658 { label: 'Peek', action: () => void selectRoom(room.room_id, true) }
16071659 ];
16081660 items.push({ label: room.favourite ? 'Unpin' : room.space ? 'Pin to top' : 'Pin', action: () => void togglePin(room) });
1661+ const mute = muteItem(room, $mutedSpaces, $rooms);
1662+ if (mute) items.push(mute);
16091663 items.push({ label: 'Copy link', action: () => copyText(`https://matrix.to/#/${room.canonical_alias ?? room.room_id}`) });
16101664 items.push({ label: 'Copy room ID', action: () => copyText(room.room_id) });
16111665 if (room.membership === 'Joined') items.push({ label: 'Leave', danger: true, divider: true, action: () => void leave(room.room_id) });
16121666 return items;
16131667 }
16141668
1615- function openRoomMenu(ev: MouseEvent, room: RoomSummary): void {
1669+ function muteItem(room: RoomSummary, spaces: MutedSpaces, all: RoomSummary[]): MenuItem | null {
1670+ if (room.membership !== 'Joined') return null;
1671+ if (room.space) {
1672+ const muted = spaces.spaces.includes(room.room_id);
1673+ return { label: muted ? 'Unmute space' : 'Mute space', action: () => void changeSpaceMute(room.room_id, !muted) };
1674+ }
1675+ const owner = mutingSpace(spaces, all, room.room_id);
1676+ if (owner) return { label: `Unmute ${roomName(owner)}`, action: () => void changeSpaceMute(owner, false) };
1677+ const muted = room.notification_mode === 'Mute';
1678+ return { label: muted ? 'Unmute' : 'Mute', action: () => void changeRoomMute(room.room_id, !muted) };
1679+ }
1680+
1681+ async function changeRoomMute(roomId: string, muted: boolean): Promise<void> {
1682+ try {
1683+ await setRoomMode(roomId, muted ? 'Mute' : null);
1684+ } catch (e) {
1685+ pushBanner(e instanceof Error ? e.message : 'Could not change notifications');
1686+ }
1687+ }
1688+
1689+ async function changeSpaceMute(spaceId: string, muted: boolean): Promise<void> {
1690+ try {
1691+ await setSpaceMuted(spaceId, muted);
1692+ } catch (e) {
1693+ pushBanner(e instanceof Error ? e.message : 'Could not change notifications');
1694+ }
1695+ }
1696+
1697+ function openRoomMenu(ev: PressEvent, room: RoomSummary): void {
16161698 menu = { items: roomMenuItems(room), x: ev.clientX, y: ev.clientY };
16171699 }
16181700
@@ -1833,11 +1915,39 @@
18331915 if (roomMenuOpen && !roomMenuEl?.contains(ev.target as Node)) roomMenuOpen = false;
18341916 }
18351917
1918+ function dismissLayer(layer: string): void {
1919+ if (layer === 'drawer') sidebarOpen = false;
1920+ else if (layer === 'dialog') {
1921+ if (recoveryKeyShown) backStack.sync('dialog', true, dismissLayer);
1922+ else dialog = null;
1923+ }
1924+ else if (layer === 'clone') cloneSource = null;
1925+ else if (layer === 'user-card') userCard = null;
1926+ else if (layer === 'menu') menu = null;
1927+ else if (layer === 'room-menu') roomMenuOpen = false;
1928+ else if (layer === 'lightbox') lightbox.set(null);
1929+ else if (layer === 'members') membersOpen = false;
1930+ else if (layer === 'thread') void showThread(null);
1931+ else if (layer === 'pins') pinsExpanded = false;
1932+ }
1933+
1934+ $: backStack.sync('drawer', sidebarOpen && viewportWidth <= 640, dismissLayer);
1935+ $: backStack.sync('dialog', dialog !== null, dismissLayer);
1936+ $: backStack.sync('clone', cloneSource !== null, dismissLayer);
1937+ $: backStack.sync('user-card', userCard !== null, dismissLayer);
1938+ $: backStack.sync('menu', menu !== null, dismissLayer);
1939+ $: backStack.sync('room-menu', roomMenuOpen, dismissLayer);
1940+ $: backStack.sync('lightbox', $lightbox !== null, dismissLayer);
1941+ $: backStack.sync('members', membersShown && viewportWidth < 1100, dismissLayer);
1942+ $: backStack.sync('thread', threadShown && viewportWidth < 1100, dismissLayer);
1943+ $: backStack.sync('pins', pinsExpanded, dismissLayer);
1944+
18361945 function onKeydown(ev: KeyboardEvent): void {
18371946 if (ev.key === 'Escape') {
18381947 if (roomMenuOpen) roomMenuOpen = false;
1839- else if (dialog) dialog = null;
1840- else if (membersShown && window.innerWidth < 1100) membersOpen = false;
1948+ else if (dialog) {
1949+ if (!recoveryKeyShown) dialog = null;
1950+ } else if (membersShown && window.innerWidth < 1100) membersOpen = false;
18411951 else if (threadShown && window.innerWidth < 1100) void showThread(null);
18421952 else if (sidebarOpen) sidebarOpen = false;
18431953 }
@@ -2005,6 +2115,9 @@
20052115 </button>
20062116 {#if roomMenuOpen}
20072117 <div class="room-menu" role="menu">
2118+ {#if selectedRoomMute}
2119+ <button type="button" role="menuitem" on:click={() => roomMenuAction(() => selectedRoomMute?.action())}>{selectedRoomMute.label}</button>
2120+ {/if}
20082121 {#if selectedRoom.membership === 'Joined'}
20092122 <button
20102123 type="button"
@@ -2049,12 +2162,14 @@
20492162 class:drop-after={dropTarget?.id === space.room_id && dropTarget.after}
20502163 class:pinned={space.favourite}
20512164 class:unread={unreadSpaces.has(space.room_id)}
2165+ class:muted={$mutedSpaces.spaces.includes(space.room_id)}
20522166 title={space.name}
20532167 aria-label={`Show channels in space ${space.name}`}
20542168 aria-pressed={selectedSpaceId === space.room_id}
2055- draggable="true"
2169+ draggable={$finePointer}
20562170 on:click={() => void selectSpace(space.room_id)}
20572171 on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, space)}
2172+ use:longpress={(ev) => openRoomMenu(ev, space)}
20582173 on:dragstart={(ev) => startRailDrag(ev, space.room_id)}
20592174 on:dragover={(ev) => overRailSpace(ev, space)}
20602175 on:dragleave={() => dropTarget?.id === space.room_id && (dropTarget = null)}
@@ -2181,12 +2296,15 @@
21812296 <button
21822297 type="button"
21832298 class="room-row"
2299+ class:muted={isRoomMuted(room, $spaceMutedRooms)}
21842300 class:active={$selectedRoomId === room.room_id}
21852301 on:click={() => void selectRoom(room.room_id)}
21862302 on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, room)}
2303+ use:longpress={(ev) => openRoomMenu(ev, room)}
21872304 >
21882305 <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
21892306 <span class="room-name"><EmojiText text={room.name} /></span>
2307+ {#if isRoomMuted(room, $spaceMutedRooms)}<MutedIcon />{/if}
21902308 <span
21912309 class="invite-join"
21922310 role="button"
@@ -2216,20 +2334,23 @@
22162334 <button
22172335 type="button"
22182336 class="room-row"
2337+ class:muted={isRoomMuted(room, $spaceMutedRooms)}
22192338 class:active={$selectedRoomId === room.room_id}
22202339 aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
22212340 on:click={() => void selectRoom(room.room_id)}
22222341 on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, room)}
2342+ use:longpress={(ev) => openRoomMenu(ev, room)}
22232343 >
22242344 <MxcAvatar mxc={room.avatar} label={room.name} size={26} />
22252345 <span class="room-name"><EmojiText text={room.name} /></span>
2226- {#if roomUnread(room) > 0}
2346+ {#if isRoomMuted(room, $spaceMutedRooms)}<MutedIcon />{/if}
2347+ {#if roomUnread(room, $spaceMutedRooms) > 0}
22272348 <span
22282349 class="badge"
22292350 class:mention={room.unread_mentions > 0}
2230- aria-label={`${roomUnread(room)} unread`}
2351+ aria-label={`${roomUnread(room, $spaceMutedRooms)} unread`}
22312352 >
2232- {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
2353+ {roomUnread(room, $spaceMutedRooms) > 99 ? '99+' : roomUnread(room, $spaceMutedRooms)}
22332354 </span>
22342355 {/if}
22352356 </button>
@@ -2273,20 +2394,23 @@
22732394 <button
22742395 type="button"
22752396 class="room-row"
2397+ class:muted={isRoomMuted(room, $spaceMutedRooms)}
22762398 class:active={$selectedRoomId === room.room_id}
22772399 aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
22782400 on:click={() => void selectRoom(room.room_id)}
22792401 on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, room)}
2402+ use:longpress={(ev) => openRoomMenu(ev, room)}
22802403 >
22812404 <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
22822405 <span class="room-name"><EmojiText text={room.name} /></span>
2283- {#if roomUnread(room) > 0}
2406+ {#if isRoomMuted(room, $spaceMutedRooms)}<MutedIcon />{/if}
2407+ {#if roomUnread(room, $spaceMutedRooms) > 0}
22842408 <span
22852409 class="badge"
22862410 class:mention={room.unread_mentions > 0}
2287- aria-label={`${roomUnread(room)} unread`}
2411+ aria-label={`${roomUnread(room, $spaceMutedRooms)} unread`}
22882412 >
2289- {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
2413+ {roomUnread(room, $spaceMutedRooms) > 99 ? '99+' : roomUnread(room, $spaceMutedRooms)}
22902414 </span>
22912415 {/if}
22922416 </button>
@@ -2330,13 +2454,16 @@
23302454 <button
23312455 type="button"
23322456 class="room-row"
2457+ class:muted={$mutedSpaces.spaces.includes(room.room_id)}
23332458 class:active={selectedSpaceId === room.room_id}
23342459 aria-label={`Show channels in space ${room.name}`}
23352460 on:click={() => void selectSpace(room.room_id)}
23362461 on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, room)}
2462+ use:longpress={(ev) => openRoomMenu(ev, room)}
23372463 >
23382464 <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
23392465 <span class="room-name"><EmojiText text={room.name} /></span>
2466+ {#if $mutedSpaces.spaces.includes(room.room_id)}<MutedIcon />{/if}
23402467 </button>
23412468 <PinButton pinned={room.favourite} label={room.name} onToggle={() => void togglePin(room)} />
23422469 </li>
@@ -2695,6 +2822,7 @@
26952822 void openUser(member.user_id, rect.left - 308, rect.top, false);
26962823 }}
26972824 on:contextmenu|preventDefault={(ev) => void openUser(member.user_id, ev.clientX, ev.clientY, true)}
2825+ use:longpress={(ev) => void openUser(member.user_id, ev.clientX, ev.clientY, true)}
26982826 >
26992827 <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} seed={member.user_id} size={28} />
27002828 <span class="member-names">
@@ -2719,7 +2847,7 @@
27192847 {/if}
27202848
27212849 {#if dialog}
2722- <div class="modal-wrap" role="presentation" on:click|self={() => (dialog = null)}>
2850+ <div class="modal-wrap" role="presentation" on:click|self={() => (dialog = recoveryKeyShown ? dialog : null)}>
27232851 {#if dialog === 'security'}
27242852 <SecurityPanel
27252853 encryptionState={$encryption}
@@ -2727,6 +2855,7 @@
27272855 onAction={() => void loadEncryption()}
27282856 encryptedPreviews={$urlPreviewSettings.encrypted_rooms}
27292857 onEncryptedPreviews={(enabled) => void changeEncryptedPreviews(enabled)}
2858+ bind:keyShown={recoveryKeyShown}
27302859 on:close={() => (dialog = null)}
27312860 />
27322861 {:else if dialog === 'settings'}
Modified file: web/src/app.css+17 additions−4 deletions
@@ -139,6 +139,7 @@
139139 html, body, #app {
140140 margin: 0;
141141 min-height: 100dvh;
142+ overscroll-behavior: none;
142143 }
143144
144145 @keyframes pop-in {
@@ -202,7 +203,7 @@ input, textarea { font: inherit; }
202203 display: grid;
203204 grid-template-columns: var(--rail-w) var(--side-w) minmax(0, 1fr) var(--thread-w) var(--members-w);
204205 grid-template-rows: auto minmax(0, 1fr) auto;
205- height: 100dvh;
206+ height: calc(100dvh - var(--kb-inset, 0px));
206207 overflow: clip;
207208 transition: grid-template-columns var(--pane-ease);
208209 }
@@ -353,7 +354,7 @@ input, textarea { font: inherit; }
353354 padding: 0 10px;
354355 }
355356 .side-search input:focus { outline: none; border-color: var(--accent); }
356-.side-scroll { order: 1; flex: 1; overflow-y: auto; padding: 6px; min-height: 0; }
357+.side-scroll { order: 1; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 6px; min-height: 0; }
357358 .side-h {
358359 font-size: 10.5px;
359360 font-weight: 650;
@@ -391,6 +392,10 @@ input, textarea { font: inherit; }
391392 .rail-space.drop-after { box-shadow: 0 6px 0 -3px var(--accent); }
392393 .room-row.active { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); box-shadow: inset 2px 0 0 var(--accent); }
393394 .room-row:has(.badge) { color: var(--text); font-weight: 600; }
395+.room-row.muted .room-name { opacity: 0.55; }
396+.room-row.muted:hover .room-name, .room-row.muted.active .room-name { opacity: 0.85; }
397+.muted-icon { flex: none; color: var(--muted); opacity: 0.8; }
398+.rail-btn.muted:not(.active):not(:hover) { opacity: 0.5; }
394399 .room-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
395400 .up-row { margin-bottom: 4px; }
396401 .room-name {
@@ -686,6 +691,7 @@ input, textarea { font: inherit; }
686691 .timeline {
687692 flex: 1;
688693 overflow-y: auto;
694+ overscroll-behavior: contain;
689695 overflow-anchor: none;
690696 padding: 8px 14px 10px;
691697 min-height: 0;
@@ -862,7 +868,7 @@ input, textarea { font: inherit; }
862868 white-space: pre-wrap;
863869 overflow-wrap: anywhere;
864870 min-height: calc(var(--ctl-h) - 2px);
865- max-height: min(40vh, 280px);
871+ max-height: min(calc((100dvh - var(--kb-inset, 0px)) * 0.4), 280px);
866872 }
867873 .composer-field > textarea {
868874 resize: none;
@@ -930,7 +936,7 @@ input, textarea { font: inherit; }
930936 border-bottom: 1px solid var(--border);
931937 }
932938 .members-head h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0; }
933-.member-list { list-style: none; margin: 0; padding: 8px 8px 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
939+.member-list { list-style: none; margin: 0; padding: 8px 8px 12px; overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; gap: 2px; }
934940 .member-group { list-style: none; }
935941 .member-group + .member-group { margin-top: 10px; }
936942 .member-group-label { display: flex; align-items: center; justify-content: space-between; margin: 4px 8px 3px; color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
@@ -1167,3 +1173,10 @@ input, textarea { font: inherit; }
11671173 .chat-head { padding-top: calc(8px + env(safe-area-inset-top)); }
11681174 .composer { padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
11691175 }
1176+.kb-open .composer, .kb-open .account { padding-bottom: 8px; }
1177+@media (pointer: coarse) {
1178+ input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']), textarea, select { font-size: 16px; }
1179+ .composer-field > textarea, .composer-field > .composer-marks { font-size: 16px; line-height: 20px; }
1180+ :root { --ctl-h: 40px; }
1181+ .room-row { min-height: 40px; }
1182+}
Modified file: web/src/lib/MentionsInbox.svelte+1 additions−1 deletions
@@ -484,7 +484,7 @@
484484 article { border:1px solid var(--border); border-radius:.6rem; padding:.5rem; margin-bottom:.6rem; }
485485 article.unread { border-color:var(--accent); }
486486 .entry-head,.entry-actions,.labels { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
487- .entry-head .room { font-weight:600; }
487+ .entry-head .room { font-weight:600; text-align:left; }
488488 .entry-head time,.entry-head span { color:var(--muted); font-size:.8rem; }
489489 .entry-head time { margin-left:auto; }
490490 .labels { margin:.35rem 0; color:var(--muted); font-size:.78rem; }
Modified file: web/src/lib/MessageRow.svelte+49 additions−8 deletions
@@ -6,7 +6,9 @@
66 import Emoji from './Emoji.svelte';
77 import EmojiText from './EmojiText.svelte';
88 import MessageText from './MessageText.svelte';
9+ import { longpress } from './longpress';
910 import ReactionPicker from './ReactionPicker.svelte';
11+ import { backStack, instanceLayerId } from './back-stack';
1012 import LinkCard from './LinkCard.svelte';
1113 import { afterUpdate, onDestroy } from 'svelte';
1214 import { dissolve, expand, rowIn, rowOut, settleHeight } from './motion';
@@ -55,7 +57,7 @@
5557 return `${Math.floor(lag / 86400000)}d`;
5658 }
5759
58- function openUser(ev: MouseEvent, menu: boolean): void {
60+ function openUser(ev: Pick<MouseEvent, 'clientX' | 'clientY' | 'currentTarget'>, menu: boolean): void {
5961 if (!onUser) return;
6062 const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
6163 onUser(event.sender.user_id, menu ? ev.clientX : rect.left, menu ? ev.clientY : rect.bottom + 4, menu);
@@ -84,6 +86,7 @@
8486 let hovered = false;
8587 let focused = false;
8688 let reactorKey: string | null = null;
89+ let reactorPinned = false;
8790 let pickerOpen = false;
8891 let pickerStyle = '';
8992
@@ -161,6 +164,11 @@
161164 $: syncSelectionListeners(selected);
162165 $: syncPickerListeners(pickerOpen);
163166
167+ const pickerLayer = instanceLayerId('reaction-picker');
168+ const dismissPicker = () => (pickerOpen = false);
169+ $: backStack.sync(pickerLayer, pickerOpen, dismissPicker);
170+ onDestroy(() => backStack.sync(pickerLayer, false, dismissPicker));
171+
164172 function onEscape(e: KeyboardEvent) {
165173 if (e.key === 'Escape') select(false);
166174 }
@@ -194,6 +202,23 @@
194202 selectedRow.update((o) => (o === owner ? null : o));
195203 });
196204
205+ function pinReactors(key: string) {
206+ reactorPinned = true;
207+ reactorKey = key;
208+ }
209+
210+ function unpinReactors() {
211+ if (!reactorPinned) return;
212+ reactorPinned = false;
213+ reactorKey = null;
214+ }
215+
216+ function holdMore(p: { clientX: number; clientY: number }) {
217+ if (readOnly || !interactive || !event.event_id) return;
218+ select(true);
219+ onMore?.(event, p.clientX, p.clientY);
220+ }
221+
197222 function select(on: boolean) {
198223 selectedRow.update((o) => (on ? owner : o === owner ? null : o));
199224 }
@@ -360,8 +385,9 @@
360385 out:rowOut|global
361386 bind:this={row}
362387 on:click={toggleSelected}
388+ use:longpress={{ on: holdMore, allowSelection: true, ignore: 'a, button, input, img, video, audio, .text, .picker-pop' }}
363389 on:keydown={onRowKeydown}
364- on:pointerenter={() => (hovered = true)}
390+ on:pointerenter={(e) => (hovered = e.pointerType === 'mouse')}
365391 on:pointerleave={() => (hovered = false)}
366392 on:focusin={() => (focused = true)}
367393 on:focusout={onFocusOut}
@@ -483,6 +509,7 @@
483509 aria-hidden="true"
484510 on:click|stopPropagation={(ev) => openUser(ev, false)}
485511 on:contextmenu|preventDefault|stopPropagation={(ev) => openUser(ev, true)}
512+ use:longpress={(ev) => openUser(ev, true)}
486513 >
487514 {#if senderAvatar}
488515 <MxcAvatar mxc={senderAvatar} label={name} size={34} eager />
@@ -507,6 +534,7 @@
507534 style={`color:hsl(${senderHue(event.sender.user_id)} 70% var(--name-lightness))`}
508535 on:click|stopPropagation={(ev) => openUser(ev, false)}
509536 on:contextmenu|preventDefault|stopPropagation={(ev) => openUser(ev, true)}
537+ use:longpress={(ev) => openUser(ev, true)}
510538 >
511539 <EmojiText text={displayName} />
512540 </button>
@@ -560,10 +588,11 @@
560588 aria-label={`${r.shortcode ? `:${r.shortcode}:` : r.key.startsWith('mxc://') ? 'custom emote' : r.key}, ${r.count} reaction${r.count === 1 ? '' : 's'}`}
561589 disabled={readOnly || !event.event_id || reacting}
562590 aria-describedby={reactorKey === r.key ? `${itemId}-reactors-${r.key}` : undefined}
563- on:pointerenter={() => (reactorKey = r.key)}
564- on:pointerleave={() => (reactorKey = null)}
591+ on:pointerenter={(e) => e.pointerType === 'mouse' && (reactorKey = r.key)}
592+ on:pointerleave={() => !reactorPinned && (reactorKey = null)}
593+ use:longpress={() => pinReactors(r.key)}
565594 on:focus={() => (reactorKey = r.key)}
566- on:blur={() => (reactorKey = null)}
595+ on:blur={() => !reactorPinned && (reactorKey = null)}
567596 on:click={() => event.event_id && onReact(event.event_id, r.key)}
568597 >
569598 {#if r.image}
@@ -610,12 +639,18 @@
610639 </div>
611640 </div>
612641
642+<svelte:window on:pointerdown={unpinReactors} />
643+
613644 <style>
614645 .msg { position: relative; display: flex; flex-wrap: wrap; gap: 0 10px; padding: var(--msg-gap, 3px) 0; }
615646 .reply-line { position: relative; flex: 0 0 100%; min-width: 0; padding-left: 44px; }
616647 .reply-line.linked::before { content: ''; position: absolute; left: 16px; top: 50%; bottom: -3px; width: 24px; border-left: 2px solid var(--border); border-top: 2px solid var(--border); border-top-left-radius: 8px; pointer-events: none; }
617- .msg:hover, .msg.selected { background: var(--hover-faint); }
618- .msg:hover .hover-time, .msg.selected .hover-time { opacity: 1; }
648+ .msg.selected { background: var(--hover-faint); }
649+ .msg.selected .hover-time { opacity: 1; }
650+ @media (hover: hover) {
651+ .msg:hover { background: var(--hover-faint); }
652+ .msg:hover .hover-time { opacity: 1; }
653+ }
619654 .msg-foot { display: flex; align-items: center; gap: 8px; }
620655 .thread-chip { display: inline-flex; align-items: baseline; gap: 8px; margin-top: 4px; padding: 3px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg-soft); color: var(--text); font: inherit; font-size: 12px; cursor: pointer; transition: border-color var(--pop), background var(--pop); }
621656 .thread-chip:hover { border-color: var(--accent); background: var(--hover-faint); }
@@ -638,7 +673,7 @@
638673 .msg-head { display: flex; align-items: baseline; gap: 8px; }
639674 .federation-lag { display: inline-flex; align-items: center; gap: 3px; color: var(--danger); font-size: 11px; font-weight: 650; }
640675 .federation-lag > span:last-child { display: inline-grid; place-items: center; min-width: 24px; height: 18px; padding: 0 4px; border: 1px solid currentColor; border-radius: 999px; }
641- .sender { font-weight: 650; font-size: 13.5px; padding: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
676+ .sender { font-weight: 650; font-size: 13.5px; padding: 0; border: none; background: none; font-family: inherit; text-align: left; cursor: pointer; }
642677 .sender:hover { text-decoration: underline; }
643678 .user-hit { flex: none; align-self: flex-start; padding: 0; border: none; background: none; border-radius: 50%; cursor: pointer; }
644679 .time, .edited { color: var(--muted); font-size: 11.5px; }
@@ -739,5 +774,11 @@
739774 .actions button:hover:not(:disabled) { border-color: var(--border); background: var(--hover); }
740775 .actions button:disabled { opacity: 0.4; cursor: default; }
741776 .emote { width: 1.3em; height: 1.3em; object-fit: contain; vertical-align: middle; }
777+ @media (pointer: coarse) {
778+ .actions button { min-width: 36px; min-height: 40px; font-size: 16px; }
779+ .actions { top: auto; bottom: calc(100% - 18px); right: 6px; flex-wrap: wrap; justify-content: flex-end; max-width: calc(100% - 12px); }
780+ .reaction::after, .sender::after, .thread-chip::after { content: ''; position: absolute; inset: -9px -2px; }
781+ .sender, .thread-chip { position: relative; }
782+ }
742783 .picker-pop { position: fixed; z-index: 60; width: 320px; max-width: calc(100vw - 16px); animation: pop-in var(--pop); }
743784 </style>
Modified file: web/src/lib/SearchView.svelte+1 additions−1 deletions
@@ -126,7 +126,7 @@
126126 .search-bar input { flex: 1; min-width: 0; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-md); color: var(--text); padding: 8px 10px; font: inherit; }
127127 .search-bar input:focus { outline: none; border-color: var(--accent); }
128128 .scopes { display: flex; gap: 6px; padding: 0 16px 10px; border-bottom: 1px solid var(--border); }
129- .scopes button { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: var(--r-sm); padding: 3px 10px; font: inherit; font-size: 12.5px; cursor: pointer; transition: color var(--pop), border-color var(--pop), background var(--pop); }
129+ .scopes button { text-align: left; border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: var(--r-sm); padding: 3px 10px; font: inherit; font-size: 12.5px; cursor: pointer; transition: color var(--pop), border-color var(--pop), background var(--pop); }
130130 .scopes button:hover { color: var(--text); }
131131 .scopes button.active { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
132132 .search-error { margin: 10px 16px 0; color: var(--danger); font-size: 13px; }
Modified file: web/src/lib/SecurityPanel.svelte+197 additions−21 deletions
@@ -1,11 +1,15 @@
11 <script lang="ts">
2- import { createEventDispatcher, onMount } from 'svelte';
2+ import { createEventDispatcher, onDestroy, onMount } from 'svelte';
33 import {
4+ disableRecovery,
5+ isBackupExists,
46 recoverEncryption,
57 runKeyBackup,
8+ setupRecovery,
69 startVerification,
710 type KeyBackupAction,
811 verifyEncryption,
12+ verifyRecoveryKey,
913 type VerifyAction
1014 } from './api';
1115 import { pushBanner } from './store';
@@ -19,15 +23,103 @@
1923 export let verificationState: VerificationState | null;
2024 export let onAction: () => void = () => undefined;
2125 export let encryptedPreviews = false;
26+ export let keyShown = false;
2227 export let onEncryptedPreviews: (enabled: boolean) => void = () => undefined;
2328
2429 let recoveryKey = '';
2530 let recoveryInput: HTMLInputElement | undefined;
2631
2732 onMount(() => {
28- if (encryptionState && encryptionState.recovery !== 'Enabled') recoveryInput?.focus();
33+ if (encryptionState?.recovery === 'Incomplete') recoveryInput?.focus();
2934 });
30- let busy: VerifyAction | KeyBackupAction | 'recover' | 'start' | null = null;
35+ onDestroy(() => {
36+ revealedKey = null;
37+ });
38+ let busy: VerifyAction | KeyBackupAction | 'recover' | 'start' | 'setup' | 'verify-key' | null = null;
39+
40+ let passphrase = '';
41+ let showPassphrase = false;
42+ let revealedKey: string | null = null;
43+ let backupExists = false;
44+ let confirming: 'reset' | 'off' | 'restart' | null = null;
45+ let verifyInput = '';
46+ let verifyResult: boolean | null = null;
47+ let copied = false;
48+
49+ $: keyShown = revealedKey !== null;
50+ $: settingUp = encryptionState?.recovery === 'Enabling' || encryptionState?.key_backup === 'Creating';
51+
52+ async function createKey(op: 'enable_recovery' | 'reset_recovery_key', startOver = false): Promise<void> {
53+ if (busy) return;
54+ busy = 'setup';
55+ try {
56+ if (startOver) await runKeyBackup('disable_key_backup', true);
57+ revealedKey = await setupRecovery(op, passphrase.trim() || null);
58+ passphrase = '';
59+ showPassphrase = false;
60+ backupExists = false;
61+ confirming = null;
62+ copied = false;
63+ onAction();
64+ } catch (e) {
65+ if (op === 'enable_recovery' && isBackupExists(e)) {
66+ backupExists = true;
67+ confirming = null;
68+ } else {
69+ pushBanner(e instanceof Error ? e.message : 'Recovery setup failed');
70+ }
71+ } finally {
72+ busy = null;
73+ }
74+ }
75+
76+ async function turnOffRecovery(): Promise<void> {
77+ if (busy) return;
78+ busy = 'setup';
79+ try {
80+ await disableRecovery();
81+ confirming = null;
82+ onAction();
83+ } catch (e) {
84+ pushBanner(e instanceof Error ? e.message : 'Could not turn off recovery');
85+ } finally {
86+ busy = null;
87+ }
88+ }
89+
90+ async function checkKey(): Promise<void> {
91+ const key = verifyInput.trim();
92+ if (!key || busy) return;
93+ busy = 'verify-key';
94+ try {
95+ verifyResult = await verifyRecoveryKey(key);
96+ verifyInput = '';
97+ } catch (e) {
98+ pushBanner(e instanceof Error ? e.message : 'Could not check the key');
99+ } finally {
100+ busy = null;
101+ }
102+ }
103+
104+ async function copyKey(): Promise<void> {
105+ if (!revealedKey) return;
106+ try {
107+ await navigator.clipboard.writeText(revealedKey);
108+ copied = true;
109+ } catch {
110+ pushBanner('Could not copy the key');
111+ }
112+ }
113+
114+ function downloadKey(): void {
115+ if (!revealedKey) return;
116+ const url = URL.createObjectURL(new Blob([revealedKey + '\n'], { type: 'text/plain' }));
117+ const link = document.createElement('a');
118+ link.href = url;
119+ link.download = 'malice-recovery-key.txt';
120+ link.click();
121+ URL.revokeObjectURL(url);
122+ }
31123
32124 let confirmOff = false;
33125
@@ -103,7 +195,7 @@
103195 <div class="modal" role="dialog" aria-modal="true" aria-label="Encryption and verification">
104196 <div class="modal-tabs" role="tablist" aria-label="Security">
105197 <button type="button" role="tab" aria-selected="true" class="active">Security</button>
106- <button type="button" class="icon-btn modal-x" aria-label="Close security dialog" on:click={() => dispatch('close')}>
198+ <button type="button" class="icon-btn modal-x" aria-label="Close security dialog" disabled={revealedKey !== null} on:click={() => dispatch('close')}>
107199 <span aria-hidden="true">✕</span>
108200 </button>
109201 </div>
@@ -156,23 +248,98 @@
156248
157249 <section aria-label="Recovery">
158250 <h3 class="sec-h">Recovery</h3>
159- <form on:submit|preventDefault={recover}>
160- <label class="field">
161- <span>Recovery key</span>
162- <input
163- type="password"
164- autocomplete="off"
165- spellcheck="false"
166- placeholder="EsT4 …"
167- bind:value={recoveryKey}
168- bind:this={recoveryInput}
169- disabled={busy !== null}
170- />
171- </label>
172- <button type="submit" class="primary-btn wide" disabled={busy !== null || !recoveryKey.trim()}>
173- {busy === 'recover' ? 'Sending…' : 'Recover with key'}
174- </button>
175- </form>
251+ {#if revealedKey}
252+ <code class="key" aria-label="Recovery key">{revealedKey}</code>
253+ <div class="row">
254+ <button type="button" class="ghost-btn" on:click={() => void copyKey()}>{copied ? 'Copied' : 'Copy'}</button>
255+ <button type="button" class="ghost-btn" on:click={downloadKey}>Download</button>
256+ <button type="button" class="primary-btn" on:click={() => (revealedKey = null)}>I saved it</button>
257+ </div>
258+ {:else if settingUp || busy === 'setup'}
259+ <p class="sec-line muted">Setting up recovery…</p>
260+ {:else}
261+ {#if encryptionState?.recovery === 'Enabled'}
262+ <form class="inline" on:submit|preventDefault={checkKey}>
263+ <label class="field">
264+ <span>Verify key</span>
265+ <input type="password" autocomplete="off" spellcheck="false" bind:value={verifyInput} on:input={() => (verifyResult = null)} disabled={busy !== null} />
266+ </label>
267+ <button type="submit" class="ghost-btn" disabled={busy !== null || !verifyInput.trim()}>
268+ {busy === 'verify-key' ? 'Checking…' : 'Check'}
269+ </button>
270+ {#if verifyResult !== null}
271+ <span class="pill" class:ok={verifyResult} class:warn={!verifyResult}>{verifyResult ? 'matches' : 'wrong key'}</span>
272+ {/if}
273+ </form>
274+ {:else if encryptionState?.recovery === 'Disabled' && !backupExists}
275+ {#if showPassphrase}
276+ <label class="field">
277+ <span>Passphrase</span>
278+ <input type="password" autocomplete="new-password" spellcheck="false" bind:value={passphrase} disabled={busy !== null} />
279+ </label>
280+ {/if}
281+ <div class="row">
282+ <button type="button" class="primary-btn" disabled={busy !== null} on:click={() => void createKey('enable_recovery')}>Set up recovery</button>
283+ {#if !showPassphrase}
284+ <button type="button" class="link" on:click={() => (showPassphrase = true)}>Use a passphrase</button>
285+ {/if}
286+ </div>
287+ {/if}
288+
289+ {#if backupExists || encryptionState?.recovery === 'Incomplete' || encryptionState?.recovery === 'Unknown'}
290+ {#if backupExists}
291+ <p class="sec-line">A key backup already exists for this account.</p>
292+ {/if}
293+ <form on:submit|preventDefault={recover}>
294+ <label class="field">
295+ <span>Recovery key</span>
296+ <input
297+ type="password"
298+ autocomplete="off"
299+ spellcheck="false"
300+ placeholder="EsT4 …"
301+ bind:value={recoveryKey}
302+ bind:this={recoveryInput}
303+ disabled={busy !== null}
304+ />
305+ </label>
306+ <button type="submit" class="primary-btn wide" disabled={busy !== null || !recoveryKey.trim()}>
307+ {busy === 'recover' ? 'Sending…' : 'Recover with key'}
308+ </button>
309+ </form>
310+ {/if}
311+
312+ <div class="row">
313+ {#if backupExists}
314+ {#if confirming === 'restart'}
315+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void createKey('enable_recovery', true)}>Delete old backup and start over</button>
316+ <button type="button" class="ghost-btn" on:click={() => (confirming = null)}>Cancel</button>
317+ {:else}
318+ <button type="button" class="ghost-btn danger" on:click={() => (confirming = 'restart')}>Start over</button>
319+ {/if}
320+ {:else if encryptionState?.recovery === 'Enabled' || encryptionState?.recovery === 'Incomplete'}
321+ {#if confirming === 'reset'}
322+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void createKey('reset_recovery_key')}>Reset key</button>
323+ <button type="button" class="ghost-btn" on:click={() => (confirming = null)}>Cancel</button>
324+ {:else if confirming === 'off'}
325+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void turnOffRecovery()}>Turn off recovery</button>
326+ <button type="button" class="ghost-btn" on:click={() => (confirming = null)}>Cancel</button>
327+ {:else}
328+ <button type="button" class="ghost-btn" on:click={() => (confirming = 'reset')}>Reset recovery key</button>
329+ {#if encryptionState.recovery === 'Enabled'}
330+ <button type="button" class="ghost-btn danger" on:click={() => (confirming = 'off')}>Turn off recovery</button>
331+ {/if}
332+ {/if}
333+ {/if}
334+ </div>
335+ {#if confirming === 'restart'}
336+ <p class="confirm">The old backup is deleted from the server. Encrypted history that only it could unlock becomes unreadable.</p>
337+ {:else if confirming === 'reset'}
338+ <p class="confirm">The current recovery key stops working.</p>
339+ {:else if confirming === 'off'}
340+ <p class="confirm">Lost devices can no longer restore message keys.</p>
341+ {/if}
342+ {/if}
176343 </section>
177344
178345 <section aria-label="Verification">
@@ -281,5 +448,14 @@
281448 }
282449 .emoji { font-size: 24px; }
283450 .emoji-name { font-size: 11px; color: var(--muted); text-align: center; }
451+ .key {
452+ display: block; font-family: ui-monospace, monospace; font-size: 14px; letter-spacing: 0.04em;
453+ background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-sm);
454+ padding: 10px 12px; margin: 0 0 8px; user-select: all; overflow-wrap: anywhere;
455+ }
456+ form.inline { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
457+ form.inline .field { flex: 1; min-width: 160px; margin-bottom: 0; }
458+ .confirm { font-size: 12.5px; color: var(--danger); margin: 8px 0 0; }
459+ .link { background: none; border: 0; padding: 0; color: var(--muted); font-size: 12.5px; cursor: pointer; text-decoration: underline; }
284460 .decimals { font-size: 17px; font-weight: 700; letter-spacing: 0.06em; }
285461 </style>
Modified file: web/src/lib/api.ts+19 additions
@@ -15,6 +15,7 @@ export type { HistoryRequest } from './transport';
1515 import { createDeviceTransport } from 'malice:device-transport';
1616
1717 import { ApiError } from './transport';
18+import { isRecord } from './guards';
1819 export { ApiError } from './transport';
1920 export type { SessionInfo, SoftLogout, VerifyAction } from './transport';
2021
@@ -167,6 +168,24 @@ export async function runKeyBackup(op: KeyBackupAction, deleteFromServer = false
167168 await transport.command(op === 'disable_key_backup' ? { op, delete_from_server: deleteFromServer } : { op });
168169 }
169170
171+export async function setupRecovery(op: 'enable_recovery' | 'reset_recovery_key', passphrase: string | null): Promise<string> {
172+ const reply = await transport.command({ op, passphrase });
173+ const key = isRecord(reply) ? reply['recovery_key'] : null;
174+ if (typeof key !== 'string' || !key) throw new Error('No recovery key returned');
175+ return key;
176+}
177+
178+export const isBackupExists = (e: unknown): boolean => e instanceof ApiError && e.kind === 'key_backup_exists';
179+
180+export async function disableRecovery(): Promise<void> {
181+ await transport.command({ op: 'disable_recovery' });
182+}
183+
184+export async function verifyRecoveryKey(recovery_key: string): Promise<boolean> {
185+ const reply = await transport.command({ op: 'verify_recovery_key', recovery_key });
186+ return isRecord(reply) && reply['valid'] === true;
187+}
188+
170189 export function startVerification(): Promise<void> {
171190 return transport.startVerification();
172191 }
Modified file: web/src/lib/motion.ts+1 additions−1 deletions
@@ -34,7 +34,7 @@ function reveal(node: Element, ms: number, axis: 'x' | 'y' = 'y'): TransitionCon
3434 if (duration === 0) return { duration: 0 };
3535 noteBusy(duration);
3636 const base = slide(node, { duration, easing: cubicOut, axis });
37- return { duration, easing: cubicOut, css: (t, u) => `${base.css?.(t, u) ?? ''}opacity: ${t};` };
37+ return { duration, easing: cubicOut, css: (t, u) => `${base.css?.(t, u) ?? ''};opacity: ${t};` };
3838 }
3939
4040 export function rowIn(node: Element): TransitionConfig {
Modified file: web/src/lib/notifications.ts+6 additions−5 deletions
@@ -12,7 +12,8 @@ import {
1212 sourceFromTimeline,
1313 type NotificationPlan
1414 } from './notification-model';
15-import { rooms, selectedRoomId, sessionUser, timelines } from './store';
15+import { effectiveRooms } from './mute';
16+import { selectedRoomId, sessionUser, timelines } from './store';
1617 import type { RoomSummary } from './types';
1718
1819 const SETTLE_MS = 300;
@@ -139,7 +140,7 @@ export function startNotifications(openRoom: (roomId: string) => void): () => vo
139140 clearTimeout(waiting.get(roomId)?.timer);
140141 const timer = setTimeout(() => {
141142 waiting.delete(roomId);
142- const room = get(rooms).find((r) => r.room_id === roomId);
143+ const room = get(effectiveRooms).find((r) => r.room_id === roomId);
143144 if (!room || !active() || watched(roomId) || notifyingCount(room) === 0) return;
144145 const source = sourceFromTimeline(get(timelines)[roomId]) ?? sourceFromSummary(room);
145146 notified.add(roomId);
@@ -167,9 +168,9 @@ export function startNotifications(openRoom: (roomId: string) => void): () => vo
167168 clearRead(list);
168169 };
169170
170- const unsubscribeRooms = rooms.subscribe(onRooms);
171- const unsubscribeEnabled = notificationsEnabled.subscribe(() => onRooms(get(rooms)));
172- const onAttention = () => clearRead(get(rooms));
171+ const unsubscribeRooms = effectiveRooms.subscribe(onRooms);
172+ const unsubscribeEnabled = notificationsEnabled.subscribe(() => onRooms(get(effectiveRooms)));
173+ const onAttention = () => clearRead(get(effectiveRooms));
173174 const onMessage = (ev: MessageEvent) => {
174175 const data = ev.data as { type?: unknown; roomId?: unknown } | null;
175176 if (data?.type === OPEN_ROOM_MESSAGE && typeof data.roomId === 'string') openRoom(data.roomId);
Modified file: web/src/lib/store.ts+3 additions−1 deletions
@@ -12,6 +12,7 @@ import { loadKeywords, resetKeywords } from './keywords';
1212 import { resetLinkPreviews } from './link-previews';
1313 import { resetQuickReactions } from './quick-reactions';
1414 import { resetPackPreferences } from './pack-preferences';
15+import { resetMutedSpaces } from './muted-spaces';
1516 import {
1617 EMPTY_IMAGE_PACKS,
1718 parseEncryptionState,
@@ -148,6 +149,7 @@ export function resetClientState(): void {
148149 imagePacks.set(EMPTY_IMAGE_PACKS);
149150 resetQuickReactions();
150151 resetPackPreferences();
152+ resetMutedSpaces();
151153 resetLinkPreviews();
152154 resetKeywords();
153155 onlineSince = 0;
@@ -201,7 +203,7 @@ function identifyRoom(raw: unknown): string | null {
201203 return isRecord(raw) && typeof raw['room_id'] === 'string' ? raw['room_id'] : null;
202204 }
203205
204-function setRooms(raw: unknown): void {
206+export function setRooms(raw: unknown): void {
205207 const next = parseShared(roomsCache, raw, parseRoomSummary, identifyRoom);
206208 if (!sameList(get(rooms), next)) rooms.set(next);
207209 }
Added file: web/src/lib/trace-name.ts+19 additions
@@ -0,0 +1,19 @@
1+const BASE = 'malice';
2+const PREFIX = `${BASE} trace=`;
3+
4+export function workerName(filter: string | null): string {
5+ const trimmed = filter?.trim();
6+ return trimmed ? PREFIX + trimmed : BASE;
7+}
8+
9+export function traceFilter(name: string): string {
10+ return name.startsWith(PREFIX) ? name.slice(PREFIX.length) : '';
11+}
12+
13+export function pageTraceFilter(): string | null {
14+ try {
15+ return localStorage.getItem('malice.trace');
16+ } catch {
17+ return null;
18+ }
19+}
Modified file: web/src/lib/transport-http.ts+4 additions−1 deletions
@@ -5,6 +5,7 @@ import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type Tr
55
66 async function parseFailure(res: Response, fallback: string): Promise<never> {
77 let message = fallback;
8+ let kind: string | null = null;
89 try {
910 const text = await res.text();
1011 if (text) {
@@ -12,6 +13,8 @@ async function parseFailure(res: Response, fallback: string): Promise<never> {
1213 const data = JSON.parse(text) as unknown;
1314 if (typeof data === 'object' && data !== null) {
1415 const m = (data as Record<string, unknown>)['message'] ?? (data as Record<string, unknown>)['error'];
16+ const k = (data as Record<string, unknown>)['kind'];
17+ if (typeof k === 'string') kind = k;
1518 if (typeof m === 'string' && m) message = m;
1619 else message = text.slice(0, 300);
1720 } else if (typeof data === 'string' && data) {
@@ -26,7 +29,7 @@ async function parseFailure(res: Response, fallback: string): Promise<never> {
2629 } catch {
2730 // keep fallback
2831 }
29- throw new ApiError(res.status, `${res.status}: ${message}`);
32+ throw new ApiError(res.status, `${res.status}: ${message}`, kind);
3033 }
3134
3235 async function json<T>(res: Response, fallback: string): Promise<T> {
Modified file: web/src/lib/transport-worker.ts+7 additions−3 deletions
@@ -3,9 +3,10 @@ import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext }
33 import type { MemberSummary, RoomSummary } from './types';
44 import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type SoftLogout, type Transport, type UploadExtras, type VerifyAction } from './transport';
55 import { statusForKind } from './wire-status';
6+import { pageTraceFilter, workerName } from './trace-name';
67
78 function toApiError(error: { kind: string; message: string }): ApiError {
8- return new ApiError(statusForKind(error.kind), error.message);
9+ return new ApiError(statusForKind(error.kind), error.message, error.kind);
910 }
1011
1112 type Pending = { resolve: (value: unknown) => void; reject: (error: unknown) => void };
@@ -39,13 +40,16 @@ class WorkerClient {
3940 if (typeof SharedWorker !== 'undefined') {
4041 const worker = new SharedWorker(new URL('../worker/worker.ts', import.meta.url), {
4142 type: 'module',
42- name: 'malice'
43+ name: workerName(pageTraceFilter())
4344 });
4445 worker.port.onmessage = (ev: MessageEvent) => this.handleMessage(ev.data);
4546 worker.port.start();
4647 this.port = worker.port;
4748 } else {
48- const worker = new Worker(new URL('../worker/worker.ts', import.meta.url), { type: 'module' });
49+ const worker = new Worker(new URL('../worker/worker.ts', import.meta.url), {
50+ type: 'module',
51+ name: workerName(pageTraceFilter())
52+ });
4953 worker.onmessage = (ev: MessageEvent) => this.handleMessage(ev.data);
5054 this.port = worker;
5155 }
Modified file: web/src/lib/transport.ts+7 additions−1 deletions
@@ -3,10 +3,12 @@ import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext }
33
44 export class ApiError extends Error {
55 status: number;
6- constructor(status: number, message: string) {
6+ kind: string | null;
7+ constructor(status: number, message: string, kind: string | null = null) {
78 super(message);
89 this.name = 'ApiError';
910 this.status = status;
11+ this.kind = kind;
1012 }
1113 }
1214
@@ -36,10 +38,14 @@ export type CommandRequest =
3638 | { op: 'set_room_display_name'; room_id: string; name: string | null }
3739 | { op: 'set_room_avatar'; room_id: string; mxc: string | null }
3840 | { op: 'mute'; room_id: string; user_id: string; muted: boolean }
41+ | { op: 'set_room_notification_mode'; room_id: string; mode: 'AllMessages' | 'MentionsAndKeywordsOnly' | 'Mute' | null }
3942 | { op: 'notification_keywords' }
4043 | { op: 'create_key_backup' | 'enable_key_backup' }
4144 | { op: 'disable_key_backup'; delete_from_server?: boolean }
4245 | { op: 'recover_key_backup'; recovery_key: string }
46+ | { op: 'enable_recovery' | 'reset_recovery_key'; passphrase?: string | null }
47+ | { op: 'disable_recovery' }
48+ | { op: 'verify_recovery_key'; recovery_key: string }
4349 | { op: 'search'; query: string; scope: unknown; limit: number }
4450 | { op: 'search_more'; search_id: string; cursor: string; limit: number }
4551 | { op: 'close_search'; search_id: string }
Modified file: web/src/lib/types.ts+2 additions−2 deletions
@@ -141,7 +141,7 @@ export function parseMediaAttachment(raw: unknown): MediaAttachment | null {
141141 };
142142 }
143143
144-export type RecoveryState = 'Unknown' | 'Disabled' | 'Incomplete' | 'Enabled';
144+export type RecoveryState = 'Unknown' | 'Disabled' | 'Incomplete' | 'Enabled' | 'Enabling';
145145
146146 export interface EncryptionState {
147147 device_id: string;
@@ -151,7 +151,7 @@ export interface EncryptionState {
151151 }
152152
153153 function parseRecoveryState(raw: unknown): RecoveryState {
154- return raw === 'Enabled' || raw === 'Disabled' || raw === 'Incomplete' ? raw : 'Unknown';
154+ return raw === 'Enabled' || raw === 'Disabled' || raw === 'Incomplete' || raw === 'Enabling' ? raw : 'Unknown';
155155 }
156156
157157 export function parseEncryptionState(raw: unknown): EncryptionState | null {
Modified file: web/src/lib/wire-status.ts+2 additions−1 deletions
@@ -9,7 +9,8 @@ export const STATUS_BY_KIND: Record<string, number> = {
99 upstream: 502,
1010 unauthorized: 401,
1111 forbidden: 403,
12- store_in_use: 409
12+ store_in_use: 409,
13+ key_backup_exists: 409
1314 };
1415
1516 export function statusForKind(kind: string): number {
Modified file: web/src/worker/worker.ts+3 additions−1 deletions
@@ -1,5 +1,6 @@
1-import init, { Host } from './pkg/malice_worker.js';
1+import init, { Host, init_tracing } from './pkg/malice_worker.js';
22 import { isRecord } from '../lib/guards';
3+import { traceFilter } from '../lib/trace-name';
34 import { EventCoalescer } from './coalesce';
45 import {
56 createAccount,
@@ -123,6 +124,7 @@ async function restoreAccount(account: AccountInfo): Promise<void> {
123124
124125 const ready = (async () => {
125126 await init();
127+ init_tracing(traceFilter((self as unknown as { name: string }).name));
126128 host = new Host(onHostEvent, onHostFatal);
127129 for (const account of await listAccounts()) await restoreAccount(account);
128130 })();
Added file: web/test/trace-name.test.ts+14 additions
@@ -0,0 +1,14 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { traceFilter, workerName } from '../src/lib/trace-name.ts';
4+
5+test('worker name carries the trace filter', () => {
6+ const filter = 'matrix_sdk::event_cache=debug,matrix_sdk::sliding_sync=debug';
7+ assert.equal(traceFilter(workerName(filter)), filter);
8+});
9+
10+test('blank filters keep the plain worker name', () => {
11+ assert.equal(workerName(null), 'malice');
12+ assert.equal(workerName(' '), 'malice');
13+ assert.equal(traceFilter('malice'), '');
14+});