Commit 20fe182f3204
tarball28 files changed+672-96
@@ -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
}@@ -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::VerificationInactive6768
| ErrorKind::VerificationUnsupported6869
| ErrorKind::NoCrossSigningIdentity => Failure::Conflict,70
+ ErrorKind::KeyBackupExists => Failure::KeyBackupExists,6971
ErrorKind::RecoveryRejected => Failure::BadRequest,7072
ErrorKind::InteractiveAuthRequired => Failure::Forbidden,7173
ErrorKind::Unauthenticated@@ -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
handle958998
.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]@@ -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
+ api7279
}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
}@@ -570,6 +570,10 @@ async fn post_command(
570570
| Request::EnableKeyBackup571571
| Request::DisableKeyBackup { .. }572572
| Request::RecoverKeyBackup { .. }573
+ | Request::EnableRecovery { .. }574
+ | Request::ResetRecoveryKey { .. }575
+ | Request::DisableRecovery576
+ | 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::NotificationKeywords603608
| Request::AddNotificationKeyword { .. }604609
| Request::RemoveNotificationKeyword { .. }@@ -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",@@ -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"] }@@ -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(())@@ -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
+}@@ -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",@@ -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
- verification91
+ verification,92
+ setRooms9093
} 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
threadWidth120126
} 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 the221232
// 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
<button20102123
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
<button21822297
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
<span21912309
class="invite-join"21922310
role="button"@@ -2216,20 +2334,23 @@
22162334
<button22172335
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
<span22282349
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
<button22742395
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
<span22852409
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
<button23312455
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
<SecurityPanel27252853
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'}@@ -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
+}@@ -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; }@@ -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|global361386
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>@@ -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; }@@ -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 VerifyAction1014
} 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
- <input163
- 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
+ <input297
+ 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>@@ -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
}@@ -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 {@@ -12,7 +12,8 @@ import {
1212
sourceFromTimeline,1313
type NotificationPlan1414
} 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);@@ -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
}@@ -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
+}@@ -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 fallback2831
}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> {@@ -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
}@@ -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 }@@ -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 {@@ -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: 40912
+ store_in_use: 409,13
+ key_backup_exists: 4091314
};1415
1516
export function statusForKind(kind: string): number {@@ -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
})();@@ -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
+});