From 8c5b4bc027bb1d381edf532bb76c7c0c8619dc2e Mon Sep 17 00:00:00 2001 From: kyle Date: Fri, 31 Jul 2026 23:14:56 +0200 Subject: [PATCH 1/5] refactor: trim unused client surface --- Cargo.lock | 13 --- README.md | 18 ++--- core/Cargo.toml | 1 - core/src/backend.rs | 64 --------------- core/src/event.rs | 43 ++-------- core/src/lib.rs | 12 +-- core/src/mock.rs | 136 ++++--------------------------- core/src/model.rs | 14 +--- docs/DATA_INTERFACE.md | 45 +++++------ src-tauri/Cargo.toml | 1 - src-tauri/src/lib.rs | 179 ++++------------------------------------- src/bindings.ts | 47 +---------- tailwind.config.js | 5 -- tsconfig.json | 7 +- 14 files changed, 79 insertions(+), 506 deletions(-) delete mode 100644 core/src/backend.rs diff --git a/Cargo.lock b/Cargo.lock index ac858a3..53f43bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,17 +87,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "atk" version = "0.18.2" @@ -4111,7 +4100,6 @@ version = "0.1.0" dependencies = [ "log", "serde", - "serde_json", "specta", "specta-typescript", "tauri", @@ -4126,7 +4114,6 @@ dependencies = [ name = "vauxl-core" version = "0.1.0" dependencies = [ - "async-trait", "serde", "specta", "thiserror 2.0.18", diff --git a/README.md b/README.md index 6107fff..6db9a38 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,14 @@ A Discord-style Matrix client. Tauri v2 shell, React (web) UI, and a Rust core. Today it runs entirely on an in-memory `MockBackend`, so the whole interface can -be built and felt before any Matrix code exists. Swapping in a matrix-rust-sdk -backend later changes only one constructor. +be built and felt before any Matrix code exists. ## Layout ``` client/ - core/ vauxl-core: the contract types, the ChatBackend trait, MockBackend (no Tauri, no Matrix) - src-tauri/ the Tauri shell: command/event wrappers over ChatBackend, tauri-specta bindings export + core/ vauxl-core: contract types and MockBackend (no Tauri, no Matrix) + src-tauri/ the Tauri shell: command and event wrappers, tauri-specta bindings export src/ the React UI (web). src/bindings.ts is GENERATED from Rust, do not edit it by hand docs/ DATA_INTERFACE.md explains the contract and the security boundary ``` @@ -48,10 +47,9 @@ bun run build # typechecks and builds the frontend into dist/ bun run tauri build # produces a packaged desktop bundle ``` -## Swapping the mock for real Matrix +## Replacing the mock with Matrix -1. Add a `MatrixBackend` to `core` (a new module) that implements `ChatBackend` - using `matrix-rust-sdk`. Crypto stays in `vodozemac` via the SDK; you write no - crypto yourself. -2. In `src-tauri/src/lib.rs`, change the constructor in `run` from - `MockBackend::new()` to your `MatrixBackend`. Nothing in the UI changes. +Implement the five commands used by the UI with `matrix-rust-sdk`, then update +`AppState` and the constructor in `src-tauri/src/lib.rs`. Add a shared backend +trait only if the mock and Matrix implementations must coexist. Crypto stays in +`vodozemac` through the SDK. diff --git a/core/Cargo.toml b/core/Cargo.toml index 26ac643..60a9c58 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -9,7 +9,6 @@ description = "Protocol-agnostic chat contract and mock backend for the Vauxl cl serde = { version = "1", features = ["derive"] } specta = { version = "2.0.0-rc.25", features = ["derive"] } thiserror = "2" -async-trait = "0.1" tokio = { version = "1", features = ["sync", "time"] } [dev-dependencies] diff --git a/core/src/backend.rs b/core/src/backend.rs deleted file mode 100644 index 0bf0234..0000000 --- a/core/src/backend.rs +++ /dev/null @@ -1,64 +0,0 @@ -use async_trait::async_trait; -use tokio::sync::broadcast; - -use crate::error::CoreError; -use crate::event::CoreEvent; -use crate::ids::*; -use crate::model::*; - -/// The single seam between the UI and the world. `MockBackend` implements this -/// for the prototype; a `MatrixBackend` (matrix-rust-sdk) implements the exact -/// same trait for the real client. Swapping which one is constructed at startup -/// changes nothing in the UI. -#[async_trait] -pub trait ChatBackend: Send + Sync { - // Session - async fn login(&self, req: LoginRequest) -> Result; - async fn restore_session(&self) -> Result, CoreError>; - async fn logout(&self) -> Result<(), CoreError>; - - // Spaces and rooms - async fn list_spaces(&self) -> Result, CoreError>; - async fn list_rooms(&self) -> Result, CoreError>; - async fn get_members(&self, room: RoomId) -> Result, CoreError>; - - // Timeline - async fn load_timeline(&self, room: RoomId, limit: u32) -> Result; - async fn load_older( - &self, - room: RoomId, - before: MessageId, - limit: u32, - ) -> Result; - async fn send_message( - &self, - room: RoomId, - content: OutgoingContent, - ) -> Result; - async fn edit_message( - &self, - room: RoomId, - target: MessageId, - content: OutgoingContent, - ) -> Result<(), CoreError>; - async fn redact_message(&self, room: RoomId, target: MessageId) -> Result<(), CoreError>; - async fn toggle_reaction( - &self, - room: RoomId, - target: MessageId, - key: String, - ) -> Result<(), CoreError>; - async fn mark_read(&self, room: RoomId, up_to: MessageId) -> Result<(), CoreError>; - async fn set_typing(&self, room: RoomId, typing: bool) -> Result<(), CoreError>; - - // Media (encrypt then upload; download/decrypt is served via custom protocol) - async fn upload_media(&self, path: String) -> Result; - - // E2EE device verification: no key material crosses the boundary, only SAS emoji. - async fn request_verification(&self, user: UserId) -> Result<(), CoreError>; - async fn confirm_sas(&self, flow: String) -> Result<(), CoreError>; - async fn cancel_verification(&self, flow: String) -> Result<(), CoreError>; - - /// Reactive stream the Tauri layer pumps into the webview as events. - fn subscribe(&self) -> broadcast::Receiver; -} diff --git a/core/src/event.rs b/core/src/event.rs index b9e61df..6cd4b80 100644 --- a/core/src/event.rs +++ b/core/src/event.rs @@ -4,16 +4,6 @@ use specta::Type; use crate::ids::*; use crate::model::*; -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(tag = "type")] -pub enum SyncState { - Offline, - Connecting, - Syncing, - Live, - Error { message: String }, -} - #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(tag = "op")] pub enum TimelineChange { @@ -22,34 +12,17 @@ pub enum TimelineChange { Removed { id: MessageId }, } -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct SasEmoji { - pub symbol: String, - pub name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(tag = "type")] -pub enum VerificationUpdate { - Requested { from: UserId, flow: String }, - ShowSas { flow: String, emoji: Vec }, - Done { flow: String }, - Cancelled { flow: String, reason: String }, -} - /// Reactive updates pushed from the core to the UI. The Tauri layer forwards /// each of these to the webview as an event. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(tag = "type")] pub enum CoreEvent { - Session { state: SessionState }, - Sync { state: SyncState }, - RoomUpserted { room: Room }, - RoomRemoved { room: RoomId }, - SpaceUpserted { space: Space }, - Timeline { room: RoomId, change: TimelineChange }, - Typing { room: RoomId, users: Vec }, - Receipt { room: RoomId, user: UserId, up_to: MessageId }, - Presence { user: User }, - Verification { update: VerificationUpdate }, + Timeline { + room: RoomId, + change: TimelineChange, + }, + Typing { + room: RoomId, + users: Vec, + }, } diff --git a/core/src/lib.rs b/core/src/lib.rs index f19d11a..c68c0be 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,20 +1,16 @@ -//! vauxl-core: the protocol-agnostic chat contract plus a MockBackend. +//! vauxl-core: protocol-agnostic chat types plus a MockBackend. //! -//! This crate deliberately has no Tauri and no Matrix dependency. The same -//! `ChatBackend` trait is implemented by `MockBackend` (here, for the prototype) -//! and later by a matrix-rust-sdk backend. The UI depends only on these types, -//! so the shell and the protocol stay swappable. +//! This crate deliberately has no Tauri or Matrix dependency. `MockBackend` +//! drives the prototype while the UI consumes generated bindings. -pub mod backend; pub mod error; pub mod event; pub mod ids; pub mod mock; pub mod model; -pub use backend::ChatBackend; pub use error::CoreError; -pub use event::{CoreEvent, SasEmoji, SyncState, TimelineChange, VerificationUpdate}; +pub use event::{CoreEvent, TimelineChange}; pub use ids::{DeviceId, MessageId, RoomId, SpaceId, UserId}; pub use mock::MockBackend; pub use model::*; diff --git a/core/src/mock.rs b/core/src/mock.rs index 450a3fd..b9e51bd 100644 --- a/core/src/mock.rs +++ b/core/src/mock.rs @@ -2,10 +2,8 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use async_trait::async_trait; use tokio::sync::broadcast; -use crate::backend::ChatBackend; use crate::error::CoreError; use crate::event::*; use crate::ids::*; @@ -26,7 +24,6 @@ struct MockState { spaces: Vec, rooms: Vec, timelines: HashMap>, - members: HashMap>, } /// In-memory backend with canned data, so the entire UI can be built and felt @@ -106,25 +103,8 @@ impl MockBackend { }); } } -} - -impl Default for MockBackend { - fn default() -> Self { - Self::new() - } -} -#[async_trait] -impl ChatBackend for MockBackend { - async fn login(&self, _req: LoginRequest) -> Result { - let st = self.state.lock().unwrap(); - Ok(SessionInfo { - user: st.me.clone(), - device_id: DeviceId::from("MOCKDEVICE"), - }) - } - - async fn restore_session(&self) -> Result, CoreError> { + pub async fn restore_session(&self) -> Result, CoreError> { let st = self.state.lock().unwrap(); Ok(Some(SessionInfo { user: st.me.clone(), @@ -132,30 +112,19 @@ impl ChatBackend for MockBackend { })) } - async fn logout(&self) -> Result<(), CoreError> { - Ok(()) - } - - async fn list_spaces(&self) -> Result, CoreError> { + pub async fn list_spaces(&self) -> Result, CoreError> { Ok(self.state.lock().unwrap().spaces.clone()) } - async fn list_rooms(&self) -> Result, CoreError> { + pub async fn list_rooms(&self) -> Result, CoreError> { Ok(self.state.lock().unwrap().rooms.clone()) } - async fn get_members(&self, room: RoomId) -> Result, CoreError> { - Ok(self - .state - .lock() - .unwrap() - .members - .get(&room) - .cloned() - .unwrap_or_default()) - } - - async fn load_timeline(&self, room: RoomId, limit: u32) -> Result { + pub async fn load_timeline( + &self, + room: RoomId, + limit: u32, + ) -> Result { let st = self.state.lock().unwrap(); let all = st.timelines.get(&room).cloned().unwrap_or_default(); let start = all.len().saturating_sub(limit as usize); @@ -165,19 +134,7 @@ impl ChatBackend for MockBackend { }) } - async fn load_older( - &self, - _room: RoomId, - _before: MessageId, - _limit: u32, - ) -> Result { - Ok(TimelineChunk { - messages: vec![], - reached_start: true, - }) - } - - async fn send_message( + pub async fn send_message( &self, room: RoomId, content: OutgoingContent, @@ -203,56 +160,7 @@ impl ChatBackend for MockBackend { Ok(id) } - async fn edit_message( - &self, - _room: RoomId, - _target: MessageId, - _content: OutgoingContent, - ) -> Result<(), CoreError> { - Ok(()) - } - - async fn redact_message(&self, _room: RoomId, _target: MessageId) -> Result<(), CoreError> { - Ok(()) - } - - async fn toggle_reaction( - &self, - _room: RoomId, - _target: MessageId, - _key: String, - ) -> Result<(), CoreError> { - Ok(()) - } - - async fn mark_read(&self, _room: RoomId, _up_to: MessageId) -> Result<(), CoreError> { - Ok(()) - } - - async fn set_typing(&self, _room: RoomId, _typing: bool) -> Result<(), CoreError> { - Ok(()) - } - - async fn upload_media(&self, _path: String) -> Result { - Ok(MediaRef { - id: self.next_id("media"), - thumbnail: None, - }) - } - - async fn request_verification(&self, _user: UserId) -> Result<(), CoreError> { - Ok(()) - } - - async fn confirm_sas(&self, _flow: String) -> Result<(), CoreError> { - Ok(()) - } - - async fn cancel_verification(&self, _flow: String) -> Result<(), CoreError> { - Ok(()) - } - - fn subscribe(&self) -> broadcast::Receiver { + pub fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } } @@ -404,7 +312,12 @@ fn build_sample() -> MockState { general.clone(), vec![ seed(&alice, "hey, welcome to the Vauxl prototype", 0, 1), - seed(&bob, "this timeline is coming from the mock backend", 60_000, 2), + seed( + &bob, + "this timeline is coming from the mock backend", + 60_000, + 2, + ), seed(&me, "and the UI never touches a key", 120_000, 3), ], ); @@ -413,28 +326,11 @@ fn build_sample() -> MockState { timelines.insert(friends_chat.clone(), vec![]); timelines.insert(dm_alice.clone(), vec![]); - let mk_member = |u: &User, power_level: i32| Member { - user: u.clone(), - membership: Membership::Joined, - power_level, - roles: vec![], - }; - let mut members: HashMap> = HashMap::new(); - members.insert( - general.clone(), - vec![ - mk_member(&me, 100), - mk_member(&alice, 50), - mk_member(&bob, 0), - ], - ); - MockState { me, spaces, rooms, timelines, - members, } } diff --git a/core/src/model.rs b/core/src/model.rs index 6b43e95..ffc8f63 100644 --- a/core/src/model.rs +++ b/core/src/model.rs @@ -5,16 +5,6 @@ use crate::ids::*; // ---------- Session and identity ---------- -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(tag = "type")] -pub enum SessionState { - LoggedOut, - Authenticating, - Recovering, - Ready { user: User }, - Error { message: String }, -} - #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct LoginRequest { pub homeserver: String, @@ -155,7 +145,9 @@ pub enum SendState { Local, Sending, Sent, - Failed { reason: String }, + Failed { + reason: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] diff --git a/docs/DATA_INTERFACE.md b/docs/DATA_INTERFACE.md index fe37176..6782e34 100644 --- a/docs/DATA_INTERFACE.md +++ b/docs/DATA_INTERFACE.md @@ -6,9 +6,9 @@ protocol (Matrix) stay swappable, and the security-critical work stays in Rust. ## Source of truth -- Rust types and the trait live in `core/src/`: +- Rust types and the prototype backend live in `core/src/`: - `model.rs` (rooms, messages, members, content), `event.rs` (reactive events), - `error.rs`, `ids.rs`, `backend.rs` (the `ChatBackend` trait). + `error.rs`, `ids.rs`, and `mock.rs` (the prototype backend). - `src/bindings.ts` is **generated** from those Rust types by `tauri-specta`. Do not edit it by hand. Regenerate with `cargo test -p vauxl-app export_bindings`. @@ -23,8 +23,7 @@ Web UI (untrusted, holds no keys) Tauri bridge (#[tauri::command] fns + emitted events) │ ▼ -Arc ── MockBackend (today: canned data, real UI) - └─ MatrixBackend (later: matrix-rust-sdk + vodozemac) +Arc (today: canned data, real UI) ``` The UI calls commands and subscribes to events. It never receives a key, an @@ -43,22 +42,17 @@ See `core/src/model.rs` for the exact fields. ## Commands (UI to core) -Each is a method on `ChatBackend` and a generated `commands.*` function. They -return `{ status: "ok", data } | { status: "error", error: CoreError }`. +Each command is a `MockBackend` method and a generated `commands.*` function. +They return `{ status: "ok", data } | { status: "error", error: CoreError }`. -| Area | Commands | -|------|----------| -| Session | `login`, `restoreSession`, `logout` | -| Spaces/rooms | `listSpaces`, `listRooms`, `getMembers` | -| Timeline | `loadTimeline`, `loadOlder`, `sendMessage`, `editMessage`, `redactMessage`, `toggleReaction`, `markRead`, `setTyping` | -| Media | `uploadMedia` | -| E2EE verification | `requestVerification`, `confirmSas`, `cancelVerification` | +- Session: `restoreSession` +- Spaces and rooms: `listSpaces`, `listRooms` +- Timeline: `loadTimeline`, `sendMessage` ## Events (core to UI) One stream, `events.coreEventMsg`, whose `payload.event` is a `CoreEvent`: -`Session`, `Sync`, `RoomUpserted`, `RoomRemoved`, `SpaceUpserted`, `Timeline` -(`Added` / `Updated` / `Removed`), `Typing`, `Receipt`, `Presence`, `Verification`. +`Timeline` (`Added` / `Updated` / `Removed`) or `Typing`. The UI is fully reactive: it calls a command, then reflects the resulting events. For example `sendMessage` returns the new id, and the appended message arrives as @@ -68,14 +62,12 @@ a `Timeline` `Added` event. This is why the core is Rust and the UI is treated as untrusted: -- **No keys in the UI.** Sessions, tokens, and crypto live behind the trait. +- **No keys in the UI.** Sessions, tokens, and crypto stay in Rust. - **`SafeHtml`** is constructed only after sanitizing in the core. The UI renders it without re-sanitizing; raw HTML never crosses the boundary. - **`MediaRef`** is an opaque handle. Encrypted media is fetched and decrypted in Rust and served to the webview via a custom protocol (for example `vauxl-media://{id}`); the UI never holds media keys. -- **Password** crosses once into `login` and is never retained by the UI. OIDC/SSO - (password never touches our code) is the more secure path to add later. - **`CoreError::Internal`** carries no detail; internal errors are logged in the core, not leaked to the UI. @@ -90,9 +82,16 @@ Follow-ups before this is production grade: homeserver still sees who is in which rooms and timing. Self-hosting for you and your friends keeps that metadata on infrastructure you control. -## Swapping the mock for Matrix +## Replacing the mock with Matrix -1. Add a `MatrixBackend` in `core` implementing `ChatBackend` over - `matrix-rust-sdk`. Reuse its `vodozemac` crypto; write none yourself. -2. Change the constructor in `src-tauri/src/lib.rs` `run` from `MockBackend::new()` - to your `MatrixBackend`. The UI and the bindings are unchanged. +1. Implement the five commands used by the UI with `matrix-rust-sdk`. +2. Update `AppState` and the constructor in `src-tauri/src/lib.rs`. +3. Add a shared backend trait only if the mock and Matrix implementations must + coexist. + +The prototype does not preserve placeholder commands or events. Consumers of +older generated bindings must remove calls and handlers that are no longer +exported. Rust consumers must replace `Arc` with +`Arc`. Restore an operation only with its backend behavior, Tauri +wrapper, and UI caller, then regenerate the bindings. Add a shared trait when a +second backend needs the same operations. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1dc3d2f..546903c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,7 +16,6 @@ tauri-build = { version = "2.5.1" } [dependencies] vauxl-core = { path = "../core" } -serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.9.2" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9dd7104..db0e58e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,8 @@ //! Tauri shell for the Vauxl client. //! -//! This layer is thin on purpose: it owns the window, exposes the `ChatBackend` -//! contract to the webview as typed commands and events, and forwards core -//! events to the UI. All domain and (later) security-critical logic lives in -//! `vauxl-core`. Today the backend is `MockBackend`; swapping in a -//! matrix-rust-sdk backend changes only the constructor in `run`. +//! This layer owns the window, exposes the prototype commands and events, and +//! forwards core events to the UI. All domain and future security-critical +//! logic lives in `vauxl-core`. use std::sync::Arc; @@ -14,14 +12,14 @@ use tauri::Manager; use tauri_specta::{collect_commands, collect_events, Builder, Event}; use vauxl_core::{ - ChatBackend, CoreError, CoreEvent, LoginRequest, MediaRef, Member, MessageId, MockBackend, - OutgoingContent, Room, RoomId, SessionInfo, Space, TimelineChunk, UserId, + CoreError, CoreEvent, MessageId, MockBackend, OutgoingContent, Room, RoomId, SessionInfo, + Space, TimelineChunk, }; /// Held in Tauri-managed state. Commands clone the `Arc` out before awaiting so /// the state guard is never held across an await point. struct AppState { - backend: Arc, + backend: Arc, } /// Wrapper so the core's `CoreEvent` can be a typed tauri-specta event without @@ -31,17 +29,7 @@ pub struct CoreEventMsg { pub event: CoreEvent, } -// ---------- Commands (thin wrappers over the ChatBackend trait) ---------- - -#[tauri::command] -#[specta::specta] -async fn login( - state: tauri::State<'_, AppState>, - req: LoginRequest, -) -> Result { - let backend = state.backend.clone(); - backend.login(req).await -} +// ---------- Commands ---------- #[tauri::command] #[specta::specta] @@ -52,13 +40,6 @@ async fn restore_session( backend.restore_session().await } -#[tauri::command] -#[specta::specta] -async fn logout(state: tauri::State<'_, AppState>) -> Result<(), CoreError> { - let backend = state.backend.clone(); - backend.logout().await -} - #[tauri::command] #[specta::specta] async fn list_spaces(state: tauri::State<'_, AppState>) -> Result, CoreError> { @@ -73,16 +54,6 @@ async fn list_rooms(state: tauri::State<'_, AppState>) -> Result, Core backend.list_rooms().await } -#[tauri::command] -#[specta::specta] -async fn get_members( - state: tauri::State<'_, AppState>, - room: RoomId, -) -> Result, CoreError> { - let backend = state.backend.clone(); - backend.get_members(room).await -} - #[tauri::command] #[specta::specta] async fn load_timeline( @@ -94,18 +65,6 @@ async fn load_timeline( backend.load_timeline(room, limit).await } -#[tauri::command] -#[specta::specta] -async fn load_older( - state: tauri::State<'_, AppState>, - room: RoomId, - before: MessageId, - limit: u32, -) -> Result { - let backend = state.backend.clone(); - backend.load_older(room, before, limit).await -} - #[tauri::command] #[specta::specta] async fn send_message( @@ -117,138 +76,30 @@ async fn send_message( backend.send_message(room, content).await } -#[tauri::command] -#[specta::specta] -async fn edit_message( - state: tauri::State<'_, AppState>, - room: RoomId, - target: MessageId, - content: OutgoingContent, -) -> Result<(), CoreError> { - let backend = state.backend.clone(); - backend.edit_message(room, target, content).await -} - -#[tauri::command] -#[specta::specta] -async fn redact_message( - state: tauri::State<'_, AppState>, - room: RoomId, - target: MessageId, -) -> Result<(), CoreError> { - let backend = state.backend.clone(); - backend.redact_message(room, target).await -} - -#[tauri::command] -#[specta::specta] -async fn toggle_reaction( - state: tauri::State<'_, AppState>, - room: RoomId, - target: MessageId, - key: String, -) -> Result<(), CoreError> { - let backend = state.backend.clone(); - backend.toggle_reaction(room, target, key).await -} - -#[tauri::command] -#[specta::specta] -async fn mark_read( - state: tauri::State<'_, AppState>, - room: RoomId, - up_to: MessageId, -) -> Result<(), CoreError> { - let backend = state.backend.clone(); - backend.mark_read(room, up_to).await -} - -#[tauri::command] -#[specta::specta] -async fn set_typing( - state: tauri::State<'_, AppState>, - room: RoomId, - typing: bool, -) -> Result<(), CoreError> { - let backend = state.backend.clone(); - backend.set_typing(room, typing).await -} - -#[tauri::command] -#[specta::specta] -async fn upload_media( - state: tauri::State<'_, AppState>, - path: String, -) -> Result { - let backend = state.backend.clone(); - backend.upload_media(path).await -} - -#[tauri::command] -#[specta::specta] -async fn request_verification( - state: tauri::State<'_, AppState>, - user: UserId, -) -> Result<(), CoreError> { - let backend = state.backend.clone(); - backend.request_verification(user).await -} - -#[tauri::command] -#[specta::specta] -async fn confirm_sas(state: tauri::State<'_, AppState>, flow: String) -> Result<(), CoreError> { - let backend = state.backend.clone(); - backend.confirm_sas(flow).await -} - -#[tauri::command] -#[specta::specta] -async fn cancel_verification( - state: tauri::State<'_, AppState>, - flow: String, -) -> Result<(), CoreError> { - let backend = state.backend.clone(); - backend.cancel_verification(flow).await -} - // ---------- Builder, bindings export, app entry ---------- fn specta_builder() -> Builder { Builder::::new() .commands(collect_commands![ - login, restore_session, - logout, list_spaces, list_rooms, - get_members, load_timeline, - load_older, send_message, - edit_message, - redact_message, - toggle_reaction, - mark_read, - set_typing, - upload_media, - request_verification, - confirm_sas, - cancel_verification, ]) .events(collect_events![CoreEventMsg]) } -fn ts() -> specta_typescript::Typescript { - specta_typescript::Typescript::default() -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = specta_builder(); #[cfg(debug_assertions)] builder - .export(ts(), "../src/bindings.ts") + .export( + specta_typescript::Typescript::default(), + "../src/bindings.ts", + ) .expect("failed to export typescript bindings"); tauri::Builder::default() @@ -281,8 +132,7 @@ pub fn run() { // Fake live traffic so the prototype feels alive. tauri::async_runtime::spawn(mock.clone().run_demo_traffic()); - let backend: Arc = mock; - app.manage(AppState { backend }); + app.manage(AppState { backend: mock }); Ok(()) }) .run(tauri::generate_context!()) @@ -296,7 +146,10 @@ mod tests { #[test] fn export_bindings() { specta_builder() - .export(ts(), "../src/bindings.ts") + .export( + specta_typescript::Typescript::default(), + "../src/bindings.ts", + ) .expect("failed to export bindings"); } } diff --git a/src/bindings.ts b/src/bindings.ts index 267a66b..a9c9eb7 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5,27 +5,14 @@ import * as __TAURI_EVENT from "@tauri-apps/api/event"; /** Commands */ export const commands = { - login: (req: LoginRequest) => typedError(__TAURI_INVOKE("login", { req })), restoreSession: () => typedError<{ user: User, device_id: DeviceId, } | null, CoreError>(__TAURI_INVOKE("restore_session")), - logout: () => typedError(__TAURI_INVOKE("logout")), listSpaces: () => typedError(__TAURI_INVOKE("list_spaces")), listRooms: () => typedError(__TAURI_INVOKE("list_rooms")), - getMembers: (room: RoomId) => typedError(__TAURI_INVOKE("get_members", { room })), loadTimeline: (room: RoomId, limit: number) => typedError(__TAURI_INVOKE("load_timeline", { room, limit })), - loadOlder: (room: RoomId, before: MessageId, limit: number) => typedError(__TAURI_INVOKE("load_older", { room, before, limit })), sendMessage: (room: RoomId, content: OutgoingContent) => typedError(__TAURI_INVOKE("send_message", { room, content })), - editMessage: (room: RoomId, target: MessageId, content: OutgoingContent) => typedError(__TAURI_INVOKE("edit_message", { room, target, content })), - redactMessage: (room: RoomId, target: MessageId) => typedError(__TAURI_INVOKE("redact_message", { room, target })), - toggleReaction: (room: RoomId, target: MessageId, key: string) => typedError(__TAURI_INVOKE("toggle_reaction", { room, target, key })), - markRead: (room: RoomId, upTo: MessageId) => typedError(__TAURI_INVOKE("mark_read", { room, upTo })), - setTyping: (room: RoomId, typing: boolean) => typedError(__TAURI_INVOKE("set_typing", { room, typing })), - uploadMedia: (path: string) => typedError(__TAURI_INVOKE("upload_media", { path })), - requestVerification: (user: UserId) => typedError(__TAURI_INVOKE("request_verification", { user })), - confirmSas: (flow: string) => typedError(__TAURI_INVOKE("confirm_sas", { flow })), - cancelVerification: (flow: string) => typedError(__TAURI_INVOKE("cancel_verification", { flow })), }; /** Events */ @@ -44,7 +31,7 @@ export type CoreError = { type: "Auth"; message: string } | { type: "Network" } * Reactive updates pushed from the core to the UI. The Tauri layer forwards * each of these to the webview as an event. */ -export type CoreEvent = { type: "Session"; state: SessionState } | { type: "Sync"; state: SyncState } | { type: "RoomUpserted"; room: Room } | { type: "RoomRemoved"; room: RoomId } | { type: "SpaceUpserted"; space: Space } | { type: "Timeline"; room: RoomId; change: TimelineChange } | { type: "Typing"; room: RoomId; users: UserId[] } | { type: "Receipt"; room: RoomId; user: UserId; up_to: MessageId } | { type: "Presence"; user: User } | { type: "Verification"; update: VerificationUpdate }; +export type CoreEvent = { type: "Timeline"; room: RoomId; change: TimelineChange } | { type: "Typing"; room: RoomId; users: UserId[] }; /** * Wrapper so the core's `CoreEvent` can be a typed tauri-specta event without @@ -60,17 +47,6 @@ export type EncryptionState = "Unencrypted" | "Encrypted" | /** Encrypted, but unverified devices are present (show a shield warning). */ "EncryptedUnverified"; -export type LoginRequest = { - homeserver: string, - username: string, - /** - * Crosses the boundary once, into the core. The core persists the session, - * never the password. OIDC/SSO (where the password never touches our code) - * is the more secure path to add later. - */ - password: string, -}; - /** * Opaque handle. The UI never fetches from the homeserver and never holds media * keys: it renders via a custom protocol the Rust side serves (for example @@ -82,16 +58,6 @@ export type MediaRef = { thumbnail: string | null, }; -export type Member = { - user: User, - membership: Membership, - /** Canonical permission weight (a Matrix power level maps in here). */ - power_level: number, - roles: string[], -}; - -export type Membership = "Joined" | "Invited" | "Left" | "Banned"; - export type Message = { id: MessageId, room: RoomId, @@ -150,11 +116,6 @@ export type RoomKind = "Text" | "Voice" | "Announcement" | "DirectMessage"; */ export type SafeHtml = string; -export type SasEmoji = { - symbol: string, - name: string, -}; - export type SendState = /** Optimistic local echo, not yet acknowledged. */ { type: "Local" } | { type: "Sending" } | { type: "Sent" } | { type: "Failed"; reason: string }; @@ -166,8 +127,6 @@ export type SessionInfo = { device_id: DeviceId, }; -export type SessionState = { type: "LoggedOut" } | { type: "Authenticating" } | { type: "Recovering" } | { type: "Ready"; user: User } | { type: "Error"; message: string }; - export type Space = { id: SpaceId, name: string, @@ -178,8 +137,6 @@ export type Space = { export type SpaceId = string; -export type SyncState = { type: "Offline" } | { type: "Connecting" } | { type: "Syncing" } | { type: "Live" } | { type: "Error"; message: string }; - export type TimelineChange = { op: "Added"; message: Message } | { op: "Updated"; message: Message } | { op: "Removed"; id: MessageId }; export type TimelineChunk = { @@ -203,8 +160,6 @@ export type User = { export type UserId = string; -export type VerificationUpdate = { type: "Requested"; from: UserId; flow: string } | { type: "ShowSas"; flow: string; emoji: SasEmoji[] } | { type: "Done"; flow: string } | { type: "Cancelled"; flow: string; reason: string }; - /* Tauri Specta runtime */ async function typedError(result: Promise): Promise<{ status: "ok"; data: T } | { status: "error"; error: E }> { try { diff --git a/tailwind.config.js b/tailwind.config.js index 878997c..23134a2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,9 +1,4 @@ /** @type {import('tailwindcss').Config} */ export default { content: ["./index.html", "./src/**/*.{ts,tsx}"], - darkMode: "class", - theme: { - extend: {}, - }, - plugins: [], }; diff --git a/tsconfig.json b/tsconfig.json index 7abb60e..e2ea951 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,17 +6,12 @@ "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, "noFallthroughCasesInSwitch": true }, - "include": ["src"], - "exclude": ["node_modules", "dist", "build", "src/bun", "src/mainview"] + "include": ["src"] } From c590c56760d613cb93caded8559e537f955b5fb6 Mon Sep 17 00:00:00 2001 From: kyle Date: Mon, 3 Aug 2026 09:36:22 +0200 Subject: [PATCH 2/5] refactor(core): remove orphaned model types --- core/src/model.rs | 27 --------------------------- docs/DATA_INTERFACE.md | 2 +- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/core/src/model.rs b/core/src/model.rs index ffc8f63..4dd937d 100644 --- a/core/src/model.rs +++ b/core/src/model.rs @@ -5,16 +5,6 @@ use crate::ids::*; // ---------- Session and identity ---------- -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct LoginRequest { - pub homeserver: String, - pub username: String, - /// Crosses the boundary once, into the core. The core persists the session, - /// never the password. OIDC/SSO (where the password never touches our code) - /// is the more secure path to add later. - pub password: String, -} - #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct SessionInfo { pub user: User, @@ -38,23 +28,6 @@ pub struct User { pub status_message: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub enum Membership { - Joined, - Invited, - Left, - Banned, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct Member { - pub user: User, - pub membership: Membership, - /// Canonical permission weight (a Matrix power level maps in here). - pub power_level: i32, - pub roles: Vec, -} - // ---------- Spaces and rooms (Discord on Matrix) ---------- #[derive(Debug, Clone, Serialize, Deserialize, Type)] diff --git a/docs/DATA_INTERFACE.md b/docs/DATA_INTERFACE.md index 6782e34..c11ce25 100644 --- a/docs/DATA_INTERFACE.md +++ b/docs/DATA_INTERFACE.md @@ -7,7 +7,7 @@ protocol (Matrix) stay swappable, and the security-critical work stays in Rust. ## Source of truth - Rust types and the prototype backend live in `core/src/`: - - `model.rs` (rooms, messages, members, content), `event.rs` (reactive events), + - `model.rs` (rooms, messages, content), `event.rs` (reactive events), `error.rs`, `ids.rs`, and `mock.rs` (the prototype backend). - `src/bindings.ts` is **generated** from those Rust types by `tauri-specta`. Do not edit it by hand. Regenerate with `cargo test -p vauxl-app export_bindings`. From 962e455513652d696e7666a66888705f6b9ec306 Mon Sep 17 00:00:00 2001 From: kyle Date: Mon, 3 Aug 2026 11:31:28 +0200 Subject: [PATCH 3/5] feat: add mock message actions --- README.md | 13 +- core/src/mock.rs | 618 +++++++++++++++++++++++++++++++++++++++-- docs/DATA_INTERFACE.md | 40 ++- src-tauri/src/lib.rs | 38 +++ src/App.tsx | 166 ++++++++++- src/bindings.ts | 3 + 6 files changed, 821 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 6db9a38..6f44c7a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Vauxl Client A Discord-style Matrix client. Tauri v2 shell, React (web) UI, and a Rust core. -Today it runs entirely on an in-memory `MockBackend`, so the whole interface can -be built and felt before any Matrix code exists. +Today it runs entirely on an in-memory `MockBackend`. Replies, edits, own-message +redaction, and reactions are functional mock behavior only. They are not +homeserver operations or real Matrix interoperability. ## Layout @@ -49,7 +50,7 @@ bun run tauri build # produces a packaged desktop bundle ## Replacing the mock with Matrix -Implement the five commands used by the UI with `matrix-rust-sdk`, then update -`AppState` and the constructor in `src-tauri/src/lib.rs`. Add a shared backend -trait only if the mock and Matrix implementations must coexist. Crypto stays in -`vodozemac` through the SDK. +Implement the current command set used by the UI with `matrix-rust-sdk`, then +update `AppState` and the constructor in `src-tauri/src/lib.rs`. Add a shared +backend trait only when a second backend exists. Crypto stays in `vodozemac` +through the SDK. diff --git a/core/src/mock.rs b/core/src/mock.rs index b9e51bd..fbe2f27 100644 --- a/core/src/mock.rs +++ b/core/src/mock.rs @@ -10,6 +10,7 @@ use crate::ids::*; use crate::model::*; const BASE_TS: u64 = 1_747_000_000_000; +type ReactionIdentity = (RoomId, MessageId, UserId, String); fn now_ms() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; @@ -24,6 +25,7 @@ struct MockState { spaces: Vec, rooms: Vec, timelines: HashMap>, + reaction_events: HashMap, } /// In-memory backend with canned data, so the entire UI can be built and felt @@ -49,7 +51,13 @@ impl MockBackend { format!("{prefix}{n}") } - fn make_message(&self, room: &RoomId, sender: &UserId, body: &str) -> Message { + fn make_message( + &self, + room: &RoomId, + sender: &UserId, + body: &str, + reply_to: Option, + ) -> Message { Message { id: MessageId::from(self.next_id("m")), room: room.clone(), @@ -60,7 +68,7 @@ impl MockBackend { formatted: None, }, reactions: vec![], - reply_to: None, + reply_to, edited: false, send_state: SendState::Sent, sender_trust: SenderTrust::Unverified, @@ -85,7 +93,7 @@ impl MockBackend { users: vec![alice.clone()], }); tokio::time::sleep(std::time::Duration::from_secs(2)).await; - let msg = self.make_message(&room, &alice, "ping from the mock backend"); + let msg = self.make_message(&room, &alice, "ping from the mock backend", None); self.state .lock() .unwrap() @@ -139,27 +147,158 @@ impl MockBackend { room: RoomId, content: OutgoingContent, ) -> Result { - let body = match content { - OutgoingContent::Text { body, .. } => body, - OutgoingContent::Media { caption, .. } => caption.unwrap_or_else(|| "[media]".into()), + let (body, reply_to) = match content { + OutgoingContent::Text { + body, + formatted: None, + reply_to, + } => (body, reply_to), + _ => return Err(CoreError::Forbidden), + }; + let msg = { + let mut st = self.state.lock().unwrap(); + if !st.rooms.iter().any(|r| r.id == room) { + return Err(CoreError::NotFound); + } + if let Some(target) = &reply_to { + if find_message(&st, &room, target).is_none() { + return Err(CoreError::NotFound); + } + } + let me_id = st.me.id.clone(); + let msg = self.make_message(&room, &me_id, &body, reply_to); + st.timelines + .entry(room.clone()) + .or_default() + .push(msg.clone()); + msg }; - let me_id = self.state.lock().unwrap().me.id.clone(); - let msg = self.make_message(&room, &me_id, &body); - self.state - .lock() - .unwrap() - .timelines - .entry(room.clone()) - .or_default() - .push(msg.clone()); let id = msg.id.clone(); let _ = self.tx.send(CoreEvent::Timeline { - room, + room: room.clone(), change: TimelineChange::Added { message: msg }, }); Ok(id) } + pub async fn edit_message( + &self, + room: RoomId, + target: MessageId, + content: OutgoingContent, + ) -> Result<(), CoreError> { + let body = match content { + OutgoingContent::Text { + body, + formatted: None, + .. + } => body, + _ => return Err(CoreError::Forbidden), + }; + let updated = { + let mut st = self.state.lock().unwrap(); + let me_id = st.me.id.clone(); + let message = find_message_mut(&mut st, &room, &target).ok_or(CoreError::NotFound)?; + if message.sender != me_id + || matches!(&message.content, MessageContent::Redacted) + || !matches!(&message.content, MessageContent::Text { .. }) + { + return Err(CoreError::Forbidden); + } + message.content = MessageContent::Text { + body, + formatted: None, + }; + message.edited = true; + message.clone() + }; + let _ = self.tx.send(CoreEvent::Timeline { + room, + change: TimelineChange::Updated { message: updated }, + }); + Ok(()) + } + + pub async fn redact_message(&self, room: RoomId, target: MessageId) -> Result<(), CoreError> { + let updated = { + let mut st = self.state.lock().unwrap(); + let me_id = st.me.id.clone(); + let updated = { + let message = + find_message_mut(&mut st, &room, &target).ok_or(CoreError::NotFound)?; + if message.sender != me_id || matches!(&message.content, MessageContent::Redacted) { + return Err(CoreError::Forbidden); + } + message.content = MessageContent::Redacted; + message.reactions.clear(); + message.reply_to = None; + message.edited = false; + message.clone() + }; + st.reaction_events + .retain(|(reaction_room, message_id, _, _), _| { + reaction_room != &room || message_id != &target + }); + updated + }; + let _ = self.tx.send(CoreEvent::Timeline { + room, + change: TimelineChange::Updated { message: updated }, + }); + Ok(()) + } + + pub async fn toggle_reaction( + &self, + room: RoomId, + target: MessageId, + key: String, + ) -> Result<(), CoreError> { + let updated = { + let mut st = self.state.lock().unwrap(); + let me_id = st.me.id.clone(); + let message = find_message(&st, &room, &target).ok_or(CoreError::NotFound)?; + if matches!(&message.content, MessageContent::Redacted) { + return Err(CoreError::Forbidden); + } + + let identity = (room.clone(), target.clone(), me_id, key.clone()); + if st.reaction_events.contains_key(&identity) { + st.reaction_events.remove(&identity); + } else { + st.reaction_events + .insert(identity.clone(), MessageId::from(self.next_id("r"))); + } + let count = st + .reaction_events + .iter() + .filter(|((reaction_room, message_id, _, reaction_key), _)| { + reaction_room == &room && message_id == &target && reaction_key == &key + }) + .count() as u32; + let me_active = st.reaction_events.contains_key(&identity); + let message = find_message_mut(&mut st, &room, &target).expect("validated message"); + if count == 0 { + message.reactions.retain(|reaction| reaction.key != key); + } else if let Some(reaction) = message.reactions.iter_mut().find(|r| r.key == key) { + reaction.count = count; + reaction.me = me_active; + } else { + message.reactions.push(Reaction { + key, + count, + me: me_active, + }); + } + message.clone() + }; + let _ = self.tx.send(CoreEvent::Timeline { + room, + change: TimelineChange::Updated { message: updated }, + }); + Ok(()) + } + pub fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } @@ -331,20 +470,87 @@ fn build_sample() -> MockState { spaces, rooms, timelines, + reaction_events: HashMap::new(), } } +fn find_message<'a>(st: &'a MockState, room: &RoomId, target: &MessageId) -> Option<&'a Message> { + st.timelines + .get(room)? + .iter() + .find(|message| &message.id == target && &message.room == room) +} + +fn find_message_mut<'a>( + st: &'a mut MockState, + room: &RoomId, + target: &MessageId, +) -> Option<&'a mut Message> { + st.timelines + .get_mut(room)? + .iter_mut() + .find(|message| &message.id == target && &message.room == room) +} + #[cfg(test)] mod tests { use super::*; + async fn timeline(backend: &MockBackend, room: RoomId) -> Vec { + backend.load_timeline(room, 50).await.unwrap().messages + } + + async fn snapshot(backend: &MockBackend, room: RoomId) -> String { + format!("{:?}", timeline(backend, room).await) + } + + async fn next_event(rx: &mut broadcast::Receiver) -> CoreEvent { + tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("event timed out") + .expect("recv failed") + } + + async fn assert_no_event(rx: &mut broadcast::Receiver) { + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()) + .await + .is_err() + ); + } + + async fn expect_updated( + rx: &mut broadcast::Receiver, + target: &MessageId, + ) -> Message { + match next_event(rx).await { + CoreEvent::Timeline { + change: TimelineChange::Updated { message }, + .. + } => { + assert_eq!(&message.id, target); + message + } + other => panic!("unexpected event: {other:?}"), + } + } + + fn text(body: &str) -> OutgoingContent { + OutgoingContent::Text { + body: body.into(), + formatted: None, + reply_to: None, + } + } + #[tokio::test] - async fn mock_send_appends_and_emits() { + async fn mock_send_preserves_reply_to_and_emits_added() { let b = MockBackend::new(); let rooms = b.list_rooms().await.unwrap(); assert!(!rooms.is_empty()); let room = rooms[0].id.clone(); - let before = b.load_timeline(room.clone(), 50).await.unwrap(); + let target = MessageId::from("m1"); + let me = b.restore_session().await.unwrap().unwrap().user.id; let mut rx = b.subscribe(); let id = b @@ -353,25 +559,381 @@ mod tests { OutgoingContent::Text { body: "hi".into(), formatted: None, - reply_to: None, + reply_to: Some(target.clone()), }, ) .await .unwrap(); - let ev = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) - .await - .expect("event timed out") - .expect("recv failed"); - match ev { + match next_event(&mut rx).await { CoreEvent::Timeline { + room: event_room, change: TimelineChange::Added { message }, - .. - } => assert_eq!(message.id, id), + } => { + assert_eq!(event_room, room); + assert_eq!(message.id, id); + assert_eq!(message.sender, me); + assert_eq!(message.reply_to, Some(target.clone())); + } other => panic!("unexpected event: {other:?}"), } - let after = b.load_timeline(room, 50).await.unwrap(); - assert_eq!(after.messages.len(), before.messages.len() + 1); + let stored = timeline(&b, room).await; + let message = stored.iter().find(|message| message.id == id).unwrap(); + assert_eq!(message.reply_to, Some(target)); + assert_eq!(message.sender, me); + } + + #[tokio::test] + async fn mock_send_rejects_missing_cross_room_reply_and_media() { + let b = MockBackend::new(); + let rooms = b.list_rooms().await.unwrap(); + let room = rooms[0].id.clone(); + let other_room = rooms[1].id.clone(); + let before = snapshot(&b, room.clone()).await; + let before_other = snapshot(&b, other_room.clone()).await; + let mut rx = b.subscribe(); + + assert!(matches!( + b.send_message( + room.clone(), + OutgoingContent::Text { + body: "missing".into(), + formatted: None, + reply_to: Some(MessageId::from("missing")), + }, + ) + .await, + Err(CoreError::NotFound) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.send_message( + other_room.clone(), + OutgoingContent::Text { + body: "cross room".into(), + formatted: None, + reply_to: Some(MessageId::from("m1")), + }, + ) + .await, + Err(CoreError::NotFound) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.send_message( + room.clone(), + OutgoingContent::Media { + upload: MediaRef { + id: "upload".into(), + thumbnail: None, + }, + caption: None, + }, + ) + .await, + Err(CoreError::Forbidden) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.send_message( + room.clone(), + OutgoingContent::Text { + body: "formatted".into(), + formatted: Some("raw".into()), + reply_to: None, + }, + ) + .await, + Err(CoreError::Forbidden) + )); + assert_no_event(&mut rx).await; + + assert_eq!(snapshot(&b, room).await, before); + assert_eq!(snapshot(&b, other_room).await, before_other); + } + + #[tokio::test] + async fn mock_edit_updates_owned_message_and_emits_updated() { + let b = MockBackend::new(); + let room = b.list_rooms().await.unwrap()[0].id.clone(); + let target = MessageId::from("m1"); + let id = b + .send_message( + room.clone(), + OutgoingContent::Text { + body: "before".into(), + formatted: None, + reply_to: Some(target.clone()), + }, + ) + .await + .unwrap(); + let mut rx = b.subscribe(); + + b.edit_message(room.clone(), id.clone(), text("after")) + .await + .unwrap(); + let message = expect_updated(&mut rx, &id).await; + assert!(message.edited); + assert_eq!(message.reply_to, Some(target)); + match &message.content { + MessageContent::Text { body, formatted } => { + assert_eq!(body, "after"); + assert!(formatted.is_none()); + } + other => panic!("unexpected content: {other:?}"), + } + + let stored = timeline(&b, room).await; + let stored = stored.iter().find(|message| message.id == id).unwrap(); + assert_eq!(stored.id, id); + assert!(stored.edited); + } + + #[tokio::test] + async fn mock_edit_rejects_missing_cross_room_wrong_owner_and_redacted() { + let b = MockBackend::new(); + let rooms = b.list_rooms().await.unwrap(); + let room = rooms[0].id.clone(); + let other_room = rooms[1].id.clone(); + let redacted = MessageId::from("m3"); + b.redact_message(room.clone(), redacted.clone()) + .await + .unwrap(); + let before = snapshot(&b, room.clone()).await; + let mut rx = b.subscribe(); + + assert!(matches!( + b.edit_message(room.clone(), MessageId::from("missing"), text("x")) + .await, + Err(CoreError::NotFound) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.edit_message(other_room, redacted.clone(), text("x")) + .await, + Err(CoreError::NotFound) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.edit_message(room.clone(), MessageId::from("m1"), text("x")) + .await, + Err(CoreError::Forbidden) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.edit_message(room.clone(), redacted, text("x")).await, + Err(CoreError::Forbidden) + )); + assert_no_event(&mut rx).await; + assert_eq!(snapshot(&b, room).await, before); + } + + #[tokio::test] + async fn mock_redact_preserves_placeholder_and_cleans_reactions() { + let b = MockBackend::new(); + let room = b.list_rooms().await.unwrap()[0].id.clone(); + let id = b + .send_message( + room.clone(), + OutgoingContent::Text { + body: "delete me".into(), + formatted: None, + reply_to: Some(MessageId::from("m1")), + }, + ) + .await + .unwrap(); + b.toggle_reaction(room.clone(), id.clone(), "👍".into()) + .await + .unwrap(); + let before_len = timeline(&b, room.clone()).await.len(); + let mut rx = b.subscribe(); + + b.redact_message(room.clone(), id.clone()).await.unwrap(); + let message = expect_updated(&mut rx, &id).await; + assert!(matches!(&message.content, MessageContent::Redacted)); + assert!(message.reactions.is_empty()); + assert!(message.reply_to.is_none()); + assert!(!message.edited); + assert_eq!(timeline(&b, room.clone()).await.len(), before_len); + assert!(!b.state.lock().unwrap().reaction_events.keys().any( + |(reaction_room, message_id, _, _)| { reaction_room == &room && message_id == &id } + )); + assert_no_event(&mut rx).await; + } + + #[tokio::test] + async fn mock_redact_rejects_missing_cross_room_wrong_owner_and_already_redacted() { + let b = MockBackend::new(); + let rooms = b.list_rooms().await.unwrap(); + let room = rooms[0].id.clone(); + let other_room = rooms[1].id.clone(); + let redacted = MessageId::from("m3"); + b.redact_message(room.clone(), redacted.clone()) + .await + .unwrap(); + let before = snapshot(&b, room.clone()).await; + let mut rx = b.subscribe(); + + assert!(matches!( + b.redact_message(room.clone(), MessageId::from("missing")) + .await, + Err(CoreError::NotFound) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.redact_message(other_room, redacted.clone()).await, + Err(CoreError::NotFound) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.redact_message(room.clone(), MessageId::from("m1")).await, + Err(CoreError::Forbidden) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.redact_message(room.clone(), redacted).await, + Err(CoreError::Forbidden) + )); + assert_no_event(&mut rx).await; + assert_eq!(snapshot(&b, room).await, before); + } + + #[tokio::test] + async fn mock_toggle_reaction_adds_deduplicates_and_toggles_off() { + let b = MockBackend::new(); + let room = b.list_rooms().await.unwrap()[0].id.clone(); + let target = MessageId::from("m1"); + let me = b.restore_session().await.unwrap().unwrap().user.id; + let key = "👍".to_string(); + let mut rx = b.subscribe(); + + b.toggle_reaction(room.clone(), target.clone(), key.clone()) + .await + .unwrap(); + let added = expect_updated(&mut rx, &target).await; + assert_eq!(added.reactions.len(), 1); + assert_eq!(added.reactions[0].key, key); + assert_eq!(added.reactions[0].count, 1); + assert!(added.reactions[0].me); + let identity = (room.clone(), target.clone(), me.clone(), key.clone()); + let first_id = b + .state + .lock() + .unwrap() + .reaction_events + .get(&identity) + .unwrap() + .clone(); + + b.toggle_reaction(room.clone(), target.clone(), key.clone()) + .await + .unwrap(); + let removed = expect_updated(&mut rx, &target).await; + assert!(removed.reactions.is_empty()); + let st = b.state.lock().unwrap(); + assert!(!st.reaction_events.contains_key(&identity)); + assert!(!st.reaction_events.values().any(|id| id == &first_id)); + drop(st); + + b.toggle_reaction(room.clone(), target.clone(), key.clone()) + .await + .unwrap(); + let added_again = expect_updated(&mut rx, &target).await; + assert_eq!(added_again.reactions[0].count, 1); + assert!(added_again.reactions[0].me); + let second_id = b + .state + .lock() + .unwrap() + .reaction_events + .get(&identity) + .unwrap() + .clone(); + assert_ne!(first_id, second_id); + + let other_identity = ( + room.clone(), + target.clone(), + UserId::from("@alice:vauxl.local"), + key.clone(), + ); + b.state + .lock() + .unwrap() + .reaction_events + .insert(other_identity.clone(), MessageId::from("ralice")); + + b.toggle_reaction(room.clone(), target.clone(), key.clone()) + .await + .unwrap(); + let other_only = expect_updated(&mut rx, &target).await; + assert_eq!(other_only.reactions[0].count, 1); + assert!(!other_only.reactions[0].me); + + b.toggle_reaction(room.clone(), target.clone(), key) + .await + .unwrap(); + let both = expect_updated(&mut rx, &target).await; + assert_eq!(both.reactions.len(), 1); + assert_eq!(both.reactions[0].count, 2); + assert!(both.reactions[0].me); + let st = b.state.lock().unwrap(); + assert_eq!(st.reaction_events.len(), 2); + assert!(st.reaction_events.contains_key(&other_identity)); + } + + #[tokio::test] + async fn mock_toggle_reaction_rejects_missing_cross_room_and_redacted() { + let b = MockBackend::new(); + let rooms = b.list_rooms().await.unwrap(); + let room = rooms[0].id.clone(); + let other_room = rooms[1].id.clone(); + let redacted = MessageId::from("m3"); + b.redact_message(room.clone(), redacted.clone()) + .await + .unwrap(); + let before = snapshot(&b, room.clone()).await; + let mut rx = b.subscribe(); + + assert!(matches!( + b.toggle_reaction(room.clone(), MessageId::from("missing"), "👍".into()) + .await, + Err(CoreError::NotFound) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.toggle_reaction(other_room, MessageId::from("m1"), "👍".into()) + .await, + Err(CoreError::NotFound) + )); + assert_no_event(&mut rx).await; + + assert!(matches!( + b.toggle_reaction(room.clone(), redacted, "👍".into()).await, + Err(CoreError::Forbidden) + )); + assert_no_event(&mut rx).await; + assert_eq!(snapshot(&b, room.clone()).await, before); + + b.toggle_reaction(room.clone(), MessageId::from("m1"), "👍".into()) + .await + .unwrap(); + let message = expect_updated(&mut rx, &MessageId::from("m1")).await; + assert_eq!(message.sender, UserId::from("@alice:vauxl.local")); + assert_eq!(message.reactions[0].count, 1); + assert!(message.reactions[0].me); } } diff --git a/docs/DATA_INTERFACE.md b/docs/DATA_INTERFACE.md index c11ce25..ecf89ee 100644 --- a/docs/DATA_INTERFACE.md +++ b/docs/DATA_INTERFACE.md @@ -1,8 +1,8 @@ # Data Interface Contract -This is the contract between the web UI and the Rust core. It is the keystone of -the client: the UI depends only on this contract, so the shell (Tauri) and the -protocol (Matrix) stay swappable, and the security-critical work stays in Rust. +This is the contract between the web UI and the Rust core. The current client is +entirely in-memory and mock-backed. The UI depends only on this contract, so the +shell and a future protocol backend can stay swappable. ## Source of truth @@ -47,7 +47,14 @@ They return `{ status: "ok", data } | { status: "error", error: CoreError }`. - Session: `restoreSession` - Spaces and rooms: `listSpaces`, `listRooms` -- Timeline: `loadTimeline`, `sendMessage` +- Timeline: `loadTimeline`, `sendMessage`, `editMessage`, `redactMessage`, + `toggleReaction` + +Replies, edits, own-message redaction, and reactions are functional mock +behavior. They are not homeserver operations. A reply is a rich reply +projection using `m.in_reply_to`, not threading. Edit and redaction changes +retain the message row and arrive as `TimelineChange::Updated`. Reaction event +IDs remain private to Rust. ## Events (core to UI) @@ -56,7 +63,8 @@ One stream, `events.coreEventMsg`, whose `payload.event` is a `CoreEvent`: The UI is fully reactive: it calls a command, then reflects the resulting events. For example `sendMessage` returns the new id, and the appended message arrives as -a `Timeline` `Added` event. +a `Timeline` `Added` event. Edits, redactions, and reaction toggles arrive as +`Timeline` `Updated` events. ## Security boundary @@ -82,16 +90,18 @@ Follow-ups before this is production grade: homeserver still sees who is in which rooms and timing. Self-hosting for you and your friends keeps that metadata on infrastructure you control. +## Scope and limits + +Media, authentication, sync, pagination, membership, receipts, typing commands, +notifications, verification, moderation, rooms, and voice remain out of scope. +The mock does not claim real homeserver interoperability. + ## Replacing the mock with Matrix -1. Implement the five commands used by the UI with `matrix-rust-sdk`. +1. Implement the current command set used by the UI with `matrix-rust-sdk`. 2. Update `AppState` and the constructor in `src-tauri/src/lib.rs`. -3. Add a shared backend trait only if the mock and Matrix implementations must - coexist. - -The prototype does not preserve placeholder commands or events. Consumers of -older generated bindings must remove calls and handlers that are no longer -exported. Rust consumers must replace `Arc` with -`Arc`. Restore an operation only with its backend behavior, Tauri -wrapper, and UI caller, then regenerate the bindings. Add a shared trait when a -second backend needs the same operations. +3. Regenerate the bindings. + +A shared backend trait remains deferred until a second backend exists. Restore an +operation only with its backend behavior, Tauri wrapper, and UI caller. The +current interface does not promise a five-command API. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db0e58e..16f9f47 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -76,6 +76,41 @@ async fn send_message( backend.send_message(room, content).await } +#[tauri::command] +#[specta::specta] +async fn edit_message( + state: tauri::State<'_, AppState>, + room: RoomId, + target: MessageId, + content: OutgoingContent, +) -> Result<(), CoreError> { + let backend = state.backend.clone(); + backend.edit_message(room, target, content).await +} + +#[tauri::command] +#[specta::specta] +async fn redact_message( + state: tauri::State<'_, AppState>, + room: RoomId, + target: MessageId, +) -> Result<(), CoreError> { + let backend = state.backend.clone(); + backend.redact_message(room, target).await +} + +#[tauri::command] +#[specta::specta] +async fn toggle_reaction( + state: tauri::State<'_, AppState>, + room: RoomId, + target: MessageId, + key: String, +) -> Result<(), CoreError> { + let backend = state.backend.clone(); + backend.toggle_reaction(room, target, key).await +} + // ---------- Builder, bindings export, app entry ---------- fn specta_builder() -> Builder { @@ -86,6 +121,9 @@ fn specta_builder() -> Builder { list_rooms, load_timeline, send_message, + edit_message, + redact_message, + toggle_reaction, ]) .events(collect_events![CoreEventMsg]) } diff --git a/src/App.tsx b/src/App.tsx index 76fc648..d0e00b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,8 @@ function bodyOf(m: Message): string { } } +type ComposerMode = { kind: "reply" | "edit"; targetId: string }; + function roomGlyph(kind: Room["kind"]): string { switch (kind) { case "Voice": @@ -65,6 +67,8 @@ export default function App() { const [messages, setMessages] = useState([]); const [typing, setTyping] = useState([]); const [draft, setDraft] = useState(""); + const [composerMode, setComposerMode] = useState(null); + const [actionError, setActionError] = useState(null); const activeRoomRef = useRef(null); const scrollRef = useRef(null); @@ -120,21 +124,71 @@ export default function App() { setActiveRoom(roomId); activeRoomRef.current = roomId; setTyping([]); + setComposerMode(null); + setDraft(""); + setActionError(null); const chunk = await call(commands.loadTimeline(roomId, 50)); setMessages(chunk?.messages ?? []); } + function cancelComposer() { + setComposerMode(null); + setDraft(""); + setActionError(null); + } + + function replyTo(message: Message) { + setComposerMode({ kind: "reply", targetId: message.id }); + setDraft(""); + setActionError(null); + } + + function edit(message: Message) { + if (message.content.type !== "Text") return; + setComposerMode({ kind: "edit", targetId: message.id }); + setDraft(message.content.body); + setActionError(null); + } + async function send() { const room = activeRoom; const body = draft.trim(); if (!room || !body) return; - setDraft(""); - // The mock echoes the message back as a Timeline event, so we do not append here. - const sent = await call( - commands.sendMessage(room, { type: "Text", body, formatted: null, reply_to: null }), - ); - // On failure, restore the draft so the user does not silently lose their text. - if (sent === null) setDraft(body); + setActionError(null); + const result = + composerMode?.kind === "edit" + ? await commands.editMessage(room, composerMode.targetId, { + type: "Text", + body, + formatted: null, + reply_to: null, + }) + : await commands.sendMessage(room, { + type: "Text", + body, + formatted: null, + reply_to: composerMode?.kind === "reply" ? composerMode.targetId : null, + }); + if (result.status === "ok") { + setDraft(""); + setComposerMode(null); + } else { + setActionError("Could not complete that action. Try again."); + } + } + + async function toggleReaction(targetId: string, key: string) { + if (!activeRoom) return; + setActionError(null); + const result = await commands.toggleReaction(activeRoom, targetId, key); + if (result.status === "error") setActionError("Could not update that reaction. Try again."); + } + + async function redact(targetId: string) { + if (!activeRoom || !window.confirm("Delete this message?")) return; + setActionError(null); + const result = await commands.redactMessage(activeRoom, targetId); + if (result.status === "error") setActionError("Could not delete that message. Try again."); } const roomsInSpace = useMemo( @@ -143,6 +197,9 @@ export default function App() { ); const dms = useMemo(() => rooms.filter((r) => r.space === null), [rooms]); const current = rooms.find((r) => r.id === activeRoom) ?? null; + const composerTarget = composerMode + ? messages.find((message) => message.id === composerMode.targetId) + : undefined; return (
@@ -225,6 +282,9 @@ export default function App() { )} {messages.map((m) => { const mine = me?.id === m.sender; + const reply = m.reply_to + ? messages.find((message) => message.id === m.reply_to) + : undefined; return (
sending… )} + {m.edited && (edited)}
+ {m.reply_to && ( +
+
{reply ? shortName(reply.sender) : "deleted"}
+
{reply ? bodyOf(reply) : "(deleted)"}
+
+ )}
{bodyOf(m)}
+
+ {m.content.type !== "Redacted" && + m.reactions.map((reaction) => ( + + ))} + {m.content.type !== "Redacted" && ( + + )} + + {mine && m.content.type === "Text" && ( + + )} + {mine && m.content.type !== "Redacted" && ( + + )} +
); @@ -258,6 +379,31 @@ export default function App() { {typing.length > 0 && `${typing.map(shortName).join(", ")} is typing…`} + {composerMode && ( +
+ + {composerMode.kind === "reply" ? "Replying to" : "Editing message"} + + + {composerTarget + ? `${shortName(composerTarget.sender)}: ${bodyOf(composerTarget)}` + : "(deleted)"} + + +
+ )} + {actionError && ( +
+ {actionError} +
+ )} +
{ @@ -270,7 +416,11 @@ export default function App() { onChange={(e) => setDraft(e.target.value)} disabled={!current || current.kind === "Voice"} placeholder={ - current ? `Message ${roomGlyph(current.kind)}${current.name}` : "Select a channel" + composerMode?.kind === "edit" + ? "Edit message" + : current + ? `Message ${roomGlyph(current.kind)}${current.name}` + : "Select a channel" } className="w-full rounded-lg bg-zinc-600/60 px-4 py-3 text-sm outline-none placeholder:text-zinc-400 focus:ring-2 focus:ring-indigo-500 disabled:opacity-50" /> diff --git a/src/bindings.ts b/src/bindings.ts index a9c9eb7..edf5f6d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -13,6 +13,9 @@ export const commands = { listRooms: () => typedError(__TAURI_INVOKE("list_rooms")), loadTimeline: (room: RoomId, limit: number) => typedError(__TAURI_INVOKE("load_timeline", { room, limit })), sendMessage: (room: RoomId, content: OutgoingContent) => typedError(__TAURI_INVOKE("send_message", { room, content })), + editMessage: (room: RoomId, target: MessageId, content: OutgoingContent) => typedError(__TAURI_INVOKE("edit_message", { room, target, content })), + redactMessage: (room: RoomId, target: MessageId) => typedError(__TAURI_INVOKE("redact_message", { room, target })), + toggleReaction: (room: RoomId, target: MessageId, key: string) => typedError(__TAURI_INVOKE("toggle_reaction", { room, target, key })), }; /** Events */ From 0a916ee1c542bb42bff008aaa1290a57c1d6f06c Mon Sep 17 00:00:00 2001 From: kyle Date: Mon, 3 Aug 2026 12:08:12 +0200 Subject: [PATCH 4/5] fix: close mock action race and reaction gaps --- core/src/mock.rs | 352 +++++++++++++++++++++++++-------------------- src-tauri/build.rs | 2 +- src/App.tsx | 112 ++++++++++----- 3 files changed, 279 insertions(+), 187 deletions(-) diff --git a/core/src/mock.rs b/core/src/mock.rs index fbe2f27..b1749b9 100644 --- a/core/src/mock.rs +++ b/core/src/mock.rs @@ -94,10 +94,8 @@ impl MockBackend { }); tokio::time::sleep(std::time::Duration::from_secs(2)).await; let msg = self.make_message(&room, &alice, "ping from the mock backend", None); - self.state - .lock() - .unwrap() - .timelines + let mut st = self.state.lock().unwrap(); + st.timelines .entry(room.clone()) .or_default() .push(msg.clone()); @@ -155,27 +153,24 @@ impl MockBackend { } => (body, reply_to), _ => return Err(CoreError::Forbidden), }; - let msg = { - let mut st = self.state.lock().unwrap(); - if !st.rooms.iter().any(|r| r.id == room) { + let mut st = self.state.lock().unwrap(); + if !st.rooms.iter().any(|r| r.id == room) { + return Err(CoreError::NotFound); + } + if let Some(target) = &reply_to { + if find_message(&st, &room, target).is_none() { return Err(CoreError::NotFound); } - if let Some(target) = &reply_to { - if find_message(&st, &room, target).is_none() { - return Err(CoreError::NotFound); - } - } - let me_id = st.me.id.clone(); - let msg = self.make_message(&room, &me_id, &body, reply_to); - st.timelines - .entry(room.clone()) - .or_default() - .push(msg.clone()); - msg - }; + } + let me_id = st.me.id.clone(); + let msg = self.make_message(&room, &me_id, &body, reply_to); let id = msg.id.clone(); + st.timelines + .entry(room.clone()) + .or_default() + .push(msg.clone()); let _ = self.tx.send(CoreEvent::Timeline { - room: room.clone(), + room, change: TimelineChange::Added { message: msg }, }); Ok(id) @@ -195,23 +190,21 @@ impl MockBackend { } => body, _ => return Err(CoreError::Forbidden), }; - let updated = { - let mut st = self.state.lock().unwrap(); - let me_id = st.me.id.clone(); - let message = find_message_mut(&mut st, &room, &target).ok_or(CoreError::NotFound)?; - if message.sender != me_id - || matches!(&message.content, MessageContent::Redacted) - || !matches!(&message.content, MessageContent::Text { .. }) - { - return Err(CoreError::Forbidden); - } - message.content = MessageContent::Text { - body, - formatted: None, - }; - message.edited = true; - message.clone() + let mut st = self.state.lock().unwrap(); + let me_id = st.me.id.clone(); + let message = find_message_mut(&mut st, &room, &target).ok_or(CoreError::NotFound)?; + if message.sender != me_id + || matches!(&message.content, MessageContent::Redacted) + || !matches!(&message.content, MessageContent::Text { .. }) + { + return Err(CoreError::Forbidden); + } + message.content = MessageContent::Text { + body, + formatted: None, }; + message.edited = true; + let updated = message.clone(); let _ = self.tx.send(CoreEvent::Timeline { room, change: TimelineChange::Updated { message: updated }, @@ -220,27 +213,25 @@ impl MockBackend { } pub async fn redact_message(&self, room: RoomId, target: MessageId) -> Result<(), CoreError> { - let updated = { - let mut st = self.state.lock().unwrap(); - let me_id = st.me.id.clone(); - let updated = { - let message = - find_message_mut(&mut st, &room, &target).ok_or(CoreError::NotFound)?; - if message.sender != me_id || matches!(&message.content, MessageContent::Redacted) { - return Err(CoreError::Forbidden); - } - message.content = MessageContent::Redacted; - message.reactions.clear(); - message.reply_to = None; - message.edited = false; - message.clone() - }; - st.reaction_events - .retain(|(reaction_room, message_id, _, _), _| { - reaction_room != &room || message_id != &target - }); - updated - }; + let mut st = self.state.lock().unwrap(); + let me_id = st.me.id.clone(); + { + let message = find_message_mut(&mut st, &room, &target).ok_or(CoreError::NotFound)?; + if message.sender != me_id || matches!(&message.content, MessageContent::Redacted) { + return Err(CoreError::Forbidden); + } + message.content = MessageContent::Redacted; + message.reply_to = None; + message.edited = false; + } + st.reaction_events + .retain(|(reaction_room, message_id, _, _), _| { + reaction_room != &room || message_id != &target + }); + sync_reactions(&mut st, &room, &target); + let updated = find_message(&st, &room, &target) + .expect("validated message") + .clone(); let _ = self.tx.send(CoreEvent::Timeline { room, change: TimelineChange::Updated { message: updated }, @@ -254,44 +245,22 @@ impl MockBackend { target: MessageId, key: String, ) -> Result<(), CoreError> { - let updated = { - let mut st = self.state.lock().unwrap(); - let me_id = st.me.id.clone(); - let message = find_message(&st, &room, &target).ok_or(CoreError::NotFound)?; - if matches!(&message.content, MessageContent::Redacted) { - return Err(CoreError::Forbidden); - } + let mut st = self.state.lock().unwrap(); + let me_id = st.me.id.clone(); + let message = find_message(&st, &room, &target).ok_or(CoreError::NotFound)?; + if matches!(&message.content, MessageContent::Redacted) { + return Err(CoreError::Forbidden); + } - let identity = (room.clone(), target.clone(), me_id, key.clone()); - if st.reaction_events.contains_key(&identity) { - st.reaction_events.remove(&identity); - } else { - st.reaction_events - .insert(identity.clone(), MessageId::from(self.next_id("r"))); - } - let count = st - .reaction_events - .iter() - .filter(|((reaction_room, message_id, _, reaction_key), _)| { - reaction_room == &room && message_id == &target && reaction_key == &key - }) - .count() as u32; - let me_active = st.reaction_events.contains_key(&identity); - let message = find_message_mut(&mut st, &room, &target).expect("validated message"); - if count == 0 { - message.reactions.retain(|reaction| reaction.key != key); - } else if let Some(reaction) = message.reactions.iter_mut().find(|r| r.key == key) { - reaction.count = count; - reaction.me = me_active; - } else { - message.reactions.push(Reaction { - key, - count, - me: me_active, - }); - } - message.clone() - }; + let identity = (room.clone(), target.clone(), me_id, key); + if st.reaction_events.remove(&identity).is_none() { + st.reaction_events + .insert(identity, MessageId::from(self.next_id("r"))); + } + sync_reactions(&mut st, &room, &target); + let updated = find_message(&st, &room, &target) + .expect("validated message") + .clone(); let _ = self.tx.send(CoreEvent::Timeline { room, change: TimelineChange::Updated { message: updated }, @@ -465,13 +434,41 @@ fn build_sample() -> MockState { timelines.insert(friends_chat.clone(), vec![]); timelines.insert(dm_alice.clone(), vec![]); - MockState { + let mut state = MockState { me, spaces, rooms, timelines, reaction_events: HashMap::new(), - } + }; + let target = MessageId::from("m1"); + add_reaction_event( + &mut state.reaction_events, + ( + general.clone(), + target.clone(), + alice.id.clone(), + "👍".into(), + ), + MessageId::from("r-alice-thumbsup"), + ); + add_reaction_event( + &mut state.reaction_events, + (general.clone(), target.clone(), bob.id.clone(), "👍".into()), + MessageId::from("r-bob-thumbsup"), + ); + add_reaction_event( + &mut state.reaction_events, + ( + general.clone(), + target.clone(), + state.me.id.clone(), + "👍".into(), + ), + MessageId::from("r-you-thumbsup"), + ); + sync_reactions(&mut state, &general, &target); + state } fn find_message<'a>(st: &'a MockState, room: &RoomId, target: &MessageId) -> Option<&'a Message> { @@ -492,6 +489,38 @@ fn find_message_mut<'a>( .find(|message| &message.id == target && &message.room == room) } +fn add_reaction_event( + reaction_events: &mut HashMap, + identity: ReactionIdentity, + event_id: MessageId, +) { + reaction_events.entry(identity).or_insert(event_id); +} + +fn sync_reactions(st: &mut MockState, room: &RoomId, target: &MessageId) { + let me_id = st.me.id.clone(); + let mut aggregates: HashMap = HashMap::new(); + for ((reaction_room, message_id, sender, key), _) in &st.reaction_events { + if reaction_room == room && message_id == target { + let (count, me) = aggregates.entry(key.clone()).or_default(); + *count += 1; + *me |= sender == &me_id; + } + } + let mut keys: Vec<_> = aggregates.keys().cloned().collect(); + keys.sort(); + let reactions = keys + .into_iter() + .map(|key| { + let (count, me) = aggregates[&key]; + Reaction { key, count, me } + }) + .collect(); + if let Some(message) = find_message_mut(st, room, target) { + message.reactions = reactions; + } +} + #[cfg(test)] mod tests { use super::*; @@ -754,6 +783,21 @@ mod tests { b.toggle_reaction(room.clone(), id.clone(), "👍".into()) .await .unwrap(); + let remote_identity = ( + room.clone(), + id.clone(), + UserId::from("@alice:vauxl.local"), + "👍".into(), + ); + { + let mut st = b.state.lock().unwrap(); + add_reaction_event( + &mut st.reaction_events, + remote_identity, + MessageId::from("r-redaction-remote"), + ); + sync_reactions(&mut st, &room, &id); + } let before_len = timeline(&b, room.clone()).await.len(); let mut rx = b.subscribe(); @@ -811,47 +855,66 @@ mod tests { } #[tokio::test] - async fn mock_toggle_reaction_adds_deduplicates_and_toggles_off() { + async fn mock_toggle_reaction_uses_seeded_children_and_exact_identity() { let b = MockBackend::new(); let room = b.list_rooms().await.unwrap()[0].id.clone(); let target = MessageId::from("m1"); let me = b.restore_session().await.unwrap().unwrap().user.id; let key = "👍".to_string(); - let mut rx = b.subscribe(); - - b.toggle_reaction(room.clone(), target.clone(), key.clone()) - .await - .unwrap(); - let added = expect_updated(&mut rx, &target).await; - assert_eq!(added.reactions.len(), 1); - assert_eq!(added.reactions[0].key, key); - assert_eq!(added.reactions[0].count, 1); - assert!(added.reactions[0].me); + let alice = UserId::from("@alice:vauxl.local"); + let bob = UserId::from("@bob:vauxl.local"); let identity = (room.clone(), target.clone(), me.clone(), key.clone()); - let first_id = b - .state - .lock() - .unwrap() - .reaction_events - .get(&identity) - .unwrap() - .clone(); + let alice_identity = (room.clone(), target.clone(), alice, key.clone()); + let bob_identity = (room.clone(), target.clone(), bob, key.clone()); + + { + let mut st = b.state.lock().unwrap(); + assert_eq!( + st.reaction_events.get(&identity), + Some(&MessageId::from("r-you-thumbsup")) + ); + add_reaction_event( + &mut st.reaction_events, + alice_identity.clone(), + MessageId::from("r-duplicate"), + ); + sync_reactions(&mut st, &room, &target); + assert_eq!( + st.reaction_events.get(&alice_identity), + Some(&MessageId::from("r-alice-thumbsup")) + ); + } + let seeded = timeline(&b, room.clone()).await; + let seeded = seeded.iter().find(|message| message.id == target).unwrap(); + assert_eq!(seeded.reactions[0].key, key); + assert_eq!(seeded.reactions[0].count, 3); + assert!(seeded.reactions[0].me); + + let mut rx = b.subscribe(); b.toggle_reaction(room.clone(), target.clone(), key.clone()) .await .unwrap(); let removed = expect_updated(&mut rx, &target).await; - assert!(removed.reactions.is_empty()); + assert_eq!(removed.reactions[0].count, 2); + assert!(!removed.reactions[0].me); let st = b.state.lock().unwrap(); assert!(!st.reaction_events.contains_key(&identity)); - assert!(!st.reaction_events.values().any(|id| id == &first_id)); + assert_eq!( + st.reaction_events.get(&alice_identity), + Some(&MessageId::from("r-alice-thumbsup")) + ); + assert_eq!( + st.reaction_events.get(&bob_identity), + Some(&MessageId::from("r-bob-thumbsup")) + ); drop(st); b.toggle_reaction(room.clone(), target.clone(), key.clone()) .await .unwrap(); let added_again = expect_updated(&mut rx, &target).await; - assert_eq!(added_again.reactions[0].count, 1); + assert_eq!(added_again.reactions[0].count, 3); assert!(added_again.reactions[0].me); let second_id = b .state @@ -861,37 +924,17 @@ mod tests { .get(&identity) .unwrap() .clone(); - assert_ne!(first_id, second_id); - - let other_identity = ( - room.clone(), - target.clone(), - UserId::from("@alice:vauxl.local"), - key.clone(), - ); - b.state - .lock() - .unwrap() - .reaction_events - .insert(other_identity.clone(), MessageId::from("ralice")); - - b.toggle_reaction(room.clone(), target.clone(), key.clone()) - .await - .unwrap(); - let other_only = expect_updated(&mut rx, &target).await; - assert_eq!(other_only.reactions[0].count, 1); - assert!(!other_only.reactions[0].me); - - b.toggle_reaction(room.clone(), target.clone(), key) - .await - .unwrap(); - let both = expect_updated(&mut rx, &target).await; - assert_eq!(both.reactions.len(), 1); - assert_eq!(both.reactions[0].count, 2); - assert!(both.reactions[0].me); + assert!(second_id.0.starts_with('r')); let st = b.state.lock().unwrap(); - assert_eq!(st.reaction_events.len(), 2); - assert!(st.reaction_events.contains_key(&other_identity)); + assert_eq!(st.reaction_events.get(&identity), Some(&second_id)); + assert_eq!( + st.reaction_events.get(&alice_identity), + Some(&MessageId::from("r-alice-thumbsup")) + ); + assert_eq!( + st.reaction_events.get(&bob_identity), + Some(&MessageId::from("r-bob-thumbsup")) + ); } #[tokio::test] @@ -928,12 +971,17 @@ mod tests { assert_no_event(&mut rx).await; assert_eq!(snapshot(&b, room.clone()).await, before); - b.toggle_reaction(room.clone(), MessageId::from("m1"), "👍".into()) + b.toggle_reaction(room.clone(), MessageId::from("m1"), "🎉".into()) .await .unwrap(); let message = expect_updated(&mut rx, &MessageId::from("m1")).await; assert_eq!(message.sender, UserId::from("@alice:vauxl.local")); - assert_eq!(message.reactions[0].count, 1); - assert!(message.reactions[0].me); + let reaction = message + .reactions + .iter() + .find(|reaction| reaction.key == "🎉") + .unwrap(); + assert_eq!(reaction.count, 1); + assert!(reaction.me); } } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7..d860e1e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/src/App.tsx b/src/App.tsx index d0e00b2..09875d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,12 @@ import type { CoreEvent, Message, Room, Space, User } from "./bindings"; async function call( p: Promise<{ status: "ok"; data: T } | { status: "error"; error: unknown }>, ): Promise { - const r = await p; - return r.status === "ok" ? r.data : null; + try { + const r = await p; + return r.status === "ok" ? r.data : null; + } catch { + return null; + } } function shortName(userId: string): string { @@ -33,6 +37,18 @@ function bodyOf(m: Message): string { } type ComposerMode = { kind: "reply" | "edit"; targetId: string }; +const GENERIC_ACTION_ERROR = "Could not complete that action. Try again."; + +function reactionDisplay(key: string): string { + const compact = key.replace(/\s+/g, " "); + if (!compact) return "∅"; + const chars = Array.from(compact); + return chars.length > 12 ? `${chars.slice(0, 11).join("")}…` : compact; +} + +function reactionKeyLabel(key: string): string { + return key ? `reaction key ${key}` : "reaction with an empty key"; +} function roomGlyph(kind: Room["kind"]): string { switch (kind) { @@ -71,6 +87,7 @@ export default function App() { const [actionError, setActionError] = useState(null); const activeRoomRef = useRef(null); + const roomGenerationRef = useRef(0); const scrollRef = useRef(null); // Initial load. @@ -121,6 +138,7 @@ export default function App() { } async function openRoom(roomId: string) { + const generation = ++roomGenerationRef.current; setActiveRoom(roomId); activeRoomRef.current = roomId; setTyping([]); @@ -128,7 +146,7 @@ export default function App() { setDraft(""); setActionError(null); const chunk = await call(commands.loadTimeline(roomId, 50)); - setMessages(chunk?.messages ?? []); + if (roomGenerationRef.current === generation) setMessages(chunk?.messages ?? []); } function cancelComposer() { @@ -152,43 +170,64 @@ export default function App() { async function send() { const room = activeRoom; + const generation = roomGenerationRef.current; const body = draft.trim(); + const mode = composerMode; if (!room || !body) return; setActionError(null); - const result = - composerMode?.kind === "edit" - ? await commands.editMessage(room, composerMode.targetId, { - type: "Text", - body, - formatted: null, - reply_to: null, - }) - : await commands.sendMessage(room, { - type: "Text", - body, - formatted: null, - reply_to: composerMode?.kind === "reply" ? composerMode.targetId : null, - }); - if (result.status === "ok") { - setDraft(""); - setComposerMode(null); - } else { - setActionError("Could not complete that action. Try again."); + try { + const result = + mode?.kind === "edit" + ? await commands.editMessage(room, mode.targetId, { + type: "Text", + body, + formatted: null, + reply_to: null, + }) + : await commands.sendMessage(room, { + type: "Text", + body, + formatted: null, + reply_to: mode?.kind === "reply" ? mode.targetId : null, + }); + if (roomGenerationRef.current !== generation) return; + if (result.status === "ok") { + setDraft(""); + setComposerMode(null); + } else { + setActionError(GENERIC_ACTION_ERROR); + } + } catch { + if (roomGenerationRef.current === generation) setActionError(GENERIC_ACTION_ERROR); } } async function toggleReaction(targetId: string, key: string) { - if (!activeRoom) return; + const room = activeRoom; + const generation = roomGenerationRef.current; + if (!room) return; setActionError(null); - const result = await commands.toggleReaction(activeRoom, targetId, key); - if (result.status === "error") setActionError("Could not update that reaction. Try again."); + try { + const result = await commands.toggleReaction(room, targetId, key); + if (roomGenerationRef.current !== generation) return; + if (result.status === "error") setActionError(GENERIC_ACTION_ERROR); + } catch { + if (roomGenerationRef.current === generation) setActionError(GENERIC_ACTION_ERROR); + } } async function redact(targetId: string) { - if (!activeRoom || !window.confirm("Delete this message?")) return; + const room = activeRoom; + const generation = roomGenerationRef.current; + if (!room || !window.confirm("Delete this message?")) return; setActionError(null); - const result = await commands.redactMessage(activeRoom, targetId); - if (result.status === "error") setActionError("Could not delete that message. Try again."); + try { + const result = await commands.redactMessage(room, targetId); + if (roomGenerationRef.current !== generation) return; + if (result.status === "error") setActionError(GENERIC_ACTION_ERROR); + } catch { + if (roomGenerationRef.current === generation) setActionError(GENERIC_ACTION_ERROR); + } } const roomsInSpace = useMemo( @@ -310,8 +349,10 @@ export default function App() { {m.reply_to && (
-
{reply ? shortName(reply.sender) : "deleted"}
-
{reply ? bodyOf(reply) : "(deleted)"}
+
{reply ? shortName(reply.sender) : "message unavailable"}
+
+ {reply ? bodyOf(reply) : "(message unavailable)"} +
)}
{bodyOf(m)}
@@ -321,8 +362,9 @@ export default function App() { ))} - {m.content.type !== "Redacted" && ( + {m.content.type !== "Redacted" && !m.reactions.some((reaction) => reaction.key === "👍") && ( ))} {m.content.type !== "Redacted" && !m.reactions.some((reaction) => reaction.key === "👍") && (