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..6f44c7a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # 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. Swapping in a matrix-rust-sdk -backend later changes only one constructor. +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 ``` 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 +48,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 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/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..b1749b9 100644 --- a/core/src/mock.rs +++ b/core/src/mock.rs @@ -2,16 +2,15 @@ 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::*; 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}; @@ -26,7 +25,7 @@ struct MockState { spaces: Vec, rooms: Vec, timelines: HashMap>, - members: HashMap>, + reaction_events: HashMap, } /// In-memory backend with canned data, so the entire UI can be built and felt @@ -52,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(), @@ -63,7 +68,7 @@ impl MockBackend { formatted: None, }, reactions: vec![], - reply_to: None, + reply_to, edited: false, send_state: SendState::Sent, sender_trust: SenderTrust::Unverified, @@ -88,11 +93,9 @@ 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"); - self.state - .lock() - .unwrap() - .timelines + let msg = self.make_message(&room, &alice, "ping from the mock backend", None); + let mut st = self.state.lock().unwrap(); + st.timelines .entry(room.clone()) .or_default() .push(msg.clone()); @@ -106,25 +109,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 +118,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,37 +140,35 @@ 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, ) -> 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 me_id = self.state.lock().unwrap().me.id.clone(); - let msg = self.make_message(&room, &me_id, &body); - self.state - .lock() - .unwrap() - .timelines + 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); + let id = msg.id.clone(); + st.timelines .entry(room.clone()) .or_default() .push(msg.clone()); - let id = msg.id.clone(); let _ = self.tx.send(CoreEvent::Timeline { room, change: TimelineChange::Added { message: msg }, @@ -203,56 +176,99 @@ impl ChatBackend for MockBackend { Ok(id) } - async fn edit_message( + pub async fn edit_message( &self, - _room: RoomId, - _target: MessageId, - _content: OutgoingContent, + room: RoomId, + target: MessageId, + content: OutgoingContent, ) -> Result<(), CoreError> { + let body = match content { + OutgoingContent::Text { + body, + formatted: None, + .. + } => body, + _ => return Err(CoreError::Forbidden), + }; + 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 }, + }); Ok(()) } - async fn redact_message(&self, _room: RoomId, _target: MessageId) -> Result<(), CoreError> { + pub async fn redact_message(&self, room: RoomId, target: MessageId) -> Result<(), CoreError> { + 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 }, + }); Ok(()) } - async fn toggle_reaction( + pub async fn toggle_reaction( &self, - _room: RoomId, - _target: MessageId, - _key: String, + 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(()) - } + 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); + } - async fn cancel_verification(&self, _flow: String) -> Result<(), CoreError> { + 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 }, + }); Ok(()) } - fn subscribe(&self) -> broadcast::Receiver { + pub fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } } @@ -404,7 +420,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 +434,90 @@ 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 { + let mut state = MockState { me, spaces, rooms, timelines, - members, + 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> { + 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) +} + +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; } } @@ -442,13 +525,61 @@ fn build_sample() -> MockState { 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 @@ -457,25 +588,400 @@ 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 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(); + + 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_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 alice = UserId::from("@alice:vauxl.local"); + let bob = UserId::from("@bob:vauxl.local"); + let identity = (room.clone(), target.clone(), me.clone(), key.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_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_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, 3); + assert!(added_again.reactions[0].me); + let second_id = b + .state + .lock() + .unwrap() + .reaction_events + .get(&identity) + .unwrap() + .clone(); + assert!(second_id.0.starts_with('r')); + let st = b.state.lock().unwrap(); + 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] + 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")); + let reaction = message + .reactions + .iter() + .find(|reaction| reaction.key == "πŸŽ‰") + .unwrap(); + assert_eq!(reaction.count, 1); + assert!(reaction.me); } } diff --git a/core/src/model.rs b/core/src/model.rs index 6b43e95..4dd937d 100644 --- a/core/src/model.rs +++ b/core/src/model.rs @@ -5,26 +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, - 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, @@ -48,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)] @@ -155,7 +118,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..ecf89ee 100644 --- a/docs/DATA_INTERFACE.md +++ b/docs/DATA_INTERFACE.md @@ -1,14 +1,14 @@ # 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 -- Rust types and the trait live in `core/src/`: - - `model.rs` (rooms, messages, members, content), `event.rs` (reactive events), - `error.rs`, `ids.rs`, `backend.rs` (the `ChatBackend` trait). +- Rust types and the prototype backend live in `core/src/`: + - `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`. @@ -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,39 +42,40 @@ 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`, `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) 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 -a `Timeline` `Added` event. +a `Timeline` `Added` event. Edits, redactions, and reaction toggles arrive as +`Timeline` `Updated` events. ## Security boundary 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 +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. -## Swapping the mock for Matrix +## 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 current command set used by the UI with `matrix-rust-sdk`. +2. Update `AppState` and the constructor in `src-tauri/src/lib.rs`. +3. Regenerate the bindings. -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. +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/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/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-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9dd7104..16f9f47 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( @@ -152,103 +111,33 @@ async fn toggle_reaction( 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 +170,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 +184,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/App.tsx b/src/App.tsx index 76fc648..f0bff52 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 { @@ -32,6 +36,25 @@ 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.trim()) return key ? "␠" : "βˆ…"; + if (!compact.replace(/[\s\p{M}]/gu, "")) return "β—Œ"; + return /^\s*\p{M}/u.test(compact) ? `β—Œ${compact}` : compact; +} + +function reactionKeyLabel(key: string): string { + if (!key) return "reaction with an empty key"; + if (!key.trim()) return `reaction key containing only whitespace: ${JSON.stringify(key)}`; + if (!key.replace(/[\s\p{M}]/gu, "")) { + return `reaction key containing only combining marks: ${JSON.stringify(key)}`; + } + return /^\s*\p{M}/u.test(key) ? `reaction key ${JSON.stringify(key)}` : `reaction key ${key}`; +} + function roomGlyph(kind: Room["kind"]): string { switch (kind) { case "Voice": @@ -65,8 +88,11 @@ 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 roomGenerationRef = useRef(0); const scrollRef = useRef(null); // Initial load. @@ -117,24 +143,96 @@ export default function App() { } async function openRoom(roomId: string) { + const generation = ++roomGenerationRef.current; setActiveRoom(roomId); activeRoomRef.current = roomId; setTyping([]); + setComposerMode(null); + setDraft(""); + setActionError(null); const chunk = await call(commands.loadTimeline(roomId, 50)); - setMessages(chunk?.messages ?? []); + if (roomGenerationRef.current === generation) 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 generation = roomGenerationRef.current; const body = draft.trim(); + const mode = composerMode; 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); + 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) { + const room = activeRoom; + const generation = roomGenerationRef.current; + if (!room) return; + setActionError(null); + 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) { + const room = activeRoom; + const generation = roomGenerationRef.current; + if (!room || !window.confirm("Delete this message?")) return; + setActionError(null); + 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( @@ -143,6 +241,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 +326,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) : "message unavailable"}
+
+ {reply ? bodyOf(reply) : "(message unavailable)"} +
+
+ )}
{bodyOf(m)}
+
+ {m.content.type !== "Redacted" && + m.reactions.map((reaction) => ( + + ))} + {m.content.type !== "Redacted" && !m.reactions.some((reaction) => reaction.key === "πŸ‘") && ( + + )} + + {mine && m.content.type === "Text" && ( + + )} + {mine && m.content.type !== "Redacted" && ( + + )} +
); @@ -258,6 +431,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)}` + : "(message unavailable)"} + + +
+ )} + {actionError && ( +
+ {actionError} +
+ )} +
{ @@ -270,7 +468,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 267a66b..edf5f6d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5,27 +5,17 @@ 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 +34,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 +50,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 +61,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 +119,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 +130,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 +140,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 +163,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"] }