From ca7e63d3e576de4136fd4ca9a169825fc1f27001 Mon Sep 17 00:00:00 2001 From: Assis Date: Mon, 21 Sep 2026 18:48:24 -0300 Subject: [PATCH 1/7] feat: improve desktop chat reliability Route screenshot paste through a confirmation preview and surface live server acknowledgements. Complete archived chat discovery, recover group subjects, and keep phone notifications eligible with manual companion presence. Desktop alerts inherit mute/archive policy with a direct-mention exception. Add preference-provenance migration; legacy rows refresh on the next history sync because prior source precedence cannot be reconstructed. --- Cargo.lock | 7 + .../down.sql | 2 + .../up.sql | 5 + crates/chat-store/src/lib.rs | 4 +- crates/chat-store/src/queries.rs | 64 ++- crates/chat-store/src/schema.rs | 2 + crates/chat-store/src/store/chat_rows.rs | 38 +- crates/chat-store/src/store/event.rs | 28 +- crates/chat-store/src/store/history_sync.rs | 20 +- crates/chat-store/src/store/mod.rs | 15 + crates/chat-store/src/types.rs | 14 + crates/chat-store/tests/history_sync.rs | 97 ++++ crates/chat-store/tests/lid.rs | 64 +++ crates/chat-store/tests/read_state.rs | 180 ++++++ crates/core/src/chat/merge.rs | 17 + crates/core/src/chat/mod.rs | 27 +- crates/core/src/events.rs | 53 ++ crates/daemon/Cargo.toml | 2 + crates/daemon/oxidezapd.plist | 5 + crates/daemon/src/macos_main.rs | 12 + crates/daemon/src/session_bridge/act.rs | 26 +- crates/daemon/src/session_bridge/tests.rs | 3 + crates/daemon/src/session_bridge/translate.rs | 8 + .../daemon/src/session_bridge/wire_events.rs | 9 +- crates/daemon/src/state/mod.rs | 18 + crates/daemon/src/state/store.rs | 49 +- crates/gui/Cargo.toml | 10 + crates/gui/src/app/attaching.rs | 25 + crates/gui/src/app/body.rs | 157 ++++++ crates/gui/src/app/calls_ctl.rs | 15 +- crates/gui/src/app/chats.rs | 101 +++- crates/gui/src/app/commands.rs | 3 + crates/gui/src/app/events.rs | 29 +- crates/gui/src/app/mod.rs | 516 ++++++++++++++++-- crates/gui/src/app/paging.rs | 157 +++++- crates/gui/src/components/chat_list/mod.rs | 1 + crates/gui/src/components/input_area_view.rs | 164 +++++- crates/gui/src/components/mod.rs | 2 + crates/gui/src/components/paste_preview.rs | 98 ++++ crates/gui/src/main.rs | 24 + crates/gui/src/platform/activity.rs | 31 ++ crates/gui/src/platform/mod.rs | 6 + crates/gui/src/platform/notifications.rs | 284 ++++++++++ crates/gui/src/platform/startup.rs | 17 +- crates/gui/src/session/frames.rs | 14 +- crates/gui/src/session/mod.rs | 32 +- crates/gui/src/views/chat.rs | 4 +- crates/gui/src/views/settings/panes.rs | 5 +- crates/ipc/src/protocol.rs | 18 + crates/ipc/src/transport.rs | 7 +- crates/plugin-host/src/event.rs | 27 + crates/plugin-host/src/tests.rs | 15 + crates/session/src/mentions.rs | 48 ++ crates/session/src/names.rs | 48 +- crates/session/src/whatsapp/chat_names.rs | 354 +++++++++++- crates/session/src/whatsapp/history.rs | 16 +- crates/session/src/whatsapp/lanes.rs | 8 +- crates/session/src/whatsapp/mod.rs | 136 ++++- crates/session/src/whatsapp/tests.rs | 454 ++++++++++++++- crates/session/src/whatsapp/ui_queue.rs | 12 + 60 files changed, 3435 insertions(+), 172 deletions(-) create mode 100644 crates/chat-store/migrations/2026-09-21-000000_chat_preference_provenance/down.sql create mode 100644 crates/chat-store/migrations/2026-09-21-000000_chat_preference_provenance/up.sql create mode 100644 crates/gui/src/components/paste_preview.rs create mode 100644 crates/gui/src/platform/activity.rs create mode 100644 crates/gui/src/platform/notifications.rs diff --git a/Cargo.lock b/Cargo.lock index ca4e41ae..ce08cc44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6411,6 +6411,8 @@ dependencies = [ "js-sys", "ksni", "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "oxidezap-core", "oxidezap-ipc", "oxidezap-logging", @@ -6439,6 +6441,7 @@ name = "oxidezap-gui" version = "0.1.0" dependencies = [ "anyhow", + "block2 0.6.2", "chrono", "futures-channel", "futures-lite", @@ -6453,6 +6456,10 @@ dependencies = [ "js-sys", "log", "mp4", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-user-notifications", "openh264", "oxidezap-audio", "oxidezap-core", diff --git a/crates/chat-store/migrations/2026-09-21-000000_chat_preference_provenance/down.sql b/crates/chat-store/migrations/2026-09-21-000000_chat_preference_provenance/down.sql new file mode 100644 index 00000000..9f4e0ad6 --- /dev/null +++ b/crates/chat-store/migrations/2026-09-21-000000_chat_preference_provenance/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE chats DROP COLUMN archive_appstate_seen; +ALTER TABLE chats DROP COLUMN mute_appstate_seen; diff --git a/crates/chat-store/migrations/2026-09-21-000000_chat_preference_provenance/up.sql b/crates/chat-store/migrations/2026-09-21-000000_chat_preference_provenance/up.sql new file mode 100644 index 00000000..6abc7390 --- /dev/null +++ b/crates/chat-store/migrations/2026-09-21-000000_chat_preference_provenance/up.sql @@ -0,0 +1,5 @@ +-- A live message may create a chat before HistorySync supplies its preference +-- snapshot. Keep that snapshot renewable until an explicit app-state action +-- has spoken for each preference; its false/NULL answer is authoritative too. +ALTER TABLE chats ADD COLUMN mute_appstate_seen BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE chats ADD COLUMN archive_appstate_seen BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/chat-store/src/lib.rs b/crates/chat-store/src/lib.rs index 10cc378d..75e7a163 100644 --- a/crates/chat-store/src/lib.rs +++ b/crates/chat-store/src/lib.rs @@ -45,6 +45,6 @@ pub use materialize::is_control_only; pub use store::ChatStore; pub use types::{ ArrivalCursor, AvatarDescriptor, ChatCursor, ChatEntry, ChatNameExpected, ChatNameWrite, - ContactEntry, MediaRef, MessageCoverage, MessageCursor, MessageKind, MessageStatus, - ReactionEntry, ReceiptEntry, StoreChange, StoredMessage, + ChatNotificationMetadata, ContactEntry, MediaRef, MessageCoverage, MessageCursor, MessageKind, + MessageStatus, ReactionEntry, ReceiptEntry, StoreChange, StoredMessage, }; diff --git a/crates/chat-store/src/queries.rs b/crates/chat-store/src/queries.rs index c00c0989..28f5719b 100644 --- a/crates/chat-store/src/queries.rs +++ b/crates/chat-store/src/queries.rs @@ -8,15 +8,15 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use diesel::prelude::*; use log::warn; -use wacore_binary::Jid; +use wacore_binary::jid::{Jid, JidExt}; use crate::error::{ChatStoreError, Result, db_err}; use crate::schema; use crate::store::ChatStore; use crate::types::{ - ArrivalCursor, AvatarDescriptor, ChatCursor, ChatEntry, ContactEntry, MediaRef, - MessageCoverage, MessageCursor, MessageKind, MessageStatus, ReactionEntry, ReceiptEntry, - StoredMessage, + ArrivalCursor, AvatarDescriptor, ChatCursor, ChatEntry, ChatNotificationMetadata, ContactEntry, + MediaRef, MessageCoverage, MessageCursor, MessageKind, MessageStatus, ReactionEntry, + ReceiptEntry, StoredMessage, }; /// How many keys one batched lookup may bind at a time. @@ -165,6 +165,10 @@ struct ChatRow { read_boundary_ms: i64, #[allow(dead_code)] read_boundary_ids: Option, + #[allow(dead_code)] + mute_appstate_seen: bool, + #[allow(dead_code)] + archive_appstate_seen: bool, } impl From for ChatEntry { @@ -442,6 +446,58 @@ impl ChatStore { Ok(row.map(Into::into)) } + /// Alert policy and title from the durable conversation rows. + /// + /// A live message may precede GUI hydration, so the front end's `Chat` + /// cannot answer whether the phone muted or archived the conversation. + /// Unlike `chat()`, this reads *both* sides of a split PN/LID pair: a + /// stale alias with an active mute must not be bypassed just because the + /// other side has the newer message. No row means unknown, not allowed. + pub async fn notification_metadata( + &self, + jid: &Jid, + ) -> Result> { + use schema::chats::dsl; + let device_id = self.device_id(); + let is_group = jid.is_group(); + let jid = jid.to_string(); + let now_ms = wacore::time::now_utc().timestamp_millis(); + let rows: Vec<(Option, bool, Option)> = self + .db() + .read(move |conn| { + let keys = + crate::lid::chat_key_candidates(conn, device_id, &jid).map_err(db_err)?; + dsl::chats + .filter(dsl::device_id.eq(device_id).and(dsl::jid.eq_any(keys))) + .order((dsl::last_message_ts.desc(), dsl::jid.desc())) + .select((dsl::muted_until, dsl::archived, dsl::name)) + .load(conn) + .map_err(db_err) + }) + .await?; + if rows.is_empty() { + return Ok(None); + } + let muted = rows + .iter() + .any(|(mute, _, _)| mute.is_some_and(|until| until > now_ms)); + let archived = rows.iter().any(|(_, archived, _)| *archived); + let name = rows + .into_iter() + .filter_map(|(_, _, name)| name) + .find(|name| { + !name.trim().is_empty() + && !(is_group + && matches!(name.trim(), "Unnamed group" | "Group name unavailable")) + }); + Ok(Some(ChatNotificationMetadata { + muted, + archived, + allowed: !muted && !archived, + name, + })) + } + /// Every special chat's identity columns, in one read. /// /// The chat-name resolver's full pass works from this, not from the diff --git a/crates/chat-store/src/schema.rs b/crates/chat-store/src/schema.rs index c6b4a238..fc3c43ae 100644 --- a/crates/chat-store/src/schema.rs +++ b/crates/chat-store/src/schema.rs @@ -13,6 +13,8 @@ diesel::table! { ephemeral_expiration -> Nullable, read_boundary_ms -> BigInt, read_boundary_ids -> Nullable, + mute_appstate_seen -> Bool, + archive_appstate_seen -> Bool, } } diff --git a/crates/chat-store/src/store/chat_rows.rs b/crates/chat-store/src/store/chat_rows.rs index 76632e40..62d5e618 100644 --- a/crates/chat-store/src/store/chat_rows.rs +++ b/crates/chat-store/src/store/chat_rows.rs @@ -220,8 +220,10 @@ pub(super) fn ensure_chat( /// rows have already moved). Activity and preview re-derive from the merged /// messages; the self-read state is the union of both sides so neither /// side's covered messages re-badge; sticky user prefs (pin/mute/archive, -/// name, ephemeral) keep dest's value and fall back to src's. A manual-unread -/// marker on either side survives; otherwise the badge is recounted. +/// name, ephemeral) keep dest's value and fall back to src's. For mute and +/// archive, an explicit app-state answer (including unmute/unarchive) beats a +/// history-only value from the other alias. A manual-unread marker on either +/// side survives; otherwise the badge is recounted. pub(crate) fn merge_chat_metadata( conn: &mut SqliteConnection, device_id: i32, @@ -237,6 +239,8 @@ pub(crate) fn merge_chat_metadata( bool, Option, Option, + bool, + bool, ); let prefs = |conn: &mut SqliteConnection, key: &str| -> QueryResult> { chat_row(device_id, key) @@ -248,6 +252,8 @@ pub(crate) fn merge_chat_metadata( dsl::archived, dsl::ephemeral_expiration, dsl::name, + dsl::mute_appstate_seen, + dsl::archive_appstate_seen, )) .first(conn) .optional() @@ -257,7 +263,8 @@ pub(crate) fn merge_chat_metadata( }; let src_state = read_state(conn, device_id, src)?; ensure_chat(conn, device_id, dest)?; - let dest_row = prefs(conn, dest)?.unwrap_or((0, 0, None, None, false, None, None)); + let dest_row = + prefs(conn, dest)?.unwrap_or((0, 0, None, None, false, None, None, false, false)); let dest_state = read_state(conn, device_id, dest)?; let mut merged = ReadState { @@ -277,15 +284,36 @@ pub(crate) fn merge_chat_metadata( } else { count_unread(conn, device_id, dest, &merged)? }; + // A split pair may have received an app-state action on only one side. + // Preserve that explicit answer even when it is false/NULL; otherwise + // keep the established sticky fallback between history-only rows. If + // both sides have explicit answers, the surviving (more active) row wins + // as it did before this provenance was tracked. + let muted_until = if dest_row.7 { + dest_row.3 + } else if src_row.7 { + src_row.3 + } else { + dest_row.3.or(src_row.3) + }; + let archived = if dest_row.8 { + dest_row.4 + } else if src_row.8 { + src_row.4 + } else { + dest_row.4 || src_row.4 + }; diesel::update(chat_row(device_id, dest)) .set(( dsl::last_message_ts.eq(src_row.0.max(dest_row.0)), dsl::unread_count.eq(unread), dsl::pinned_at.eq(dest_row.2.or(src_row.2)), - dsl::muted_until.eq(dest_row.3.or(src_row.3)), - dsl::archived.eq(dest_row.4 || src_row.4), + dsl::muted_until.eq(muted_until), + dsl::archived.eq(archived), dsl::ephemeral_expiration.eq(dest_row.5.or(src_row.5)), dsl::name.eq(dest_row.6.or(src_row.6)), + dsl::mute_appstate_seen.eq(dest_row.7 || src_row.7), + dsl::archive_appstate_seen.eq(dest_row.8 || src_row.8), dsl::read_boundary_ms.eq(merged.watermark_ms), dsl::read_boundary_ids.eq(ids_json), )) diff --git a/crates/chat-store/src/store/event.rs b/crates/chat-store/src/store/event.rs index c865e2c3..627e4a55 100644 --- a/crates/chat-store/src/store/event.rs +++ b/crates/chat-store/src/store/event.rs @@ -183,14 +183,20 @@ pub(super) fn apply_event( }; let chat = crate::lid::route_chat_key(conn, device_id, &update.jid.to_string(), cs)?; ensure_chat(conn, device_id, &chat)?; - let stored: Option = chat_row(device_id, &chat) - .select(schema::chats::muted_until) + let (stored, seen): (Option, bool) = chat_row(device_id, &chat) + .select(( + schema::chats::muted_until, + schema::chats::mute_appstate_seen, + )) .first(conn)?; - if stored == muted_until { + if stored == muted_until && seen { return Ok(()); } diesel::update(chat_row(device_id, &chat)) - .set(schema::chats::muted_until.eq(muted_until)) + .set(( + schema::chats::muted_until.eq(muted_until), + schema::chats::mute_appstate_seen.eq(true), + )) .execute(conn)?; cs.chats = true; Ok(()) @@ -199,14 +205,20 @@ pub(super) fn apply_event( let chat = crate::lid::route_chat_key(conn, device_id, &update.jid.to_string(), cs)?; ensure_chat(conn, device_id, &chat)?; let archived = update.action.archived.unwrap_or(false); - let stored: bool = chat_row(device_id, &chat) - .select(schema::chats::archived) + let (stored, seen): (bool, bool) = chat_row(device_id, &chat) + .select(( + schema::chats::archived, + schema::chats::archive_appstate_seen, + )) .first(conn)?; - if stored == archived { + if stored == archived && seen { return Ok(()); } diesel::update(chat_row(device_id, &chat)) - .set(schema::chats::archived.eq(archived)) + .set(( + schema::chats::archived.eq(archived), + schema::chats::archive_appstate_seen.eq(true), + )) .execute(conn)?; cs.chats = true; Ok(()) diff --git a/crates/chat-store/src/store/history_sync.rs b/crates/chat-store/src/store/history_sync.rs index 1069df6c..e874aed8 100644 --- a/crates/chat-store/src/store/history_sync.rs +++ b/crates/chat-store/src/store/history_sync.rs @@ -110,11 +110,13 @@ fn apply_history_conversation( )) .on_conflict((dsl::device_id, dsl::jid)) .do_update() - // Live rows already track unread/mute/pin; history only refreshes - // identity + activity floor. A nameless chunk preserves an - // existing name rather than clobbering it with NULL; a named one - // still updates. Two statements rather than one, because the SET - // clause is static and the two cases write different columns. + // A live message can create the row before the phone's history + // snapshot has supplied its mute/archive preferences. History + // keeps refreshing those until an explicit app-state update has + // spoken for each one; then even false/NULL is newer authority + // than a delayed snapshot. Unread/pin remain owned by live state. + // A nameless chunk preserves an existing name rather than + // clobbering it with NULL; a named one still updates. .set(( dsl::name.eq(diesel::dsl::sql::< diesel::sql_types::Nullable, @@ -122,6 +124,14 @@ fn apply_history_conversation( dsl::last_message_ts.eq(diesel::dsl::sql::( "MAX(last_message_ts, excluded.last_message_ts)", )), + dsl::muted_until.eq(diesel::dsl::sql::< + diesel::sql_types::Nullable, + >( + "CASE WHEN mute_appstate_seen THEN muted_until ELSE excluded.muted_until END", + )), + dsl::archived.eq(diesel::dsl::sql::( + "CASE WHEN archive_appstate_seen THEN archived ELSE excluded.archived END", + )), )) .execute(conn)?; } diff --git a/crates/chat-store/src/store/mod.rs b/crates/chat-store/src/store/mod.rs index ec32d5e8..31483fc0 100644 --- a/crates/chat-store/src/store/mod.rs +++ b/crates/chat-store/src/store/mod.rs @@ -769,6 +769,21 @@ mod migration_tests { .expect("create store"); ChatStore::new(&store).await.expect("run migrations"); + // The current top migration only tracks which source last supplied + // mute/archive preferences. Revert it first so the historical + // downgrade assertions below still start at account-cascade. + store + .shared() + .run(|conn| { + conn.revert_last_migration(MIGRATIONS) + .map(|_| ()) + .map_err(StoreError::Migration) + }) + .await + .expect("preference-provenance downgrade is reversible"); + assert!(!has_column(&store, "chats", "mute_appstate_seen").await); + assert!(!has_column(&store, "chats", "archive_appstate_seen").await); + // Reverted in reverse application order. On top is the account-cascade // follow-up: it only adds the `device` foreign key to the descriptors // and the labels table, so reverting it leaves both in place without diff --git a/crates/chat-store/src/types.rs b/crates/chat-store/src/types.rs index 263a330d..89d0ee8b 100644 --- a/crates/chat-store/src/types.rs +++ b/crates/chat-store/src/types.rs @@ -235,6 +235,20 @@ pub struct ChatEntry { pub ephemeral_expiration: Option, } +/// The durable chat metadata a live message needs before it can alert. +/// +/// `allowed` is conservative across an unmerged PN/LID pair: either side's +/// active mute or archive suppresses the notification. `name` is the best +/// stored candidate, not necessarily a displayable name; the session's name +/// resolver still decides whether it is a real subject or a placeholder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatNotificationMetadata { + pub muted: bool, + pub archived: bool, + pub allowed: bool, + pub name: Option, +} + /// A stored message. `message` is the decoded proto when the row has one and /// it decodes cleanly; the denormalized columns (`kind`, `text`) always work /// even when it doesn't. diff --git a/crates/chat-store/tests/history_sync.rs b/crates/chat-store/tests/history_sync.rs index b499c932..a284ec07 100644 --- a/crates/chat-store/tests/history_sync.rs +++ b/crates/chat-store/tests/history_sync.rs @@ -8,6 +8,103 @@ mod common; use common::*; +/// A message can create the row before the conversation snapshot arrives. +/// History supplies the initial mute/archive state, but an explicit app-state +/// update (including false/NULL) outranks any stale snapshot replay. +#[tokio::test] +async fn history_prefills_live_row_without_overwriting_appstate() { + let (_store, chat_store) = test_store().await; + feed( + &chat_store, + [message_event( + wa::Message::text("first"), + incoming_info(PEER, PEER, "MSG-PREF", 1_700_000_000), + )], + ) + .await; + assert!( + chat_store + .notification_metadata(&jid(PEER)) + .await + .unwrap() + .unwrap() + .allowed + ); + + let snapshot = |muted: bool, archived: bool| { + history_sync_event(wa::HistorySync { + sync_type: wa::history_sync::HistorySyncType::RECENT, + conversations: vec![wa::Conversation { + id: PEER.into(), + conversation_timestamp: Some(1_700_000_000), + mute_end_time: muted.then_some(1_900_000_000), + archived: Some(archived), + ..Default::default() + }], + ..Default::default() + }) + }; + feed(&chat_store, [snapshot(true, true)]).await; + let state = chat_store + .notification_metadata(&jid(PEER)) + .await + .unwrap() + .unwrap(); + assert!(state.muted); + assert!(state.archived); + assert!(!state.allowed); + + // A later history chunk may still refresh both fields before any + // app-state event has established a newer authoritative value. + feed(&chat_store, [snapshot(false, false)]).await; + assert!( + chat_store + .notification_metadata(&jid(PEER)) + .await + .unwrap() + .unwrap() + .allowed + ); + + feed( + &chat_store, + [ + Event::MuteUpdate( + wacore::types::events::MuteUpdate::builder() + .jid(jid(PEER)) + .timestamp(ts(1_700_000_100)) + .action(Box::new(wa::sync_action_value::MuteAction { + muted: Some(false), + ..Default::default() + })) + .from_full_sync(false) + .build(), + ), + Event::ArchiveUpdate( + wacore::types::events::ArchiveUpdate::builder() + .jid(jid(PEER)) + .timestamp(ts(1_700_000_100)) + .action(Box::new(wa::sync_action_value::ArchiveChatAction { + archived: Some(false), + ..Default::default() + })) + .from_full_sync(false) + .build(), + ), + ], + ) + .await; + feed(&chat_store, [snapshot(true, true)]).await; + let state = chat_store + .notification_metadata(&jid(PEER)) + .await + .unwrap() + .unwrap(); + assert!(!state.muted); + assert!(!state.archived); + assert!(state.allowed); +} + #[tokio::test] async fn history_sync_materializes_without_clobbering_live_rows() { let (_store, chat_store) = test_store().await; diff --git a/crates/chat-store/tests/lid.rs b/crates/chat-store/tests/lid.rs index 86c3de33..7bfd72f5 100644 --- a/crates/chat-store/tests/lid.rs +++ b/crates/chat-store/tests/lid.rs @@ -13,6 +13,70 @@ mod common; use common::*; +#[tokio::test] +async fn alias_reconcile_keeps_explicit_unmute_and_unarchive_over_history() { + let (store, chat_store) = test_store().await; + feed( + &chat_store, + [ + message_event( + wa::Message::text("phone side"), + incoming_info(PEER, PEER, "MSG-PN-PREF", 1_700_000_000), + ), + Event::MuteUpdate( + wacore::types::events::MuteUpdate::builder() + .jid(jid(PEER)) + .timestamp(ts(1_700_000_001)) + .action(Box::new(wa::sync_action_value::MuteAction { + muted: Some(false), + ..Default::default() + })) + .from_full_sync(false) + .build(), + ), + Event::ArchiveUpdate( + wacore::types::events::ArchiveUpdate::builder() + .jid(jid(PEER)) + .timestamp(ts(1_700_000_001)) + .action(Box::new(wa::sync_action_value::ArchiveChatAction { + archived: Some(false), + ..Default::default() + })) + .from_full_sync(false) + .build(), + ), + message_event( + wa::Message::text("newer LID side"), + incoming_info(PEER_LID, PEER_LID, "MSG-LID-PREF", 1_700_000_100), + ), + history_sync_event(wa::HistorySync { + sync_type: wa::history_sync::HistorySyncType::RECENT, + conversations: vec![wa::Conversation { + id: PEER_LID.into(), + conversation_timestamp: Some(1_700_000_100), + mute_end_time: Some(1_900_000_000), + archived: Some(true), + ..Default::default() + }], + ..Default::default() + }), + ], + ) + .await; + add_lid_mapping(&store).await; + chat_store.reconcile_chat(&jid(PEER)).unwrap(); + chat_store.flush().await.unwrap(); + + let state = chat_store + .notification_metadata(&jid(PEER_LID)) + .await + .unwrap() + .unwrap(); + assert!(!state.muted); + assert!(!state.archived); + assert!(state.allowed); +} + /// The issue #1078 scenario: rows stored under the phone-number key before /// any mapping was known, delivered/read receipts arriving LID-keyed. #[tokio::test] diff --git a/crates/chat-store/tests/read_state.rs b/crates/chat-store/tests/read_state.rs index 9f1395f3..0bd7b23e 100644 --- a/crates/chat-store/tests/read_state.rs +++ b/crates/chat-store/tests/read_state.rs @@ -971,6 +971,186 @@ async fn wire_indefinite_mute_value_reads_as_forever() { assert_eq!(chats[0].muted_until, Some(chrono::DateTime::::MAX_UTC)); } +/// Alert policy must use durable conversation metadata, including rows the +/// GUI has never paged in. A new committed chat is allowed; no row is not. +#[tokio::test] +async fn notification_policy_is_fail_closed_and_follows_mute_and_archive() { + let (_store, chat_store) = test_store().await; + assert!( + chat_store + .notification_metadata(&jid(GROUP)) + .await + .unwrap() + .is_none() + ); + + feed( + &chat_store, + [message_event( + wa::Message::text("first"), + incoming_info(GROUP, PEER, "MSG-ALERT", 1_700_000_000), + )], + ) + .await; + chat_store + .set_chat_name(&jid(GROUP), "Example group") + .unwrap(); + chat_store.flush().await.unwrap(); + let metadata = chat_store + .notification_metadata(&jid(GROUP)) + .await + .unwrap() + .unwrap(); + assert!(metadata.allowed); + assert_eq!(metadata.name.as_deref(), Some("Example group")); + + feed( + &chat_store, + [Event::MuteUpdate( + wacore::types::events::MuteUpdate::builder() + .jid(jid(GROUP)) + .timestamp(ts(1_700_000_100)) + .action(Box::new(wa::sync_action_value::MuteAction { + muted: Some(true), + mute_end_timestamp: Some(-1), + ..Default::default() + })) + .from_full_sync(false) + .build(), + )], + ) + .await; + assert!( + !chat_store + .notification_metadata(&jid(GROUP)) + .await + .unwrap() + .unwrap() + .allowed + ); + + feed( + &chat_store, + [Event::MuteUpdate( + wacore::types::events::MuteUpdate::builder() + .jid(jid(GROUP)) + .timestamp(ts(1_700_000_200)) + .action(Box::new(wa::sync_action_value::MuteAction { + muted: Some(false), + ..Default::default() + })) + .from_full_sync(false) + .build(), + )], + ) + .await; + assert!( + chat_store + .notification_metadata(&jid(GROUP)) + .await + .unwrap() + .unwrap() + .allowed + ); + + // An expired mute is stored metadata but no longer suppresses an alert. + feed( + &chat_store, + [Event::MuteUpdate( + wacore::types::events::MuteUpdate::builder() + .jid(jid(GROUP)) + .timestamp(ts(1_700_000_250)) + .action(Box::new(wa::sync_action_value::MuteAction { + muted: Some(true), + mute_end_timestamp: Some(1_700_000_200_000), + ..Default::default() + })) + .from_full_sync(false) + .build(), + )], + ) + .await; + let metadata = chat_store + .notification_metadata(&jid(GROUP)) + .await + .unwrap() + .unwrap(); + assert!(!metadata.muted); + assert!(metadata.allowed); + + feed( + &chat_store, + [Event::ArchiveUpdate( + wacore::types::events::ArchiveUpdate::builder() + .jid(jid(GROUP)) + .timestamp(ts(1_700_000_300)) + .action(Box::new(wa::sync_action_value::ArchiveChatAction { + archived: Some(true), + ..Default::default() + })) + .from_full_sync(false) + .build(), + )], + ) + .await; + assert!( + !chat_store + .notification_metadata(&jid(GROUP)) + .await + .unwrap() + .unwrap() + .allowed + ); +} + +#[tokio::test] +async fn notification_policy_checks_both_unmerged_peer_aliases() { + let (store, chat_store) = test_store().await; + feed( + &chat_store, + [ + message_event( + wa::Message::text("phone side"), + incoming_info(PEER, PEER, "MSG-PN", 1_700_000_000), + ), + Event::MuteUpdate( + wacore::types::events::MuteUpdate::builder() + .jid(jid(PEER)) + .timestamp(ts(1_700_000_050)) + .action(Box::new(wa::sync_action_value::MuteAction { + muted: Some(true), + ..Default::default() + })) + .from_full_sync(false) + .build(), + ), + message_event( + wa::Message::text("newer LID side"), + incoming_info(PEER_LID, PEER_LID, "MSG-LID", 1_700_000_100), + ), + ], + ) + .await; + add_lid_mapping(&store).await; + + assert!( + !chat_store + .notification_metadata(&jid(PEER)) + .await + .unwrap() + .unwrap() + .allowed + ); + assert!( + !chat_store + .notification_metadata(&jid(PEER_LID)) + .await + .unwrap() + .unwrap() + .allowed + ); +} + #[tokio::test] async fn noop_mark_read_clears_manual_unread_marker() { let (_store, chat_store) = test_store().await; diff --git a/crates/core/src/chat/merge.rs b/crates/core/src/chat/merge.rs index f5ced0ec..a73dfbd7 100644 --- a/crates/core/src/chat/merge.rs +++ b/crates/core/src/chat/merge.rs @@ -103,6 +103,10 @@ impl Chat { if hydrated.pinned_at.is_some() || hydrated.from_store { self.pinned_at = hydrated.pinned_at; } + if hydrated.from_store { + self.muted_until = hydrated.muted_until; + self.archived = hydrated.archived; + } if hydrated.last_message_time >= self.last_message_time { // What an absent preview means depends on whether the load // brought messages. With messages, the store simply has no TEXT @@ -355,6 +359,19 @@ mod tests { assert!(chat.avatar_loaded); } + #[test] + fn store_hydration_is_authoritative_for_archive_state() { + let jid = "a@s.whatsapp.net".to_string(); + let mut chat = Chat::new(jid.clone()); + let mut archived = Chat::from_store(jid.clone(), "Someone".into(), 0); + archived.archived = true; + chat.merge_history(archived); + assert!(chat.archived); + + chat.merge_history(Chat::from_store(jid, "Someone".into(), 0)); + assert!(!chat.archived, "an unarchive from the store also wins"); + } + #[test] fn better_name_survives_history_merges() { let jid = "111222333444555@lid".to_string(); diff --git a/crates/core/src/chat/mod.rs b/crates/core/src/chat/mod.rs index 7f60a90e..bba07300 100644 --- a/crates/core/src/chat/mod.rs +++ b/crates/core/src/chat/mod.rs @@ -27,7 +27,7 @@ pub fn fallback_chat_name(jid: &Jid) -> String { if jid.is_status_broadcast() { "Status".to_string() } else if jid.is_group() { - "Unnamed group".to_string() + "Group name unavailable".to_string() } else if jid.is_broadcast_list() { "Broadcast list".to_string() } else if jid.is_newsletter() { @@ -41,6 +41,17 @@ pub fn fallback_chat_name(jid: &Jid) -> String { } } +#[cfg(test)] +mod fallback_tests { + use super::fallback_chat_name; + + #[test] + fn group_without_recoverable_subject_has_an_honest_label() { + let group = "120363000000000001@g.us".parse().expect("synthetic group"); + assert_eq!(fallback_chat_name(&group), "Group name unavailable"); + } +} + /// What to call whoever is at `sender` when nobody has a name for them. /// /// The number where the address carries one, the generic label otherwise, @@ -100,6 +111,14 @@ pub struct Chat { /// of the list; the store owns the timestamp and hydration restores it. #[serde(default, skip_serializing_if = "Option::is_none")] pub pinned_at: Option>, + /// Until when desktop alerts for this conversation are muted. + /// `DateTime::MAX_UTC` represents a mute without expiry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub muted_until: Option>, + /// Whether WhatsApp keeps this conversation in the archived list. + /// Store hydration is authoritative for this value. + #[serde(default, skip_serializing_if = "is_false")] + pub archived: bool, /// Whether this is a group chat pub is_group: bool, /// Whether this is the status broadcast. @@ -137,8 +156,8 @@ pub struct Chat { /// chats (incoming message before its store row commits — e.g. the /// initial-pairing window) stay `false` until a history load adopts them, /// so a complete-but-still-empty store load must not prune them; a chat - /// the store DID originate and no longer returns was deleted/archived - /// elsewhere and must go. + /// the store DID originate and no longer returns from the relevant list + /// may be pruned by the front end. pub(crate) from_store: bool, } @@ -192,6 +211,8 @@ impl Chat { unread_count: 0, manually_unread: false, pinned_at: None, + muted_until: None, + archived: false, is_group, is_status, avatar_picture_id: None, diff --git a/crates/core/src/events.rs b/crates/core/src/events.rs index 67c512d6..c5df0aa5 100644 --- a/crates/core/src/events.rs +++ b/crates/core/src/events.rs @@ -59,6 +59,20 @@ pub enum UiEvent { chat_jid: String, message: Box, sender_name: Option, + /// A live, committed message in a known, unmuted, unarchived chat. + /// Missing from an older daemon's frame means unknown and suppresses + /// the alert; the GUI still owns focus and duplicate filtering. + #[serde(default)] + notification_allowed: bool, + /// Resolved from durable chat metadata when available, so a message + /// arriving before GUI hydration does not title the banner with a JID + /// or a generated group placeholder. + #[serde(default, skip_serializing_if = "Option::is_none")] + notification_title: Option, + /// The store says this conversation is archived. A live @mention may + /// still alert, but the GUI must not resurrect it in the active list. + #[serde(default)] + notification_archived: bool, }, ReceiptReceived { chat_jid: String, @@ -373,3 +387,42 @@ mod receipt_wire { Ok(ReceiptType::parse(&wire)) } } + +#[cfg(test)] +mod notification_wire_tests { + use super::UiEvent; + + #[test] + fn older_message_frame_without_policy_is_not_allowed_to_alert() { + let event = UiEvent::MessageReceived { + chat_jid: "group@g.us".into(), + message: Box::new(crate::fixtures::message( + "MESSAGE-1", + "member@example.invalid", + "example", + )), + sender_name: None, + notification_allowed: true, + notification_title: Some("Example group".into()), + notification_archived: true, + }; + let mut wire = serde_json::to_value(event).unwrap(); + let fields = wire + .get_mut("message_received") + .and_then(serde_json::Value::as_object_mut) + .unwrap(); + fields.remove("notification_allowed"); + fields.remove("notification_title"); + fields.remove("notification_archived"); + let older: UiEvent = serde_json::from_value(wire).unwrap(); + assert!(matches!( + older, + UiEvent::MessageReceived { + notification_allowed: false, + notification_title: None, + notification_archived: false, + .. + } + )); + } +} diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 0bac9af4..416553df 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -153,6 +153,8 @@ tray-icon = { version = "0.24", default-features = false } # graph transitively; declared because this crate imports it directly. [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSRunningApplication"] } [target.'cfg(unix)'.dependencies] rustix = { workspace = true } diff --git a/crates/daemon/oxidezapd.plist b/crates/daemon/oxidezapd.plist index 264a2f29..b95d315f 100644 --- a/crates/daemon/oxidezapd.plist +++ b/crates/daemon/oxidezapd.plist @@ -6,6 +6,11 @@ com.oxidezap.daemon CFBundleName oxidezapd + + LSUIElement + NSCameraUsageDescription oxidezap needs the camera so you can be seen on a video call. NSMicrophoneUsageDescription diff --git a/crates/daemon/src/macos_main.rs b/crates/daemon/src/macos_main.rs index d24c6a7f..c2062c2a 100644 --- a/crates/daemon/src/macos_main.rs +++ b/crates/daemon/src/macos_main.rs @@ -11,6 +11,8 @@ use std::time::Duration; use anyhow::{Context, Result}; use core_foundation::runloop::{CFRunLoop, kCFRunLoopDefaultMode}; +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; use crate::state::StateHub; use crate::tray::macos::MacTray; @@ -23,6 +25,16 @@ use crate::tray::macos::MacTray; /// way it is on a bare window manager — and the runloop is pumped either /// way, so the two paths cannot drift. pub fn run(runtime: tokio::runtime::Runtime, hub: Arc) -> Result<()> { + // The daemon owns an AppKit status item, not a user-facing application. + // `LSUIElement` is the launch-time declaration; setting the same policy + // explicitly also covers the unbundled executable the GUI starts and + // prevents AppKit from briefly registering a second Dock application. + let mtm = MainThreadMarker::new().context("the macOS daemon is not on the main thread")?; + let app = NSApplication::sharedApplication(mtm); + if !app.setActivationPolicy(NSApplicationActivationPolicy::Accessory) { + log::warn!("AppKit refused the accessory activation policy; a Dock icon may appear"); + } + let mut tray = match MacTray::start(&hub) { Ok(tray) => Some(tray), Err(e) => { diff --git a/crates/daemon/src/session_bridge/act.rs b/crates/daemon/src/session_bridge/act.rs index fadaebe3..629ca88f 100644 --- a/crates/daemon/src/session_bridge/act.rs +++ b/crates/daemon/src/session_bridge/act.rs @@ -192,7 +192,12 @@ impl Bridge { } Action::LoadChats { id, - request: oxidezap_ipc::LoadChats { after, limit }, + request: + oxidezap_ipc::LoadChats { + after, + limit, + archived, + }, answer_to, } => { // As above: the permit is what decides whether the query @@ -204,7 +209,7 @@ impl Bridge { let page = client.load_chats( after.map(|cursor| cursor.as_str().to_string()), limit.map_or(WhatsAppClient::CHAT_PAGE, i64::from), - false, + archived, ); let reads = Arc::clone(&self.reads); let hub = Arc::clone(&self.hub); @@ -235,6 +240,23 @@ impl Bridge { for message in &chat.messages { reads.observe_message(&chat.jid, message); } + // The hub is the active-list snapshot handed + // to newly attached windows. An explicit + // include-archived page belongs only to the + // requesting window; publishing its archived + // rows here would resurrect them as ordinary + // placeholder chats on the next attach. + if chat.archived { + if !hub.apply_for( + asked_as, + Change::from_store(DaemonEvent::ChatRemoved { + jid: chat.jid.clone(), + }), + ) { + reads.forget(&chat.jid); + } + continue; + } // Asked and written under one lock, so a // logout cannot land between the question and // the answer. What it refuses is folded back diff --git a/crates/daemon/src/session_bridge/tests.rs b/crates/daemon/src/session_bridge/tests.rs index b1fb593c..7ce51162 100644 --- a/crates/daemon/src/session_bridge/tests.rs +++ b/crates/daemon/src/session_bridge/tests.rs @@ -28,6 +28,9 @@ pub(super) fn received(chat_jid: &str, message: ChatMessage, sender_name: Option chat_jid: chat_jid.into(), message: Box::new(message), sender_name: sender_name.map(str::to_string), + notification_allowed: false, + notification_title: None, + notification_archived: false, } } diff --git a/crates/daemon/src/session_bridge/translate.rs b/crates/daemon/src/session_bridge/translate.rs index 9d3d3070..a3738ccc 100644 --- a/crates/daemon/src/session_bridge/translate.rs +++ b/crates/daemon/src/session_bridge/translate.rs @@ -297,7 +297,15 @@ impl Bridge { chat_jid, message, sender_name, + .. } => { + // A complete store load removed this JID from the active + // list. Do not resurrect it from live traffic while the + // store is deciding whether the message unarchived/recreated + // it; a store-backed ChatUpdated clears the marker shortly. + if self.hub.chat(&chat_jid).is_none() && self.hub.chat_is_inactive(&chat_jid) { + return Vec::new(); + } let mut summary = self.hub.chat(&chat_jid).unwrap_or_else(|| ChatSummary { name: live_chat_name(&chat_jid, &message, sender_name), jid: chat_jid.clone(), diff --git a/crates/daemon/src/session_bridge/wire_events.rs b/crates/daemon/src/session_bridge/wire_events.rs index aee68953..0f8e4a0c 100644 --- a/crates/daemon/src/session_bridge/wire_events.rs +++ b/crates/daemon/src/session_bridge/wire_events.rs @@ -100,8 +100,10 @@ pub(crate) fn chat_to_dto(chat: oxidezap_core::Chat) -> ChatDto { manually_unread: chat.manually_unread, is_group: chat.is_group, is_pinned: chat.pinned_at.is_some(), - is_muted: false, - is_archived: false, + is_muted: chat + .muted_until + .is_some_and(|until| until > wacore::time::now_utc()), + is_archived: chat.archived, last_message_ts: chat.last_message_time.map(|t| t.timestamp_millis()), last_message_preview: chat.last_message, } @@ -293,6 +295,9 @@ mod tests { chat_jid: "559900000001@s.whatsapp.net".into(), message: Box::new(message), sender_name: None, + notification_allowed: false, + notification_title: None, + notification_archived: false, }), }) .unwrap(); diff --git a/crates/daemon/src/state/mod.rs b/crates/daemon/src/state/mod.rs index f11634df..8c618d4e 100644 --- a/crates/daemon/src/state/mod.rs +++ b/crates/daemon/src/state/mod.rs @@ -278,6 +278,11 @@ impl StateHub { self.state.chat(jid) } + /// Whether the active store list deliberately excluded this chat. + pub fn chat_is_inactive(&self, jid: &str) -> bool { + self.state.chat_is_inactive(jid) + } + /// The JIDs a complete store reload is allowed to contradict. See /// [`StateStore::store_backed_chat_jids`]. pub fn store_backed_chat_jids(&self) -> Vec { @@ -636,6 +641,19 @@ mod tests { assert_eq!(tray.borrow_and_update().unread, 4); } + #[test] + fn a_removed_chat_stays_inactive_until_the_store_restores_it() { + let hub = StateHub::new(); + hub.apply(stored(chat("a@s.whatsapp.net", 1, 10))); + hub.apply(removed("a@s.whatsapp.net")); + assert!(hub.chat_is_inactive("a@s.whatsapp.net")); + + // A store-backed row is the authoritative answer that this address + // is active again (unarchived or recreated). + hub.apply(stored(chat("a@s.whatsapp.net", 1, 20))); + assert!(!hub.chat_is_inactive("a@s.whatsapp.net")); + } + /// Receipts and typing churn state constantly. The tray must not wake for /// changes it cannot render, or the icon redraws for nothing all day. #[tokio::test] diff --git a/crates/daemon/src/state/store.rs b/crates/daemon/src/state/store.rs index f5917c6c..5be58630 100644 --- a/crates/daemon/src/state/store.rs +++ b/crates/daemon/src/state/store.rs @@ -141,6 +141,12 @@ struct Inner { /// Chats keyed by JID. A map, not a Vec: every update is a lookup by JID, /// and a Vec would make a rename or a receipt O(n) over every chat. chats: std::collections::HashMap, + /// Chats the active store list removed (archived or deleted). + /// + /// A live message for one must not recreate an active snapshot row before + /// the store has said whether it was unarchived. The next store-backed + /// update clears the marker; account departure clears the whole set. + inactive_chats: std::collections::HashSet, /// Which account this is, counted up every time one leaves. /// /// Here rather than beside the lock, so a task that asks and then applies @@ -195,6 +201,7 @@ impl StateStore { account: None, plugins: Vec::new(), chats: std::collections::HashMap::new(), + inactive_chats: std::collections::HashSet::new(), account_generation: 0, }), } @@ -253,6 +260,7 @@ impl StateStore { pub(super) fn forget_account(&self) { let mut inner = self.lock(); inner.chats.clear(); + inner.inactive_chats.clear(); inner.account = None; inner.calls = oxidezap_core::CallState::new(); inner.version = inner.version.next(); @@ -289,6 +297,11 @@ impl StateStore { self.lock().chats.get(jid).map(|e| e.summary.clone()) } + /// Whether the active store list deliberately excluded this chat. + pub(super) fn chat_is_inactive(&self, jid: &str) -> bool { + self.lock().inactive_chats.contains(jid) + } + /// The JIDs a complete store reload is allowed to contradict. /// /// Only store-backed chats. A chat the daemon has only ever seen live has @@ -334,25 +347,31 @@ impl StateStore { match &event { DaemonEvent::ConnectionChanged(state) => inner.connection = state.clone(), - DaemonEvent::ChatUpdated(summary) => match inner.chats.entry(summary.jid.clone()) { - std::collections::hash_map::Entry::Occupied(mut slot) => { - let entry = slot.get_mut(); - entry.summary = summary.clone(); - // Sticky: a live update to a chat the store has already - // published must not make it live-only again, or a - // deletion elsewhere would stop being prunable the moment - // one more message arrived. - entry.from_store |= from_store; + DaemonEvent::ChatUpdated(summary) => { + if from_store { + inner.inactive_chats.remove(&summary.jid); } - std::collections::hash_map::Entry::Vacant(slot) => { - slot.insert(ChatEntry { - summary: summary.clone(), - from_store, - }); + match inner.chats.entry(summary.jid.clone()) { + std::collections::hash_map::Entry::Occupied(mut slot) => { + let entry = slot.get_mut(); + entry.summary = summary.clone(); + // Sticky: a live update to a chat the store has already + // published must not make it live-only again, or a + // deletion elsewhere would stop being prunable the moment + // one more message arrived. + entry.from_store |= from_store; + } + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(ChatEntry { + summary: summary.clone(), + from_store, + }); + } } - }, + } DaemonEvent::ChatRemoved { jid } => { inner.chats.remove(jid); + inner.inactive_chats.insert(jid.clone()); } // Not the usual route in — [`Self::change_calls`] is — but the // state it names is the state this holds, so applying it here diff --git a/crates/gui/Cargo.toml b/crates/gui/Cargo.toml index 713cdc0b..c34b0631 100644 --- a/crates/gui/Cargo.toml +++ b/crates/gui/Cargo.toml @@ -97,6 +97,16 @@ openh264 = "0.9" # H.264 software decoder [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_System_Threading"] } +# Native notification authorization and the process-level active state. GPUI +# already uses this objc2 family internally; declared because this crate calls +# the platform APIs directly. +[target.'cfg(target_os = "macos")'.dependencies] +block2 = "0.6" +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["NSArray", "NSBundle", "NSDictionary", "NSError", "NSObject", "NSString", "NSURL", "std"] } +objc2-user-notifications = { version = "0.3", default-features = false, features = ["block2", "UNNotificationAttachment", "UNNotificationContent", "UNNotificationRequest", "UNNotificationSettings", "UNNotificationTrigger", "UNUserNotificationCenter"] } + # The same window as a page. `gpui_platform` picks the web backend by target, # so the dependency differs only in the features a browser has no use for — # there is no font-kit, no X11 and no Wayland behind a canvas. diff --git a/crates/gui/src/app/attaching.rs b/crates/gui/src/app/attaching.rs index 7d78ff4d..8ca1c972 100644 --- a/crates/gui/src/app/attaching.rs +++ b/crates/gui/src/app/attaching.rs @@ -168,6 +168,28 @@ impl WhatsAppApp { Some(QuotedMessage::from(draft)) } + pub(crate) fn cancel_paste_preview(&mut self, cx: &mut Context) -> bool { + let cancelled = self.paste_preview.take().is_some(); + if cancelled { + cx.notify(); + } + cancelled + } + + pub(crate) fn confirm_paste_preview(&mut self, cx: &mut Context) { + let Some(preview) = self.paste_preview.take() else { + return; + }; + let quoted = self.take_reply_draft(preview.reply, cx); + let drawn = self.send_attachment(&preview.jid, preview.file, quoted, cx); + let destination_still_open = self.destination == Destination::Chats + && self.selected_chat.as_deref() == Some(preview.jid.as_str()); + if drawn && preview.chat_was_visible && destination_still_open { + self.scroll_to_last_message(); + } + cx.notify(); + } + /// Hand one file to the session and draw its bubble. /// /// Answers whether a bubble was added, which is what decides if the @@ -179,6 +201,9 @@ impl WhatsAppApp { quoted: Option, cx: &mut Context, ) -> bool { + #[cfg(test)] + self.attachment_attempts.push(file.clone()); + let Some(client) = &self.client else { warn!("Cannot send a file: client is unavailable"); self.notify_user( diff --git a/crates/gui/src/app/body.rs b/crates/gui/src/app/body.rs index 88f51183..4c0651e7 100644 --- a/crates/gui/src/app/body.rs +++ b/crates/gui/src/app/body.rs @@ -357,6 +357,163 @@ mod tests { .unwrap(); } + fn one_pixel_png() -> Vec { + vec![ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, + 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, + 192, 240, 31, 0, 3, 3, 1, 0, 24, 251, 3, 253, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, + 130, + ] + } + + fn paste_preview_fixture( + cx: &mut gpui::TestAppContext, + ) -> (gpui::VisualTestContext, Entity) { + use gpui::{ClipboardItem, Image, ImageFormat}; + + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + init_app_bindings(cx); + }); + let mut app_entity = None; + let window = cx.open_window(gpui::size(gpui::px(1000.), gpui::px(800.)), |window, cx| { + let app = cx.new(|cx| { + let mut app = WhatsAppApp::new(cx); + app.app_state = AppState::Connected; + app.destination = Destination::Chats; + app.chats + .push(Arc::new(Chat::new("peer@example.invalid".into()))); + app + }); + app_entity = Some(app.clone()); + gpui_component::Root::new(app, window, cx) + }); + let app = app_entity.unwrap(); + let mut cx = gpui::VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + app.update(cx, |app, cx| { + app.select_chat( + "peer@example.invalid".into(), + ChatOpen::ToCompose, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.write_to_clipboard(ClipboardItem::new_image(&Image { + format: ImageFormat::Png, + bytes: one_pixel_png(), + id: 0, + })); + cx.simulate_keystrokes(if cfg!(target_os = "macos") { + "cmd-v" + } else { + "ctrl-v" + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, app) + } + + #[gpui::test] + fn pasted_image_waits_in_a_visible_preview(cx: &mut gpui::TestAppContext) { + let (mut cx, app) = paste_preview_fixture(cx); + + cx.read(|cx| { + let app = app.read(cx); + assert!( + app.attachment_attempts.is_empty(), + "opening the preview must not call the attachment send boundary" + ); + assert_eq!(app.keyboard_owner, Some(KeyboardOwner::PastePreview)); + assert!( + app.paste_preview + .as_ref() + .is_some_and(|preview| preview.chat_was_visible), + "the preview must remember that its destination was visible before the modal" + ); + assert!( + app.visible_chat.is_none(), + "a conversation covered by the modal must not count as visible" + ); + }); + cx.update(|window, cx| { + assert!(app.read(cx).paste_preview_focus.is_focused(window)); + }); + assert!( + cx.debug_bounds("paste-preview").is_some(), + "the pasted image must open a rendered preview" + ); + assert!( + cx.debug_bounds("paste-preview-image").is_some(), + "the pasted image itself must be rendered in the preview" + ); + } + + #[gpui::test] + fn send_button_confirms_once_and_closes_preview(cx: &mut gpui::TestAppContext) { + let (mut cx, app) = paste_preview_fixture(cx); + let send = cx + .debug_bounds("paste-preview-send") + .expect("preview must render a Send control"); + cx.simulate_click(send.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.read(|cx| { + let app = app.read(cx); + assert_eq!(app.attachment_attempts.len(), 1); + assert_eq!(app.attachment_attempts[0].file_name, "pasted.png"); + assert_eq!(app.attachment_attempts[0].mime_type, "image/png"); + assert_eq!(app.attachment_attempts[0].bytes, one_pixel_png()); + assert!(app.paste_preview.is_none()); + }); + cx.simulate_click(send.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + cx.read(|cx| assert_eq!(app.read(cx).attachment_attempts.len(), 1)); + } + + #[gpui::test] + fn cancel_button_discards_preview_without_sending(cx: &mut gpui::TestAppContext) { + let (mut cx, app) = paste_preview_fixture(cx); + let cancel = cx + .debug_bounds("paste-preview-cancel") + .expect("preview must render a Cancel control"); + cx.simulate_click(cancel.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.read(|cx| { + assert!(app.read(cx).attachment_attempts.is_empty()); + assert!(app.read(cx).paste_preview.is_none()); + }); + } + + #[gpui::test] + fn escape_cancels_preview_without_sending_and_restores_composer(cx: &mut gpui::TestAppContext) { + let (mut cx, app) = paste_preview_fixture(cx); + + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.read(|cx| { + let app = app.read(cx); + assert!(app.paste_preview.is_none()); + assert!(app.attachment_attempts.is_empty()); + assert_eq!(app.keyboard_owner, Some(KeyboardOwner::Composer)); + }); + cx.update(|window, cx| { + let app = app.read(cx); + let composer = app.input_area.as_ref().unwrap().read(cx).focus_handle(cx); + assert!(composer.is_focused(window)); + }); + } + #[test] fn body_invalidates_for_controllers_theme_resize_and_focus() { let (mut cx, window, app) = setup(); diff --git a/crates/gui/src/app/calls_ctl.rs b/crates/gui/src/app/calls_ctl.rs index 60d4c23d..6a7b0a78 100644 --- a/crates/gui/src/app/calls_ctl.rs +++ b/crates/gui/src/app/calls_ctl.rs @@ -1024,6 +1024,7 @@ impl WhatsAppApp { log::debug!("keyboard: {:?} -> {wanted:?}", self.keyboard_owner); match &wanted { KeyboardOwner::RingingCall(_) => window.focus(&self.call_focus, cx), + KeyboardOwner::PastePreview => window.focus(&self.paste_preview_focus, cx), KeyboardOwner::Viewer => { let handle = self.viewer.read(cx).focus().clone(); window.focus(&handle, cx) @@ -1139,6 +1140,7 @@ fn keyboard_owner_for( let composing = intent == ChatOpen::ToCompose; match ringing_call.filter(|_| surfaces.call_card) { Some(call_id) => KeyboardOwner::RingingCall(call_id), + None if surfaces.paste_preview => KeyboardOwner::PastePreview, None if surfaces.viewer => KeyboardOwner::Viewer, None if showing_settings => KeyboardOwner::Screen, // Where both are drawn, the gesture decides. A chat opened to be @@ -1212,6 +1214,7 @@ mod keyboard_owner_tests { chat_list: false, composer: false, viewer: false, + paste_preview: false, call_card: false, }; @@ -1233,6 +1236,7 @@ mod keyboard_owner_tests { chat_list: true, composer: true, viewer: true, + paste_preview: true, call_card: true, }; assert_eq!( @@ -1241,12 +1245,21 @@ mod keyboard_owner_tests { ); assert_eq!( keyboard_owner_for(None, all, ChatOpen::ToCompose, true), + KeyboardOwner::PastePreview, + "a pending paste outranks the media viewer and settings" + ); + assert_eq!( + owner(KeyboardSurfaces { + paste_preview: false, + ..all + }), KeyboardOwner::Viewer, - "a picture outranks a screen that focuses nothing of its own" + "the media viewer is next when no paste preview is open" ); assert_eq!( owner(KeyboardSurfaces { viewer: false, + paste_preview: false, ..all }), KeyboardOwner::Composer diff --git a/crates/gui/src/app/chats.rs b/crates/gui/src/app/chats.rs index 8b7faf32..cb21385e 100644 --- a/crates/gui/src/app/chats.rs +++ b/crates/gui/src/app/chats.rs @@ -22,16 +22,18 @@ pub enum ChatFilter { All, Unread, Groups, + Archived, } impl ChatFilter { - pub const ALL: [Self; 3] = [Self::All, Self::Unread, Self::Groups]; + pub const ALL: [Self; 4] = [Self::All, Self::Unread, Self::Groups, Self::Archived]; pub fn id(self) -> &'static str { match self { Self::All => "all", Self::Unread => "unread", Self::Groups => "groups", + Self::Archived => "archived", } } @@ -40,15 +42,17 @@ impl ChatFilter { Self::All => "All", Self::Unread => "Unread", Self::Groups => "Groups", + Self::Archived => "Archived", } } /// Whether `chat` belongs under this filter. pub fn matches(self, chat: &Chat) -> bool { match self { - Self::All => true, - Self::Unread => chat.unread_count > 0 || chat.manually_unread, - Self::Groups => chat.is_group, + Self::All => !chat.archived, + Self::Unread => !chat.archived && (chat.unread_count > 0 || chat.manually_unread), + Self::Groups => !chat.archived && chat.is_group, + Self::Archived => chat.archived, } } } @@ -56,10 +60,11 @@ impl ChatFilter { /// What a complete store load says about a chat already on screen. /// /// A complete load is the store's whole truth about the rows it has, so a -/// store-backed chat missing from one was archived or deleted — possibly on -/// another device — and has to leave the window too. Two things stop that -/// being a plain removal, and naming them here is what keeps the rule in one -/// place: a live-only chat was never in the store to be missing from it (during +/// active store-backed chat missing from one was archived or deleted — +/// possibly on another device — and has to leave the main window too. An +/// explicitly archived chat is outside that load's scope and stays available +/// to the Archived filter. Two more things stop absence being a plain removal: +/// a live-only chat was never in the store to be missing from it (during /// pairing the store is empty while live messages already populate the UI), /// and the conversation being *read* is not yanked out from under its reader. /// @@ -85,7 +90,29 @@ pub fn survives_complete_load( loaded: &std::collections::HashSet<&str>, visible: Option<&str>, ) -> Survival { - if !chat.is_from_store() || loaded.contains(chat.jid.as_str()) { + // The ordinary complete load is complete only for the non-archived + // list. Once an archived page has installed a row, absence from that + // active load says nothing about it and must not erase the explicit list. + if chat.archived || !chat.is_from_store() || loaded.contains(chat.jid.as_str()) { + Survival::Keep + } else if visible == Some(chat.jid.as_str()) { + Survival::Defer + } else { + Survival::Drop + } +} + +/// What a complete include-archived scan says about rows already held. +/// +/// Unlike the active attach load, reaching the final page covers archived +/// rows too. An archived, store-backed row absent from `loaded` was deleted; +/// an active or live-only row is outside this scan's authority. +pub fn survives_archived_scan( + chat: &Chat, + loaded: &std::collections::HashSet, + visible: Option<&str>, +) -> Survival { + if !chat.archived || !chat.is_from_store() || loaded.contains(&chat.jid) { Survival::Keep } else if visible == Some(chat.jid.as_str()) { Survival::Defer @@ -176,13 +203,17 @@ impl WhatsAppApp { // same reason: a reload while the reader is in Status // would otherwise clear the badge of a conversation nobody // was looking at. - if self.window_focused && self.visible_chat.as_deref() == Some(jid.as_str()) { + if self.window_focused + && crate::platform::application_is_active() + && self.visible_chat.as_deref() == Some(jid.as_str()) + { Arc::make_mut(&mut self.chats[at]).mark_as_read(); } // The read a row without messages could not bound. Spent // here because this is what gave it a message to name; see // `owed_reads`. if self.window_focused + && crate::platform::application_is_active() && self.visible_chat.as_deref() == Some(jid.as_str()) && self.owed_reads.contains(&jid) && let Some(newest) = newest_shared_message(&self.chats[at]) @@ -279,6 +310,20 @@ mod tests { assert!(!ChatFilter::Groups.matches(&chat("a@s.whatsapp.net", false, 9, false))); } + #[test] + fn archived_is_a_separate_list_for_direct_chats_and_groups() { + let mut direct = chat("a@s.whatsapp.net", false, 0, false); + direct.archived = true; + let mut group = chat("g@g.us", true, 0, false); + group.archived = true; + + assert!(ChatFilter::Archived.matches(&direct)); + assert!(ChatFilter::Archived.matches(&group)); + assert!(!ChatFilter::All.matches(&direct)); + assert!(!ChatFilter::Unread.matches(&direct)); + assert!(!ChatFilter::Groups.matches(&group)); + } + fn from_store(jid: &str) -> Chat { Chat::from_store(jid.to_string(), "Someone".to_string(), 0) } @@ -311,6 +356,40 @@ mod tests { ); } + #[test] + fn an_archived_chat_survives_the_active_lists_complete_load() { + let mut archived = from_store("a@s.whatsapp.net"); + archived.archived = true; + assert_eq!( + survives_complete_load(&archived, &std::collections::HashSet::new(), None), + Survival::Keep + ); + } + + #[test] + fn a_complete_archived_scan_drops_only_missing_archived_rows() { + let loaded = std::collections::HashSet::from(["kept@s.whatsapp.net".to_string()]); + let mut kept = from_store("kept@s.whatsapp.net"); + kept.archived = true; + let mut deleted = from_store("deleted@s.whatsapp.net"); + deleted.archived = true; + let active = from_store("active@s.whatsapp.net"); + + assert_eq!(survives_archived_scan(&kept, &loaded, None), Survival::Keep); + assert_eq!( + survives_archived_scan(&deleted, &loaded, None), + Survival::Drop + ); + assert_eq!( + survives_archived_scan(&deleted, &loaded, Some("deleted@s.whatsapp.net")), + Survival::Defer + ); + assert_eq!( + survives_archived_scan(&active, &loaded, None), + Survival::Keep + ); + } + #[test] fn the_conversation_on_screen_is_spared_but_owed_a_removal() { let loaded = std::collections::HashSet::from(["b@s.whatsapp.net"]); @@ -338,7 +417,7 @@ mod tests { #[test] fn filter_ids_are_stable_and_distinct() { let ids: Vec<&str> = ChatFilter::ALL.iter().map(|f| f.id()).collect(); - assert_eq!(ids, vec!["all", "unread", "groups"]); + assert_eq!(ids, vec!["all", "unread", "groups", "archived"]); } fn tied_chat(jid: &str, pin_secs: Option, secs: Option) -> Chat { diff --git a/crates/gui/src/app/commands.rs b/crates/gui/src/app/commands.rs index 368a6829..4355a2c2 100644 --- a/crates/gui/src/app/commands.rs +++ b/crates/gui/src/app/commands.rs @@ -327,6 +327,9 @@ impl WhatsAppApp { self.decline_waiting_call(cx); return; } + if self.cancel_paste_preview(cx) { + return; + } if self.close_media_viewer(cx) { return; } diff --git a/crates/gui/src/app/events.rs b/crates/gui/src/app/events.rs index daeecf21..fc59c2c4 100644 --- a/crates/gui/src/app/events.rs +++ b/crates/gui/src/app/events.rs @@ -95,6 +95,20 @@ impl WhatsAppApp { // a conversation that believes it has everything asks for // nothing. See `forget_chat_paging`. self.forget_chat_paging(&dropped, cx); + // An archived scan may have deferred a deleted row that + // is still on screen. This active-only complete load says + // nothing about archived rows, so keep that debt until + // the conversation stops being visible. + departed.extend( + self.departed_chats + .iter() + .filter(|jid| { + self.chats + .iter() + .any(|chat| chat.jid.as_str() == jid.as_str() && chat.archived) + }) + .cloned(), + ); self.departed_chats = departed; } // The updates this load itself brought back already read: @@ -233,8 +247,21 @@ impl WhatsAppApp { chat_jid, message, sender_name, + notification_allowed, + notification_title, + notification_archived, } => { - self.handle_message_received(chat_jid, *message, sender_name, cx); + self.handle_message_received( + chat_jid, + *message, + sender_name, + IncomingAlert::new( + notification_allowed, + notification_title, + notification_archived, + ), + cx, + ); // A live status update brings its own 24-hour deadline with // it, and it can be the earliest one on screen. self.ensure_status_tick(cx); diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index 9a98ecb0..2bfdb3be 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -34,7 +34,9 @@ pub use calls::CallCard; #[cfg(all(test, unix))] pub(crate) use calls_ctl::exercise_call_frame_adoption; pub use chat_row::{ChatRow, Preview, PreviewGlyph, Unread}; -pub use chats::{ChatFilter, ChatListCache, Survival, survives_complete_load}; +pub use chats::{ + ChatFilter, ChatListCache, Survival, survives_archived_scan, survives_complete_load, +}; pub use media::RecordingState; pub use messages::{BubbleIds, MessageListCache, TimelineItem}; pub use paging::nearing_end; @@ -123,6 +125,8 @@ enum KeyboardOwner { /// A call that is ringing — not one that has been answered, which is a /// call people type through. RingingCall(String), + /// An image pasted into the composer, waiting for explicit confirmation. + PastePreview, /// The fullscreen viewer, which owns the arrow keys while it is up. Viewer, /// A screen with its own controls — Settings. It is handed the window's @@ -152,10 +156,22 @@ pub struct KeyboardSurfaces { /// `leave_connected_view` does not close it — while the error screen that /// replaces the conversation draws nothing of it. pub viewer: bool, + /// The modal preview for a pasted image. + pub paste_preview: bool, /// The call card, which only the connected screens float. pub call_card: bool, } +struct PendingPastePreview { + jid: String, + reply: Option, + file: crate::platform::picker::Picked, + image: Arc, + /// Whether the captured destination was the conversation on screen before + /// this modal deliberately hid it from read/paging accounting. + chat_was_visible: bool, +} + /// The layout a set of row heights was measured against. /// /// The list keeps one measured height per index and nothing about a row says @@ -277,7 +293,7 @@ use indexmap::IndexMap; use gpui::{ App, Context, Entity, FocusHandle, Focusable, Image, KeyBinding, ListState, ScrollStrategy, - Task, WeakEntity, Window, actions, div, prelude::*, + SystemNotification, Task, WeakEntity, Window, actions, div, prelude::*, }; use gpui_component::VirtualListScrollHandle; use gpui_component::input::InputState; @@ -349,6 +365,7 @@ pub struct RetryMessage { use crate::components::{ AccountSummary, InputAreaEvent, InputAreaView, ReplyDraft, new_timeline_state, + render_paste_preview, }; use log::{debug, error, info, warn}; use wacore_binary::jid::{Jid, JidExt, observe_str}; @@ -638,6 +655,9 @@ pub struct WhatsAppApp { control: Option, /// Destination captured while an asynchronous clipboard read is pending. pending_pastes: HashMap)>, + paste_preview: Option, + #[cfg(test)] + attachment_attempts: Vec, /// Scroll handle for chat list chat_list_scroll: VirtualListScrollHandle, /// The Status sidebar's scroll position, so that list can have a @@ -648,6 +668,8 @@ pub struct WhatsAppApp { /// Focus target for the call card, so its actions are reachable from /// the keyboard while it floats over the app. call_focus: FocusHandle, + /// Focus target for the pasted-image confirmation modal. + paste_preview_focus: FocusHandle, /// Focus target for the window itself, so the actions hung off the root /// are reachable whatever else is on screen — including on the screens on /// the way to a conversation, which have no list and no composer to @@ -662,6 +684,11 @@ pub struct WhatsAppApp { keyboard_owner: Option, window_focused: bool, window_activation: Option, + /// Incoming messages this process has already surfaced to the operating + /// system. The session may redeliver an event while reconnecting, and a + /// notification is a user-visible side effect rather than an idempotent + /// timeline merge. + notified_messages: IndexMap<(String, String, String), ()>, /// Whether the last gesture that touched a conversation was someone /// meaning to *talk* to it or meaning to *look* at it. /// @@ -717,6 +744,9 @@ pub struct WhatsAppApp { /// without remembering the omission, the chat outlived its deletion until /// some unrelated later reload happened to notice again. departed_chats: std::collections::HashSet, + /// Archived store rows seen since the current include-archived scan + /// started at the top. When its final page arrives, absence is deletion. + archived_scan: std::collections::HashSet, /// Chats opened before their messages arrived, whose reads are still owed. /// /// `MarkRead` is a claim about the message the requester was looking at, @@ -907,6 +937,24 @@ pub struct WhatsAppApp { last_avatar_window_fingerprint: Option, } +/// Store-backed attention decision delivered with a live message. The GUI's +/// paged chat row may not yet know any of these facts. +struct IncomingAlert { + allowed: bool, + title: Option, + archived: bool, +} + +impl IncomingAlert { + fn new(allowed: bool, title: Option, archived: bool) -> Self { + Self { + allowed, + title, + archived, + } + } +} + impl WhatsAppApp { pub fn media_cache(&self) -> Option> { self.client.as_ref().map(Session::media_cache) @@ -1086,8 +1134,12 @@ impl WhatsAppApp { } => entity.update(cx, |app, cx| { app.apply_message_page(jid, messages, next, cx); }), - FromDaemon::Chats { chats, next } => entity.update(cx, |app, cx| { - app.apply_chat_page(chats, next, cx); + FromDaemon::Chats { + chats, + next, + archived, + } => entity.update(cx, |app, cx| { + app.apply_chat_page(chats, next, archived, cx); }), // Who is in a group, for the line under its name. FromDaemon::Members(roster) => entity.update(cx, |app, cx| { @@ -1106,8 +1158,8 @@ impl WhatsAppApp { FromDaemon::CallFrames => entity.update(cx, |app, cx| { app.draw_waiting_call_frames(cx); }), - FromDaemon::PageLost { jid } => entity.update(cx, |app, cx| { - app.page_lost(jid, cx); + FromDaemon::PageLost { jid, archived } => entity.update(cx, |app, cx| { + app.page_lost(jid, archived, cx); }), FromDaemon::StatusViewLost(message_ids) => entity.update(cx, |app, cx| { app.forget_status_views(&message_ids, cx); @@ -1167,14 +1219,19 @@ impl WhatsAppApp { selected_chat: None, client: None, pending_pastes: HashMap::new(), + paste_preview: None, + #[cfg(test)] + attachment_attempts: Vec::new(), chat_list_scroll: VirtualListScrollHandle::new(), status_list_scroll: gpui::ScrollHandle::new(), chat_list_focus: cx.focus_handle(), call_focus: cx.focus_handle(), + paste_preview_focus: cx.focus_handle(), root_focus: cx.focus_handle(), keyboard_owner: None, window_focused: false, window_activation: None, + notified_messages: IndexMap::new(), // Nothing has been opened to talk to yet, and a window that comes // up on a restored selection is one nobody has typed into. keyboard_intent: ChatOpen::ToPreview, @@ -1184,6 +1241,7 @@ impl WhatsAppApp { visible_chat: None, retained_chat: None, departed_chats: std::collections::HashSet::new(), + archived_scan: std::collections::HashSet::new(), owed_reads: std::collections::HashSet::new(), pages: cx.new(|_| paging::Pages::new()), watched_status: std::collections::HashSet::new(), @@ -1724,13 +1782,13 @@ impl WhatsAppApp { if self.window_activation.is_some() { return; } - self.window_focused = cx - .active_window() - .is_some_and(|active| active == window.window_handle()); + // GPUI's `active_window` is the application's main window on macOS, + // which can remain this window while another application is in front. + // Only the key window is actually receiving input and may claim that + // its visible conversation was read. + self.window_focused = window.is_window_active(); self.window_activation = Some(cx.observe_window_activation(window, |app, window, cx| { - let focused = cx - .active_window() - .is_some_and(|active| active == window.window_handle()); + let focused = window.is_window_active(); if app.window_focused == focused { return; } @@ -1743,6 +1801,9 @@ impl WhatsAppApp { } fn resume_visible_read(&mut self, cx: &mut Context) { + if !self.window_focused || !crate::platform::application_is_active() { + return; + } let Some(jid) = self.visible_chat.clone() else { return; }; @@ -1799,6 +1860,8 @@ impl WhatsAppApp { // newly paired one. self.leave_connected_view(cx); self.pending_pastes.clear(); + self.paste_preview = None; + self.notified_messages.clear(); // A call is account state as much as a chat is. See // [`calls_ctl::Calls::forget`]. self.calls.update(cx, |calls, cx| calls.forget(cx)); @@ -1818,6 +1881,7 @@ impl WhatsAppApp { self.visible_chat = None; self.retained_chat = None; self.departed_chats.clear(); + self.archived_scan.clear(); // The cursors describe positions in one account's store; the next // account's rows are not behind them. self.forget_paging(cx); @@ -1980,6 +2044,11 @@ impl WhatsAppApp { self.is_connected() } + /// Whether the conversation is covered by a pasted-image confirmation. + pub fn paste_preview_showing(&self) -> bool { + self.paste_preview.is_some() + } + /// Whether the user chose to stop waiting and read what is here. pub fn is_offline(&self) -> bool { matches!(self.app_state, AppState::Offline) @@ -2413,6 +2482,7 @@ impl WhatsAppApp { // that keeps a read from swallowing anything newer. All it needs from // here is the message this side is looking at. if self.window_focused + && crate::platform::application_is_active() && let Some(chat) = self .find_chat(&jid) .filter(|c| c.unread_count > 0 || c.manually_unread) @@ -2632,11 +2702,20 @@ impl WhatsAppApp { let Some((jid, reply)) = self.pending_pastes.remove(paste_id) else { return; }; - let quoted = self.take_reply_draft(reply, cx); - if self.send_attachment(&jid, file, quoted, cx) - && self.visible_chat.as_deref() == Some(&jid) - { - self.scroll_to_last_message(); + let Some(format) = gpui::ImageFormat::from_mime_type(&file.mime_type) else { + return; + }; + if self.paste_preview.is_none() { + let image = Arc::new(gpui::Image::from_bytes(format, file.bytes.clone())); + let chat_was_visible = self.visible_chat.as_deref() == Some(jid.as_str()); + self.paste_preview = Some(PendingPastePreview { + jid, + reply, + file, + image, + chat_was_visible, + }); + cx.notify(); } } InputAreaEvent::PasteImageError(paste_id, error) => { @@ -2916,6 +2995,7 @@ impl WhatsAppApp { chat_jid: String, mut message: ChatMessage, sender_name: Option, + alert: IncomingAlert, cx: &mut App, ) { // Parse JID to determine chat type @@ -2931,6 +3011,7 @@ impl WhatsAppApp { // receipt for a message nobody had laid eyes on. let read_now = read_is_allowed( self.window_focused, + crate::platform::application_is_active(), self.visible_chat.as_deref() == Some(chat_jid.as_str()), message.is_from_me, ); @@ -2948,6 +3029,19 @@ impl WhatsAppApp { .or_else(|| self.name_cache.get(&message.sender).cloned()); } + // Capture the user-facing part before the message moves into its + // conversation. Statuses have their own reader and do not represent a + // chat asking for attention; our own sends likewise never notify us. + let notification = + (alert.allowed && !read_now && !message.is_from_me && !is_status).then(|| { + let body = if is_group { + format!("{}: {}", message.author_label(), message.preview_text()) + } else { + message.preview_text() + }; + (message.id.clone(), message.sender.clone(), body) + }); + // Their message ends their typing, more reliably than `paused` does: // the peer that stopped composing is not obliged to say so, and a // sender whose message just arrived is definitively no longer @@ -2964,6 +3058,9 @@ impl WhatsAppApp { if let Some(index) = chat_index { // Update the existing chat let chat = Arc::make_mut(&mut self.chats[index]); + if alert.archived { + chat.archived = true; + } // For groups: update participant name, NOT the chat name if is_group { @@ -3008,6 +3105,7 @@ impl WhatsAppApp { } else { Chat::new(chat_jid.clone()) }; + new_chat.archived = alert.archived; // For groups: track participant if is_group && let Some(ref name) = sender_name { @@ -3038,10 +3136,103 @@ impl WhatsAppApp { self.invalidate_chat_cache(); self.invalidate_message_cache(&chat_jid, cx); } + + if let Some((message_id, sender, body)) = notification { + self.notify_incoming_message(&chat_jid, &message_id, &sender, body, alert.title, cx); + } } - /// Handle a receipt event (read/played status update) - /// A receipt about our own messages: advance their ticks. + /// Raise one operating-system notification for an incoming message. + /// + /// One stable tag per conversation lets a newer message replace the + /// previous banner instead of stacking an unbounded column. The bounded + /// id set is separate: reconnects can repeat a message after its banner + /// has already been delivered, and replacement alone would still make it + /// alert a second time. + fn notify_incoming_message( + &mut self, + chat_jid: &str, + message_id: &str, + sender: &str, + body: String, + notification_title: Option, + cx: &mut App, + ) { + const REMEMBERED_MESSAGES: usize = 256; + + let key = ( + chat_jid.to_string(), + message_id.to_string(), + sender.to_string(), + ); + if self.notified_messages.contains_key(&key) { + return; + } + let Some(chat) = self.find_chat(chat_jid) else { + return; + }; + // The event's policy was read from the durable store after commit. + // This Chat may be a snapshot placeholder or a brand-new live row, + // so its mute/archive defaults cannot override that answer. + let title = notification_title.unwrap_or_else(|| { + if chat.is_group + && matches!( + chat.name.as_str(), + "Unnamed group" | "Group name unavailable" + ) + { + "Group message".to_string() + } else { + chat.name.clone() + } + }); + let avatar_key = chat.avatar_cache_key.clone(); + self.notified_messages.insert(key, ()); + while self.notified_messages.len() > REMEMBERED_MESSAGES { + self.notified_messages.shift_remove_index(0); + } + let tag = notification_tag(chat_jid); + let media_cache = self.media_cache(); + // The native path can include a cached profile image as a content + // thumbnail. Its reader runs off the UI thread after macOS confirms + // authorization. Web builds and non-bundle tests stay on GPUI's path. + if !crate::platform::show_notification_with_avatar(&tag, &title, &body, move || { + let key = avatar_key.as_deref()?; + media_cache.as_ref()?.read(key).ok() + }) { + cx.show_system_notification(SystemNotification { + tag: tag.into(), + title: title.into(), + body: body.into(), + actions: Vec::new(), + }); + } + } + + /// Open the conversation named by a system-notification response. + /// + /// The tag carries only a stable hash, not a phone number or JID. Resolve + /// it against chats this window already owns, then take the ordinary chat + /// selection path so paging, read bounds and group metadata stay intact. + pub fn open_system_notification( + &mut self, + tag: &str, + window: &mut Window, + cx: &mut Context, + ) { + let Some(jid) = self + .chats + .iter() + .find(|chat| notification_tag(&chat.jid) == tag) + .map(|chat| chat.jid.clone()) + else { + return; + }; + self.select_chat(jid, ChatOpen::ToPreview, window, cx); + } + + /// A server acknowledgement or peer receipt about our own messages: + /// advance their ticks. /// /// Only ever moves forward. Receipts arrive out of order and another of /// the peer's devices can repeat a delivery ack after the read one, so a @@ -3053,13 +3244,8 @@ impl WhatsAppApp { receipt_type: ReceiptType, cx: &mut App, ) { - let status = match receipt_type { - ReceiptType::Delivered => MessageStatus::Delivered, - // Played is Read plus "and listened to it"; the ticks are the same. - ReceiptType::Read | ReceiptType::ReadSelf => MessageStatus::Read, - ReceiptType::Played | ReceiptType::PlayedSelf => MessageStatus::Read, - // Retries, errors and sender echoes say nothing about delivery. - _ => return, + let Some(status) = receipt_status(receipt_type.clone()) else { + return; }; let Some(chat) = self.find_chat_mut(&chat_jid) else { @@ -3274,6 +3460,21 @@ impl WhatsAppApp { } } +/// The delivery state carried by one receipt, when it says anything the +/// bubble can draw. `Sent` is the live projection of a positive server ack; +/// durable history remains the recovery path if this event is dropped. +fn receipt_status(receipt_type: ReceiptType) -> Option { + match receipt_type { + ReceiptType::Sent => Some(MessageStatus::Sent), + ReceiptType::Delivered => Some(MessageStatus::Delivered), + // Played is Read plus "and listened to it"; the ticks are the same. + ReceiptType::Read | ReceiptType::ReadSelf => Some(MessageStatus::Read), + ReceiptType::Played | ReceiptType::PlayedSelf => Some(MessageStatus::Read), + // Retries, errors and sender echoes say nothing about delivery. + _ => None, + } +} + /// The newest message in `chat` that the daemon can also name. /// /// `MarkRead` is a claim about what the requester saw, and the daemon checks it @@ -3306,8 +3507,28 @@ fn read_bound(chat: &Chat) -> ReadBound { } } -fn read_is_allowed(window_focused: bool, chat_visible: bool, is_from_me: bool) -> bool { - window_focused && chat_visible && !is_from_me +fn read_is_allowed( + window_focused: bool, + application_active: bool, + chat_visible: bool, + is_from_me: bool, +) -> bool { + window_focused && application_active && chat_visible && !is_from_me +} + +/// Stable, opaque identity for one conversation's desktop notification. +/// +/// The operating system persists notification tags, so the raw address does +/// not belong in one. FNV-1a is sufficient here: this is replacement identity, +/// not authentication, and the app resolves a response against the chats it +/// already holds before opening anything. +fn notification_tag(jid: &str) -> String { + let mut hash = 14_695_981_039_346_656_037u64; + for byte in jid.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(1_099_511_628_211); + } + format!("oxidezap-chat-{hash:016x}") } /// The newest message in `chat` that the daemon has also seen. @@ -3508,9 +3729,22 @@ impl Render for WhatsAppApp { .then(|| render_call_overlay(self, window, cx)) .flatten(); + let paste_preview = self.paste_preview.as_ref().map(|preview| { + render_paste_preview( + preview.image.clone(), + cx.entity().clone(), + self.can_send(), + &self.paste_preview_focus, + cx.product().metrics, + cx, + ) + .into_any_element() + }); + // The card is the one surface the root draws itself, so it is the // one the root answers for. let call_card = call_overlay.is_some(); + let paste_preview_open = paste_preview.is_some(); // Above the call card as well as the body: a notice raised by // something the call did is about the call, and a card that covered @@ -3519,6 +3753,7 @@ impl Render for WhatsAppApp { // asks nothing of the conversation underneath it; the stack draws // nothing at all while it is empty. root.child(body.cached(gpui::StyleRefinement::default().size_full())) + .children(paste_preview) .children(call_overlay) .child(self.notices().clone()) // Cached views report their surfaces in prepaint. Move focus after @@ -3528,6 +3763,7 @@ impl Render for WhatsAppApp { move |_, window, cx| { entity.update(cx, |app, _| { app.keyboard_surfaces.call_card = call_card; + app.keyboard_surfaces.paste_preview = paste_preview_open; }); let entity = entity.downgrade(); window.defer(cx, move |window, cx| { @@ -3627,12 +3863,228 @@ fn timeline_may_page(visible: Option<&str>, anchored: Option<&str>, chat_jid: &s mod tests { use super::*; + #[test] + fn a_sent_receipt_replaces_only_the_outgoing_pending_clock() { + assert_eq!(receipt_status(ReceiptType::Sent), Some(MessageStatus::Sent)); + + let mut chat = Chat::new("12025550143@s.whatsapp.net".to_string()); + let mut pending = ChatMessage::new_outgoing("ACKED-1".to_string(), "waiting".to_string()); + pending.status = MessageStatus::Pending; + let mut delivered = + ChatMessage::new_outgoing("DELIVERED-1".to_string(), "already there".to_string()); + delivered.status = MessageStatus::Delivered; + let mut failed = + ChatMessage::new_outgoing("FAILED-1".to_string(), "did not leave".to_string()); + failed.status = MessageStatus::Failed; + let incoming = ChatMessage::new_incoming( + "INCOMING-1".to_string(), + "12025550143@s.whatsapp.net".to_string(), + "hello".to_string(), + ); + chat.messages = vec![pending, delivered, failed, incoming]; + + assert_eq!( + chat.advance_status(&["ACKED-1".to_string()], MessageStatus::Sent), + 1 + ); + assert_eq!(chat.messages[0].status, MessageStatus::Sent); + assert_eq!( + chat.advance_status(&["DELIVERED-1".to_string()], MessageStatus::Sent), + 0, + "an older acknowledgement must not regress delivery" + ); + assert_eq!(chat.messages[1].status, MessageStatus::Delivered); + assert_eq!( + chat.advance_status(&["FAILED-1".to_string()], MessageStatus::Sent), + 0, + "a late acknowledgement must not revive a failed send" + ); + assert_eq!(chat.messages[2].status, MessageStatus::Failed); + assert_eq!( + chat.advance_status(&["INCOMING-1".to_string()], MessageStatus::Sent), + 0, + "our delivery state never belongs on an incoming message" + ); + } + #[test] fn reads_require_a_focused_visible_chat() { - assert!(!read_is_allowed(false, true, false)); - assert!(!read_is_allowed(true, false, false)); - assert!(!read_is_allowed(true, true, true)); - assert!(read_is_allowed(true, true, false)); + assert!(!read_is_allowed(false, true, true, false)); + assert!(!read_is_allowed(true, false, true, false)); + assert!(!read_is_allowed(true, true, false, false)); + assert!(!read_is_allowed(true, true, true, true)); + assert!(read_is_allowed(true, true, true, false)); + } + + #[gpui::test] + fn an_incoming_message_outside_the_active_chat_raises_one_system_notification( + cx: &mut gpui::TestAppContext, + ) { + let app = cx.update(|cx| { + cx.set_app_identity("org.oxidezap.test", "OxideZap Test"); + cx.new(|cx| { + let mut app = WhatsAppApp::new(cx); + app.chats.push(Arc::new(Chat::with_name( + "peer@example.invalid".into(), + "Example contact".into(), + ))); + let mut muted = + Chat::with_name("muted@example.invalid".into(), "Muted contact".into()); + muted.muted_until = Some(chrono::DateTime::::MAX_UTC); + app.chats.push(Arc::new(muted)); + app.chats.push(Arc::new(Chat::with_name( + "group@g.us".into(), + "Example group".into(), + ))); + app.visible_chat = Some("elsewhere@example.invalid".into()); + app.window_focused = true; + app + }) + }); + + for _ in 0..2 { + cx.update(|cx| { + app.update(cx, |app, cx| { + app.handle_message_received( + "peer@example.invalid".into(), + ChatMessage::new_incoming( + "MESSAGE-1".into(), + "peer@example.invalid".into(), + "New message".into(), + ), + Some("Example contact".into()), + IncomingAlert::new(true, None, false), + cx, + ); + }); + }); + } + + cx.update(|cx| { + app.update(cx, |app, cx| { + app.handle_message_received( + "muted@example.invalid".into(), + ChatMessage::new_incoming( + "MESSAGE-MUTED".into(), + "muted@example.invalid".into(), + "Quiet message".into(), + ), + Some("Muted contact".into()), + IncomingAlert::new(false, None, false), + cx, + ); + }); + }); + + cx.update(|cx| { + app.update(cx, |app, cx| { + // The phone has unmuted it, but this GUI row has not yet + // hydrated that change. The event's store decision wins. + app.handle_message_received( + "muted@example.invalid".into(), + ChatMessage::new_incoming( + "MESSAGE-MUTED".into(), + "muted@example.invalid".into(), + "No longer quiet".into(), + ), + Some("Muted contact".into()), + IncomingAlert::new(true, None, false), + cx, + ); + }); + }); + + let notifications = cx.shown_system_notifications(); + assert_eq!( + notifications.len(), + 2, + "an ineligible delivery must not consume the deduplication key" + ); + assert_eq!(notifications[0].title.as_ref(), "Example contact"); + assert_eq!(notifications[0].body.as_ref(), "New message"); + assert_eq!(notifications[1].title.as_ref(), "Muted contact"); + assert_eq!(notifications[1].body.as_ref(), "No longer quiet"); + + for sender in ["member-a@example.invalid", "member-b@example.invalid"] { + cx.update(|cx| { + app.update(cx, |app, cx| { + app.handle_message_received( + "group@g.us".into(), + ChatMessage::new_incoming( + "COLLIDING-ID".into(), + sender.into(), + "Group message".into(), + ), + Some(sender.into()), + IncomingAlert::new(true, None, false), + cx, + ); + }); + }); + } + assert_eq!( + cx.shown_system_notifications().len(), + 4, + "same message id from two group senders is two notifications" + ); + + // A live group may not be on any GUI page yet. An unknown store + // decision suppresses its first alert, and a known decision carries + // the durable subject even though this window has only a fallback. + cx.update(|cx| { + app.update(cx, |app, cx| { + app.handle_message_received( + "new-group@g.us".into(), + ChatMessage::new_incoming( + "MESSAGE-NEW-1".into(), + "member@example.invalid".into(), + "Muted before hydration".into(), + ), + Some("Member".into()), + IncomingAlert::new(false, Some("Stored subject".into()), false), + cx, + ); + app.handle_message_received( + "new-group@g.us".into(), + ChatMessage::new_incoming( + "MESSAGE-NEW-2".into(), + "member@example.invalid".into(), + "Allowed before hydration".into(), + ), + Some("Member".into()), + IncomingAlert::new(true, Some("Stored subject".into()), false), + cx, + ); + }); + }); + let notifications = cx.shown_system_notifications(); + assert_eq!(notifications.len(), 5); + assert_eq!(notifications[4].title.as_ref(), "Stored subject"); + + cx.update(|cx| { + app.update(cx, |app, cx| { + app.handle_message_received( + "archived-group@g.us".into(), + ChatMessage::new_incoming( + "MESSAGE-ARCHIVED-MENTION".into(), + "member@example.invalid".into(), + "Mentioned you".into(), + ), + Some("Member".into()), + IncomingAlert::new(true, Some("Mentioned in Archived example".into()), true), + cx, + ); + assert!( + app.find_chat("archived-group@g.us") + .is_some_and(|chat| chat.archived), + "a mentioned archived group must not reappear in the active list" + ); + }); + }); + assert_eq!( + cx.shown_system_notifications()[5].title.as_ref(), + "Mentioned in Archived example" + ); } fn at(secs: i64) -> Option> { diff --git a/crates/gui/src/app/paging.rs b/crates/gui/src/app/paging.rs index 7504d8cb..7566c64d 100644 --- a/crates/gui/src/app/paging.rs +++ b/crates/gui/src/app/paging.rs @@ -107,6 +107,8 @@ pub(super) struct Pages { timelines: TimelinePages, /// Where the chat list continues. chats: Paging, + /// The include-archived list has a different ordering window and cursor. + archived_chats: Paging, } impl Pages { @@ -114,6 +116,15 @@ impl Pages { Self { timelines: TimelinePages::new(), chats: Paging::default(), + archived_chats: Paging::default(), + } + } + + fn chat_list(&mut self, archived: bool) -> &mut Paging { + if archived { + &mut self.archived_chats + } else { + &mut self.chats } } @@ -148,9 +159,10 @@ impl Pages { /// The cursor the next page of the chat list is asked with, marking it /// asked. The inner `None` is "from the top". - fn more_chats(&mut self) -> Option> { - let ask = self.chats.to_ask()?; - self.chats = Paging::Loading { from: ask.clone() }; + fn more_chats(&mut self, archived: bool) -> Option> { + let list = self.chat_list(archived); + let ask = list.to_ask()?; + *list = Paging::Loading { from: ask.clone() }; Some(ask) } @@ -172,23 +184,27 @@ impl Pages { } /// The same, for the chat list. - fn chat_page_arrived(&mut self, next: Option) -> bool { - let Paging::Loading { from } = &self.chats else { + fn chat_page_arrived(&mut self, archived: bool, next: Option) -> bool { + let list = self.chat_list(archived); + let Paging::Loading { from } = list else { return false; }; - self.chats = Paging::arrived(from.clone(), next); + *list = Paging::arrived(from.clone(), next); true } /// A page that was refused. Put the position back so it can be asked for /// again; a list that stayed `Loading` would never ask anything again. - fn lost(&mut self, jid: Option<&str>) { + fn lost(&mut self, jid: Option<&str>, archived: bool) { match jid { Some(jid) => { let paging = self.timelines.entry(jid.to_string()).or_default(); *paging = paging.lost(); } - None => self.chats = self.chats.lost(), + None => { + let list = self.chat_list(archived); + *list = list.lost(); + } } } @@ -209,12 +225,23 @@ impl Pages { } } self.chats = self.chats.reopened(); + // Unlike the active list, no attach load refreshes the archived + // list's first page. An archive change can insert a recent chat + // before every cursor this window already crossed, so resuming from + // the old end would never discover it. Restart from the top once any + // in-flight answer has settled; a request already on the wire keeps + // its position so an older response cannot be mistaken for a newer + // one. + if !matches!(self.archived_chats, Paging::Loading { .. }) { + self.archived_chats = Paging::Unasked; + } } /// Everything this window learned about where its lists continue. fn forget(&mut self) { self.timelines.clear(); self.chats = Paging::Unasked; + self.archived_chats = Paging::Unasked; } } @@ -265,8 +292,12 @@ impl WhatsAppApp { let Some(client) = &self.client else { return; }; - if let Some(ask) = self.pages.update(cx, |pages, _| pages.more_chats()) { - client.load_chats(ask); + let archived = self.chat_filter == super::ChatFilter::Archived; + if let Some(ask) = self.pages.update(cx, |pages, _| pages.more_chats(archived)) { + if archived && ask.is_none() { + self.archived_scan.clear(); + } + client.load_chats(ask, archived); } } @@ -324,33 +355,83 @@ impl WhatsAppApp { &mut self, chats: Vec, next: Option, + archived: bool, cx: &mut Context, ) { + let archived_scan_finished = archived && next.is_none(); // The same rule, and the one that matters most: this page's rows go // into the list whether or not anything else remembers them. if !self .pages - .update(cx, |pages, _| pages.chat_page_arrived(next)) + .update(cx, |pages, _| pages.chat_page_arrived(archived, next)) { debug!("a chat page arrived that nobody asked for"); return; } if chats.is_empty() { + if archived_scan_finished { + self.finish_archived_scan(cx); + } return; } + if archived { + self.archived_scan.extend( + chats + .iter() + .filter(|chat| chat.archived) + .map(|chat| chat.jid.clone()), + ); + } // The same entrance a history load uses: a page that merely called // `merge_chats` left a notice for a group that arrives only by page // parked forever, and never armed the status tick for a broadcast // that arrived the same way. Nothing here says a status update was // watched — only a load knows that. self.install_chats(chats, &std::collections::HashSet::new(), cx); + if archived_scan_finished { + self.finish_archived_scan(cx); + } + cx.notify(); + } + + /// Prune archived rows only after their include-archived scan reached its + /// final page. Before then, absence means merely "not loaded yet". + fn finish_archived_scan(&mut self, cx: &mut Context) { + let visible = self.visible_chat.clone(); + let mut deferred = Vec::new(); + let mut dropped = Vec::new(); + { + let mut cache = self.message_list_cache.borrow_mut(); + self.chats.retain(|chat| { + match super::survives_archived_scan(chat, &self.archived_scan, visible.as_deref()) { + super::Survival::Keep => true, + super::Survival::Defer => { + deferred.push(chat.jid.clone()); + true + } + super::Survival::Drop => { + cache.remove(&chat.jid); + dropped.push(chat.jid.clone()); + false + } + } + }); + } + self.departed_chats.extend(deferred); + if dropped.is_empty() { + return; + } + self.forget_chat_paging(&dropped, cx); + self.forget_missing_selection(); + self.invalidate_chat_cache(); cx.notify(); } /// A page that was refused. Put the position back so it can be asked for /// again; a view that stayed `Loading` would never ask anything again. - pub(super) fn page_lost(&mut self, jid: Option, cx: &mut App) { - self.pages.update(cx, |pages, _| pages.lost(jid.as_deref())); + pub(super) fn page_lost(&mut self, jid: Option, archived: bool, cx: &mut App) { + self.pages + .update(cx, |pages, _| pages.lost(jid.as_deref(), archived)); } /// Forget where these conversations continued. @@ -521,6 +602,19 @@ mod tests { } } + #[test] + fn active_and_archived_chat_lists_keep_independent_cursors() { + let mut pages = Pages::new(); + + assert_eq!(pages.more_chats(false), Some(None)); + assert_eq!(pages.more_chats(true), Some(None)); + assert!(pages.chat_page_arrived(false, Some(cursor("active-next")))); + assert!(pages.chat_page_arrived(true, Some(cursor("archive-next")))); + + assert_eq!(pages.more_chats(false), Some(Some(cursor("active-next")))); + assert_eq!(pages.more_chats(true), Some(Some(cursor("archive-next")))); + } + /// A load walks the store's order itself, so what it says about the end /// of the list beats anything a window could infer from the rows. #[test] @@ -762,12 +856,12 @@ mod tests { fn a_page_from_a_forgotten_account_is_refused() { let mut pages = Pages::new(); pages.open_timeline(CHAT); - assert!(pages.more_chats().is_some()); + assert!(pages.more_chats(false).is_some()); pages.forget(); assert!(!pages.timeline_page_arrived(CHAT, None)); - assert!(!pages.chat_page_arrived(None)); + assert!(!pages.chat_page_arrived(false, None)); } /// A refusal puts both lists back where they were asking from, so the @@ -778,13 +872,17 @@ mod tests { pages.open_timeline(CHAT); pages.timeline_page_arrived(CHAT, Some(cursor("m1:9:2"))); pages.older(CHAT); - pages.more_chats(); + pages.more_chats(false); - pages.lost(Some(CHAT)); - pages.lost(None); + pages.lost(Some(CHAT), false); + pages.lost(None, false); assert_eq!(pages.older(CHAT), Some(cursor("m1:9:2"))); - assert_eq!(pages.more_chats(), Some(None), "from the top, as before"); + assert_eq!( + pages.more_chats(false), + Some(None), + "from the top, as before" + ); } /// A chat that left takes its position with it: a JID is reused, and a @@ -814,7 +912,7 @@ mod tests { pages.timeline_page_arrived(CHAT, Some(cursor("m1:9:2"))); pages.older(CHAT); pages.timeline_page_arrived(CHAT, None); - pages.more_chats(); + pages.more_chats(false); pages.reopen(&[CHAT.to_string()]); @@ -824,9 +922,26 @@ mod tests { "asking again from where it stopped" ); assert_eq!( - pages.more_chats(), + pages.more_chats(false), None, "and a chat list still waiting is left alone" ); } + + #[test] + fn an_archived_list_restarts_at_the_top_after_store_changes() { + let mut pages = Pages::new(); + pages.more_chats(true); + pages.chat_page_arrived(true, Some(cursor("archive-next"))); + pages.more_chats(true); + pages.chat_page_arrived(true, None); + + pages.reopen(&[]); + + assert_eq!( + pages.more_chats(true), + Some(None), + "a newly archived recent chat can sort before the old cursor" + ); + } } diff --git a/crates/gui/src/components/chat_list/mod.rs b/crates/gui/src/components/chat_list/mod.rs index 586e1d3b..342d536f 100644 --- a/crates/gui/src/components/chat_list/mod.rs +++ b/crates/gui/src/components/chat_list/mod.rs @@ -324,6 +324,7 @@ fn render_empty( EmptyState::new(match props.filter { ChatFilter::Unread => "Nothing unread", ChatFilter::Groups => "No groups", + ChatFilter::Archived => "No archived chats", ChatFilter::All => "No chats", }) .icon(IconName::Inbox) diff --git a/crates/gui/src/components/input_area_view.rs b/crates/gui/src/components/input_area_view.rs index 6ffb3f70..df2a86db 100644 --- a/crates/gui/src/components/input_area_view.rs +++ b/crates/gui/src/components/input_area_view.rs @@ -7,14 +7,11 @@ use std::{cell::RefCell, rc::Rc, time::Duration}; use wacore::time::Instant; -use gpui::{ - App, Entity, EventEmitter, Focusable as _, KeyDownEvent, Task, WeakEntity, Window, div, - prelude::*, -}; +use gpui::{App, Entity, EventEmitter, Focusable as _, Task, WeakEntity, Window, div, prelude::*}; use gpui_component::{ ActiveTheme, Disableable as _, Icon, IconName, Sizable as _, button::{Button, ButtonVariants}, - input::{InputEvent, Textarea, TextareaState}, + input::{InputEvent, Paste, Textarea, TextareaState}, }; use crate::components::{ProductIcon, parts}; @@ -484,17 +481,9 @@ impl InputAreaView { div() .flex_1() .min_w_0() - .on_key_down(cx.listener(|view, event: &KeyDownEvent, _window, cx| { - let modifiers = &event.keystroke.modifiers; - if event.keystroke.key.eq_ignore_ascii_case("v") - && modifiers.secondary() - && !modifiers.alt - && !modifiers.shift - && !modifiers.function - { - view.paste_image(cx); - } - })) + .capture_action::( + cx.listener(|view, _: &Paste, _window, cx| view.paste_image(cx)), + ) .child(Textarea::new(&self.input).w_full()), ) .child( @@ -690,3 +679,146 @@ fn render_reply_bar( }), ) } + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, rc::Rc, sync::Arc}; + + use gpui::{ + AppContext as _, ClipboardItem, Entity, Image, ImageFormat, IntoElement, Keystroke, + ParentElement, Render, Styled as _, Subscription, Window, div, px, size, + }; + + use super::{InputAreaEvent, InputAreaView}; + + struct ComposerHarness { + input: Entity, + } + + struct ComposerFixture { + cx: gpui::HeadlessAppContext, + window: gpui::WindowHandle, + input: Entity, + pasted_images: Rc>>>, + _events: Subscription, + } + + impl ComposerHarness { + fn new(window: &mut Window, cx: &mut gpui::Context) -> Self { + let input = cx.new(|cx| InputAreaView::new(window, cx)); + Self { input } + } + } + + impl Render for ComposerHarness { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut gpui::Context, + ) -> impl IntoElement { + div().size_full().child(self.input.clone()) + } + } + + fn setup() -> ComposerFixture { + let mut cx = gpui::HeadlessAppContext::with_asset_source( + Arc::new(gpui_wgpu::CosmicTextSystem::new("DejaVu Sans")), + Arc::new(crate::assets::Assets), + ); + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let window = cx + .open_window(size(px(640.), px(120.)), |window, cx| { + cx.new(|cx| ComposerHarness::new(window, cx)) + }) + .unwrap(); + cx.run_until_parked(); + let input = window + .update(&mut cx, |harness, _, _| harness.input.clone()) + .unwrap(); + let pasted_images = Rc::new(RefCell::new(Vec::new())); + let observed = pasted_images.clone(); + let events = cx.update(|cx| { + cx.subscribe(&input, move |_, event: &InputAreaEvent, _| { + if let InputAreaEvent::PasteImage(_, file) = event + && let Some(file) = file.borrow().as_ref() + { + observed.borrow_mut().push(file.bytes.clone()); + } + }) + }); + cx.update_window(*window, |_, window, cx| { + input.read(cx).focus_handle(cx).focus(window, cx); + }) + .unwrap(); + cx.run_until_parked(); + ComposerFixture { + cx, + window, + input, + pasted_images, + _events: events, + } + } + + fn paste_shortcut() -> Keystroke { + Keystroke::parse(if cfg!(target_os = "macos") { + "cmd-v" + } else { + "ctrl-v" + }) + .unwrap() + } + + fn paste(cx: &mut gpui::HeadlessAppContext, window: &gpui::WindowHandle) { + cx.update_window((*window).into(), |_, window, cx| { + window.dispatch_keystroke(paste_shortcut(), cx); + }) + .unwrap(); + cx.run_until_parked(); + } + + #[test] + fn focused_composer_pastes_a_clipboard_image() { + let ComposerFixture { + mut cx, + window, + pasted_images, + _events, + .. + } = setup(); + let bytes = vec![1, 2, 3, 4]; + cx.update(|cx| { + cx.write_to_clipboard(ClipboardItem::new_image(&Image { + format: ImageFormat::Png, + bytes: bytes.clone(), + id: 0, + })); + }); + + paste(&mut cx, &window); + + let pasted = pasted_images.borrow().clone(); + assert_eq!(pasted, vec![bytes]); + } + + #[test] + fn focused_composer_still_pastes_text_once() { + let ComposerFixture { + mut cx, + window, + input, + pasted_images, + _events, + .. + } = setup(); + cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("hello".into()))); + + paste(&mut cx, &window); + + cx.update(|cx| assert_eq!(input.read(cx).input.read(cx).text(), "hello")); + assert!(pasted_images.borrow().is_empty()); + } +} diff --git a/crates/gui/src/components/mod.rs b/crates/gui/src/components/mod.rs index 1d329a5e..e6341b66 100644 --- a/crates/gui/src/components/mod.rs +++ b/crates/gui/src/components/mod.rs @@ -15,6 +15,7 @@ mod message_list; mod nav_rail; pub mod notice; pub mod parts; +mod paste_preview; pub mod plugin_ui; mod rich_text; mod status; @@ -33,6 +34,7 @@ pub use media_viewer::{ViewerProps, render_media_viewer}; pub use message_bubble::render_message_bubble; pub use message_list::{new_timeline_state, render_message_list}; pub use nav_rail::render_nav_rail; +pub use paste_preview::render_paste_preview; pub use plugin_ui::PluginContext; pub use rich_text::{BubbleText, render_rich_text}; pub use status::{ diff --git a/crates/gui/src/components/paste_preview.rs b/crates/gui/src/components/paste_preview.rs new file mode 100644 index 00000000..cd082eac --- /dev/null +++ b/crates/gui/src/components/paste_preview.rs @@ -0,0 +1,98 @@ +//! Confirmation surface for an image read from the clipboard. + +use std::sync::Arc; + +use gpui::{ + App, Entity, FocusHandle, Image, ImageSource, InteractiveElement as _, IntoElement, ObjectFit, + ParentElement as _, Styled as _, StyledImage as _, div, img, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::{Disableable as _, FocusTrapElement as _}; + +use crate::app::WhatsAppApp; +use crate::components::parts; +use crate::theme::Metrics; + +pub fn render_paste_preview( + image: Arc, + app: Entity, + can_send: bool, + focus_handle: &FocusHandle, + metrics: Metrics, + cx: &App, +) -> impl IntoElement + use<> { + div() + .id("paste-preview") + .debug_selector(|| "paste-preview".into()) + .track_focus(focus_handle) + .absolute() + .inset_0() + .flex() + .flex_col() + .gap(metrics.space_xl()) + .p(metrics.space_xxl()) + .bg(parts::scrim(cx).opacity(0.92)) + .on_scroll_wheel(|_, _window, cx| cx.stop_propagation()) + .on_mouse_down(gpui::MouseButton::Left, |_, _window, cx| { + cx.stop_propagation(); + }) + .child( + div() + .text_size(metrics.text_title()) + .text_color(parts::on_scrim(cx)) + .child("Send this image?"), + ) + .child( + div() + .id("paste-preview-image") + .debug_selector(|| "paste-preview-image".into()) + .flex_1() + .min_h_0() + .flex() + .items_center() + .justify_center() + .child( + img(ImageSource::Image(image)) + .size_full() + .object_fit(ObjectFit::Contain), + ), + ) + .child( + div() + .flex() + .justify_end() + .gap(metrics.space_lg()) + .child( + div() + .id("paste-preview-cancel") + .debug_selector(|| "paste-preview-cancel".into()) + .child( + Button::new("paste-preview-cancel-button") + .label("Cancel") + .on_click({ + let app = app.clone(); + move |_event, _window, cx| { + app.update(cx, |app, cx| { + app.cancel_paste_preview(cx); + }); + } + }), + ), + ) + .child( + div() + .id("paste-preview-send") + .debug_selector(|| "paste-preview-send".into()) + .child( + Button::new("paste-preview-send-button") + .label("Send") + .primary() + .disabled(!can_send) + .on_click(move |_event, _window, cx| { + app.update(cx, |app, cx| app.confirm_paste_preview(cx)); + }), + ), + ), + ) + .focus_trap("paste-preview-trap", focus_handle) +} diff --git a/crates/gui/src/main.rs b/crates/gui/src/main.rs index 4ede7e20..bed334c3 100644 --- a/crates/gui/src/main.rs +++ b/crates/gui/src/main.rs @@ -59,6 +59,7 @@ fn open_the_window() { // Before anything asks for one: a page's text system starts empty and // resolving a font it has not been given is a panic, not a fallback. crate::platform::fonts(cx); + cx.set_app_identity("org.oxidezap.client.local", "OxideZap"); gpui_component::init(cx); // Reads ~/.config/oxidezap/theme.json over a preset. Cannot fail: a // missing or malformed file resolves to the product default and @@ -79,6 +80,29 @@ fn open_the_window() { }, |window, cx| { let view = cx.new(WhatsAppApp::new); + let notification_view = view.downgrade(); + let notification_window = window.window_handle(); + cx.on_system_notification_response(move |response, cx| { + let tag = response.tag.to_string(); + cx.dismiss_system_notification(&tag); + cx.activate(true); + if notification_window + .update(cx, |_, window, cx| { + window.activate_window(); + let _ = notification_view.update(cx, |app, cx| { + app.open_system_notification(&tag, window, cx); + }); + }) + .is_err() + { + log::debug!("notification activated after its window closed"); + } + }); + // Registering the callback initializes GPUI's notification + // center and installs its response delegate. Ask only after + // that delegate exists, and before the session can deliver an + // incoming message. + crate::platform::request_notification_authorization(); // The theme file is watched for the window's whole life, // not for the part of it that is pairing. Armed from the // pairing screen alone, a window that opened onto an diff --git a/crates/gui/src/platform/activity.rs b/crates/gui/src/platform/activity.rs new file mode 100644 index 00000000..c6921d52 --- /dev/null +++ b/crates/gui/src/platform/activity.rs @@ -0,0 +1,31 @@ +//! Whether this front end is actually the application receiving input. +//! +//! A GPUI window can remain its application's active window on macOS while a +//! different application is frontmost. A read receipt needs both facts. + +/// Whether this application is currently active at the operating-system level. +pub fn application_is_active() -> bool { + imp::application_is_active() +} + +#[cfg(target_os = "macos")] +mod imp { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + + pub(super) fn application_is_active() -> bool { + let Some(main_thread) = MainThreadMarker::new() else { + log::warn!("application activity was queried away from the main thread"); + return false; + }; + NSApplication::sharedApplication(main_thread).isActive() + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + /// GPUI's window-active signal is sufficient away from macOS. + pub(super) const fn application_is_active() -> bool { + true + } +} diff --git a/crates/gui/src/platform/mod.rs b/crates/gui/src/platform/mod.rs index 3f2be5d7..00ef0040 100644 --- a/crates/gui/src/platform/mod.rs +++ b/crates/gui/src/platform/mod.rs @@ -26,6 +26,7 @@ //! expression and the `#[cfg]` is on the two blocks themselves. A module to //! hold one line is ceremony; the rule starts where there is a body to name. +mod activity; mod capabilities; pub mod clipboard; pub mod clock; @@ -37,16 +38,21 @@ pub mod keyboard; pub mod launch; pub mod lifecycle; pub mod log_store; +mod notifications; pub mod picker; pub mod plugins; pub mod prefs; pub mod startup; +pub use activity::application_is_active; pub use capabilities::{calls_belong_to_another_tab, calls_unavailable, video_decode_unavailable}; pub use clock::{sleep, with_timeout}; pub use fonts::{fonts, with_downloaded_fonts}; pub use identity::front_end_id; pub use launch::run; pub use lifecycle::{leave, watch_for_departure}; +pub use notifications::{ + request_authorization as request_notification_authorization, show_notification_with_avatar, +}; pub use plugins::Home as PluginHome; pub use startup::{application, clocks, logging}; diff --git a/crates/gui/src/platform/notifications.rs b/crates/gui/src/platform/notifications.rs new file mode 100644 index 00000000..69e96ae6 --- /dev/null +++ b/crates/gui/src/platform/notifications.rs @@ -0,0 +1,284 @@ +//! Early system-notification authorization. +//! +//! GPUI requests authorization lazily from `show_system_notification` and +//! immediately submits that same first notification. Asking after GPUI has +//! installed its response delegate, but before a message can arrive, avoids +//! making the first incoming message race the macOS permission sheet. + +/// Ask the operating system for notification authorization when it has one. +pub fn request_authorization() { + imp::request_authorization(); +} + +/// Post a native notification, attaching an already-cached profile image when +/// the bytes are a format macOS can thumbnail. +/// +/// This is intentionally a best-effort presentation API. The supplied reader +/// only accesses the existing cache, off the UI thread, and never waits for an +/// avatar download. When +/// `avatar` is absent or unsupported, the same notification is posted without +/// an attachment. The attachment is a media preview, not a sender avatar: the +/// macOS sender-avatar treatment belongs to Communication Notifications and +/// requires an `INSendMessageIntent` plus the app capability/entitlements. +/// +/// Returns `true` when the native path accepted the request. A non-macOS build, +/// or a process not launched from an app bundle, returns `false` so callers +/// can retain their normal GPUI path. +pub fn show_notification_with_avatar( + tag: &str, + title: &str, + body: &str, + avatar: impl Fn() -> Option>> + Send + Sync + 'static, +) -> bool { + imp::show_notification_with_avatar(tag, title, body, avatar) +} + +#[cfg(target_os = "macos")] +mod imp { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + use std::path::{Path, PathBuf}; + use std::ptr::NonNull; + use std::sync::Arc; + + use block2::RcBlock; + use objc2::rc::autoreleasepool; + use objc2::runtime::Bool; + use objc2_foundation::{NSArray, NSBundle, NSError, NSString, NSURL}; + use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, + UNNotificationAttachment, UNNotificationRequest, UNNotificationSettings, + UNUserNotificationCenter, + }; + + pub(super) fn request_authorization() { + // The API raises an Objective-C exception outside an application + // bundle. Keep `cargo run` and unit tests on the supported no-op path. + if NSBundle::mainBundle().bundleIdentifier().is_none() { + log::info!("system notification authorization skipped: not running from an app bundle"); + return; + } + + let completion = RcBlock::new(|granted: Bool, error: *mut NSError| { + // SAFETY: UserNotifications lends the NSError for this callback. + if let Some(error) = unsafe { error.as_ref() } { + log::warn!( + "system notification authorization failed: {}", + error.localizedDescription() + ); + } else if !granted.as_bool() { + log::info!("system notification authorization denied"); + } + }); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound, + &completion, + ); + } + + pub(super) fn show_notification_with_avatar( + tag: &str, + title: &str, + body: &str, + avatar: impl Fn() -> Option>> + Send + Sync + 'static, + ) -> bool { + // UserNotifications raises an Objective-C exception outside an app + // bundle. Keep direct launches and tests on the GPUI/no-op path. + if NSBundle::mainBundle().bundleIdentifier().is_none() { + log::info!("system notification skipped: not running from an app bundle"); + return false; + } + + // Check *before* scheduling: requests queued while the user is still + // deciding the permission sheet can appear as stale alerts much later. + // The callback also keeps the UI free of disk reads and attachment + // encoding. A denied/undetermined message is not queued for later. + let tag = tag.to_string(); + let title = title.to_string(); + let body = body.to_string(); + let avatar = Arc::new(avatar); + let settings = RcBlock::new(move |settings: NonNull| { + // SAFETY: UserNotifications lends a live settings object for this callback. + let status = unsafe { settings.as_ref() }.authorizationStatus(); + if !matches!( + status, + UNAuthorizationStatus::Authorized + | UNAuthorizationStatus::Provisional + | UNAuthorizationStatus::Ephemeral + ) { + return; + } + let (tag, title, body, avatar) = ( + tag.clone(), + title.clone(), + body.clone(), + Arc::clone(&avatar), + ); + std::thread::spawn(move || { + let bytes = avatar(); + autoreleasepool(|_| { + post_authorized(&tag, &title, &body, bytes.as_deref().map(Vec::as_slice)); + }); + }); + }); + UNUserNotificationCenter::currentNotificationCenter() + .getNotificationSettingsWithCompletionHandler(&settings); + true + } + + fn post_authorized(tag: &str, title: &str, body: &str, avatar: Option<&[u8]>) { + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(title)); + content.setBody(&NSString::from_str(body)); + + let attachment = avatar.and_then(|bytes| make_avatar_attachment(tag, bytes)); + if let Some((image, _)) = &attachment { + content.setAttachments(&NSArray::from_retained_slice(std::slice::from_ref(image))); + } + + // A nil trigger delivers immediately. The stable tag has the same + // replacement semantics as GPUI's notification backend. + let request = UNNotificationRequest::requestWithIdentifier_content_trigger( + &NSString::from_str(tag), + &content, + None, + ); + let retry_without_avatar = attachment.is_some(); + let cleanup_path = attachment.map(|(_, path)| path); + let retry_tag = tag.to_string(); + let retry_title = title.to_string(); + let retry_body = body.to_string(); + let completion = RcBlock::new(move |error: *mut NSError| { + // SAFETY: when non-null, UserNotifications lends an NSError for + // the duration of this callback. + if let Some(error) = unsafe { error.as_ref() } { + log::warn!( + "failed to deliver system notification: {}", + error.localizedDescription() + ); + if let Some(path) = &cleanup_path { + let _ = std::fs::remove_file(path); + } + // A corrupted/oversized attachment must not eat the message + // alert. Re-submit once without media; the same request id + // replaces any pending first attempt, without a second loop. + if retry_without_avatar { + submit_plain(&retry_tag, &retry_title, &retry_body); + } + } + }); + UNUserNotificationCenter::currentNotificationCenter() + .addNotificationRequest_withCompletionHandler(&request, Some(&completion)); + } + + fn submit_plain(tag: &str, title: &str, body: &str) { + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(title)); + content.setBody(&NSString::from_str(body)); + let request = UNNotificationRequest::requestWithIdentifier_content_trigger( + &NSString::from_str(tag), + &content, + None, + ); + UNUserNotificationCenter::currentNotificationCenter() + .addNotificationRequest_withCompletionHandler(&request, None); + } + + /// Stage a private copy because UNNotificationAttachment accepts a file + /// URL, while the media cache deliberately exposes bytes. On successful + /// scheduling Notification Center moves the file into its own store. + fn make_avatar_attachment( + tag: &str, + bytes: &[u8], + ) -> Option<(objc2::rc::Retained, PathBuf)> { + // Apple limits image attachments to 10 MiB. Do not let an oversized + // cached blob cause the whole text notification to be rejected. + if bytes.len() > 10 * 1024 * 1024 { + return None; + } + let extension = image_extension(bytes)?; + let path = attachment_path(tag, bytes, extension); + write_attachment_file(&path, bytes)?; + let url = NSURL::from_file_path(&path)?; + let identifier = NSString::from_str(path.file_stem()?.to_str()?); + + // SAFETY: `identifier` and `url` are valid Objective-C objects for the + // duration of the call, and a nil options dictionary is supported by + // the API. The result validates the file's image type. + match unsafe { + UNNotificationAttachment::attachmentWithIdentifier_URL_options_error( + &identifier, + &url, + None, + ) + } { + Ok(attachment) => Some((attachment, path)), + Err(error) => { + log::debug!( + "profile image was not accepted as a notification attachment: {}", + error.localizedDescription() + ); + let _ = std::fs::remove_file(path); + None + } + } + } + + fn attachment_path(tag: &str, bytes: &[u8], extension: &str) -> PathBuf { + let mut hasher = DefaultHasher::new(); + tag.hash(&mut hasher); + bytes.hash(&mut hasher); + + let mut directory = std::env::temp_dir(); + directory.push("oxidezap-notification-avatars"); + directory.push(format!("{:016x}.{extension}", hasher.finish())); + directory + } + + fn write_attachment_file(path: &Path, bytes: &[u8]) -> Option<()> { + let directory = path.parent()?; + std::fs::create_dir_all(directory).ok()?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)).ok()?; + } + + std::fs::write(path, bytes).ok()?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).ok()?; + } + Some(()) + } + + fn image_extension(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("png") + } else if bytes.starts_with(b"\xff\xd8\xff") { + Some("jpg") + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("gif") + } else { + None + } + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + pub(super) const fn request_authorization() {} + + pub(super) fn show_notification_with_avatar( + _tag: &str, + _title: &str, + _body: &str, + _avatar: impl Fn() -> Option>> + Send + Sync + 'static, + ) -> bool { + false + } +} diff --git a/crates/gui/src/platform/startup.rs b/crates/gui/src/platform/startup.rs index c507ffbd..93f8b5ec 100644 --- a/crates/gui/src/platform/startup.rs +++ b/crates/gui/src/platform/startup.rs @@ -84,7 +84,22 @@ mod imp { /// worth being able to choose it from the URL when a machine's WebGPU is the /// thing that is broken. pub(super) fn application() -> gpui::Application { - gpui_platform::application() + gpui_platform::application().with_quit_mode(quit_mode()) + } + + /// A desktop window is the GUI process. The daemon owns the persistent + /// session and tray, so leaving a windowless GUI alive only retains stale + /// IPC window ownership. GPUI's default is explicit quit on macOS. + pub(super) const fn quit_mode() -> gpui::QuitMode { + gpui::QuitMode::LastWindowClosed + } +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + #[test] + fn desktop_quits_when_its_last_window_closes() { + assert_eq!(super::imp::quit_mode(), gpui::QuitMode::LastWindowClosed); } } diff --git a/crates/gui/src/session/frames.rs b/crates/gui/src/session/frames.rs index f7a444f1..2a2f4fcb 100644 --- a/crates/gui/src/session/frames.rs +++ b/crates/gui/src/session/frames.rs @@ -226,10 +226,14 @@ impl<'a> Frames<'a> { mut chats, next, } => { - if take_pending(self.pending, id).is_none() { + let Some(Awaiting::Page { + jid: None, + archived, + }) = take_pending(self.pending, id) + else { debug!("a chat page arrived for {id}, which nobody is waiting on"); return ControlFlow::Continue(()); - } + }; // A chat page carries one message per row and that row is the // list's preview: its media is externalized like any other, so // it has to be read back here like any other. Skipping it drew @@ -239,7 +243,11 @@ impl<'a> Frames<'a> { fill(&mut message.media, self.media); } } - self.publish(FromDaemon::Chats { chats, next })?; + self.publish(FromDaemon::Chats { + chats, + next, + archived, + })?; } DaemonMessage::GroupMembers { id, roster } => { if take_pending(self.pending, id).is_none() { diff --git a/crates/gui/src/session/mod.rs b/crates/gui/src/session/mod.rs index 8e8e041e..cdf63394 100644 --- a/crates/gui/src/session/mod.rs +++ b/crates/gui/src/session/mod.rs @@ -177,6 +177,8 @@ pub enum FromDaemon { chats: Vec, /// Where to continue, or `None` at the end of the list. next: Option, + /// Whether this page came from the include-archived list. + archived: bool, }, /// Who is in a group, for the header that asked. Members(oxidezap_core::GroupRoster), @@ -195,6 +197,8 @@ pub enum FromDaemon { PageLost { /// The conversation, or `None` for a page of the chat list. jid: Option, + /// Which chat-list cursor owns the request; false for timelines. + archived: bool, }, /// A picture is waiting in [`Session::call_frames`]. /// @@ -354,6 +358,8 @@ enum Awaiting { Page { /// The conversation, or `None` for a page of the chat list. jid: Option, + /// Which chat-list cursor owns the request; false for timelines. + archived: bool, }, } @@ -443,10 +449,10 @@ impl Awaiting { } // The view that asked is waiting on this and will not ask again // until it hears something. - Self::Page { jid } => { + Self::Page { jid, archived } => { log::warn!("a page of history did not arrive: {detail}"); if let Some(events) = events { - let _ = events.send(FromDaemon::PageLost { jid }); + let _ = events.send(FromDaemon::PageLost { jid, archived }); } } // Same rule as a page, and the same consequence: a header that @@ -1318,15 +1324,25 @@ impl SessionHandle { // opinion about it would be guessing. limit: None, }), - Awaiting::Page { jid: Some(jid) }, + Awaiting::Page { + jid: Some(jid), + archived: false, + }, ); } /// Ask for one page of the chat list, after `after`. - pub fn load_chats(&self, after: Option) { + pub fn load_chats(&self, after: Option, archived: bool) { self.ask( - ClientRequest::LoadChats(LoadChats { after, limit: None }), - Awaiting::Page { jid: None }, + ClientRequest::LoadChats(LoadChats { + after, + limit: None, + archived, + }), + Awaiting::Page { + jid: None, + archived, + }, ); } @@ -1887,9 +1903,9 @@ fn fail_reserved(conn: &Conn, id: RequestId, detail: String) { // waiting on a page asks for nothing until it hears, so a request // that never left has to say so — the reconnect keeps the chats and // the paging state, and a list left `Loading` never asks again. - Awaiting::Page { jid } => { + Awaiting::Page { jid, archived } => { error!("a page request never left this process: {detail}"); - let _ = conn.events.try_send(FromDaemon::PageLost { jid }); + let _ = conn.events.try_send(FromDaemon::PageLost { jid, archived }); } // And again, for the header's line: it is holding this request open. Awaiting::Members { jid } => { diff --git a/crates/gui/src/views/chat.rs b/crates/gui/src/views/chat.rs index 58e15873..545c4e82 100644 --- a/crates/gui/src/views/chat.rs +++ b/crates/gui/src/views/chat.rs @@ -74,6 +74,7 @@ pub fn render_connected_view( // visible than a chat on another screen. let visible = (app.destination() == Destination::Chats && layout.show_chat_area() + && !app.paste_preview_showing() && app.media_viewer(cx).is_none()) .then(|| selected_jid.clone()) .flatten(); @@ -102,7 +103,7 @@ pub fn render_connected_view( // unread chat — can be on a page nobody has fetched. An empty list is at // its end by definition, so it asks like any other list that is; the // paging state is what stops it asking twice, and `Done` is what ends it. - if app.chat_list_is_empty(cx) { + if app.chat_list_is_empty(cx) || app.is_searching(cx) { app.want_more_chats(cx); } @@ -245,6 +246,7 @@ pub fn render_connected_view( && open_chat.is_some() && !is_offline, viewer: viewer.is_some(), + paste_preview: false, // The card floats above this view rather than inside it, so the root // is what knows whether one was drawn. See `WhatsAppApp::render`. call_card: false, diff --git a/crates/gui/src/views/settings/panes.rs b/crates/gui/src/views/settings/panes.rs index e1f78cab..45bc5a92 100644 --- a/crates/gui/src/views/settings/panes.rs +++ b/crates/gui/src/views/settings/panes.rs @@ -405,8 +405,9 @@ fn notifications(metrics: Metrics, cx: &App) -> AnyElement { group( label("NOTIFICATIONS", metrics, cx), pending( - "This client does not raise desktop notifications yet. The daemon \ - carries a tray presence; routing messages through it is the next step.", + "Incoming messages raise desktop notifications while this window is running, \ + except for the conversation currently visible in the active window. Clicking a \ + notification opens that conversation.", metrics, cx, ), diff --git a/crates/ipc/src/protocol.rs b/crates/ipc/src/protocol.rs index 830a7783..8558eef7 100644 --- a/crates/ipc/src/protocol.rs +++ b/crates/ipc/src/protocol.rs @@ -6,6 +6,10 @@ use oxidezap_core::{ }; use serde::{Deserialize, Serialize}; +fn is_false(value: &bool) -> bool { + !*value +} + /// Monotonic counter over daemon state. /// /// Only ever increases, and only the daemon advances it. Clients compare but @@ -833,6 +837,10 @@ pub struct LoadChats { pub after: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, + /// Include archived rows in this page. The caller still decides whether + /// to draw only archived rows or an all-inclusive export. + #[serde(default, skip_serializing_if = "is_false")] + pub archived: bool, } /// The group whose members are being asked for. See @@ -1880,6 +1888,7 @@ mod tests { ClientRequest::LoadChats(LoadChats { after: Some(PageCursor::new("c1:1700000000123")), limit: Some(20), + archived: false, }), r#"{"request":"load_chats","after":"c1:1700000000123","limit":20}"#.to_string(), ), @@ -1887,9 +1896,18 @@ mod tests { ClientRequest::LoadChats(LoadChats { after: None, limit: None, + archived: false, }), r#"{"request":"load_chats"}"#.to_string(), ), + ( + ClientRequest::LoadChats(LoadChats { + after: None, + limit: None, + archived: true, + }), + r#"{"request":"load_chats","archived":true}"#.to_string(), + ), ( ClientRequest::GroupMembers(GroupMembers { jid: "120363000000000001@g.us".into(), diff --git a/crates/ipc/src/transport.rs b/crates/ipc/src/transport.rs index 986b9f31..6e947170 100644 --- a/crates/ipc/src/transport.rs +++ b/crates/ipc/src/transport.rs @@ -5,6 +5,11 @@ use std::path::PathBuf; /// Bumped whenever a frame changes shape in a way an older peer would /// misread. The daemon refuses a mismatch rather than guessing. /// +/// 34: `LoadChats.archived` lets the desktop request the store's +/// include-archived view, and full `Chat` frames carry the durable archive +/// state used to separate that view. A v33 daemon would ignore the request +/// field and silently return only active chats, so the mismatch is refused. +/// /// 33: `DaemonMessage::AvatarFailed` and `UiEvent::AvatarFailed`. Propagates avatar /// resolution, download, and materialization failures with a retryable flag to /// coordinate front-end failure cooldowns and retry pacing. A v32 front-end @@ -252,7 +257,7 @@ use std::path::PathBuf; /// would misparse the first three and not recognise the rest. /// /// [`PairingCode`]: crate::PairingCode -pub const PROTOCOL_VERSION: u32 = 33; +pub const PROTOCOL_VERSION: u32 = 34; /// Where the daemon's web bridge listens when nobody says otherwise. /// diff --git a/crates/plugin-host/src/event.rs b/crates/plugin-host/src/event.rs index 7875407c..dffa3b2c 100644 --- a/crates/plugin-host/src/event.rs +++ b/crates/plugin-host/src/event.rs @@ -100,11 +100,26 @@ impl Event { pub fn from_session(event: &UiEvent) -> Option { use fields::{call, connection, media, receipt}; + // `Sent` is the session's live projection of a server acknowledgement, + // not a peer receipt. The plugin ABI's weakest receipt means "reached the + // device", so translating this as Delivered would overstate the event and + // then publish the real delivery a second time. + if matches!( + event, + UiEvent::ReceiptReceived { + receipt_type: oxidezap_core::ReceiptType::Sent, + .. + } + ) { + return None; + } + Some(match event { UiEvent::MessageReceived { chat_jid, message, sender_name, + .. } => Event::new(abi::kinds::MESSAGE) .str(fields::CHAT_JID, chat_jid.clone()) .flag(fields::IS_GROUP, is_group(chat_jid)) @@ -291,6 +306,15 @@ pub fn from_session(event: &UiEvent) -> Option { /// `every_converted_event_is_one_the_filter_admits` holds. #[must_use] pub fn kind_of(event: &UiEvent) -> Option { + if matches!( + event, + UiEvent::ReceiptReceived { + receipt_type: oxidezap_core::ReceiptType::Sent, + .. + } + ) { + return None; + } Some(match event { UiEvent::MessageReceived { .. } => abi::kinds::MESSAGE, UiEvent::InitComplete @@ -339,6 +363,9 @@ mod tests { chat_jid: chat.into(), message: Box::new(message), sender_name: None, + notification_allowed: false, + notification_title: None, + notification_archived: false, } } diff --git a/crates/plugin-host/src/tests.rs b/crates/plugin-host/src/tests.rs index 9ecf94ae..b9957476 100644 --- a/crates/plugin-host/src/tests.rs +++ b/crates/plugin-host/src/tests.rs @@ -161,6 +161,9 @@ fn message(chat: &str, text: &str) -> UiEvent { chat_jid: chat.into(), message: Box::new(message), sender_name: None, + notification_allowed: false, + notification_title: None, + notification_archived: false, } } @@ -1815,6 +1818,18 @@ fn every_converted_event_is_one_the_filter_admits() { } } +#[test] +fn a_server_ack_is_not_reported_to_plugins_as_peer_delivery() { + let sent = UiEvent::ReceiptReceived { + chat_jid: "a@s.whatsapp.net".into(), + message_ids: vec!["MSG-SENT".into()], + receipt_type: oxidezap_core::ReceiptType::Sent, + }; + + assert_eq!(crate::event::kind_of(&sent), None); + assert_eq!(crate::event::from_session(&sent), None); +} + /// A call the peer accepted is a call that was answered. Without it a plugin /// watching an outgoing call sees it start and end with nothing in between, /// and cannot tell one that connected from one nobody picked up. diff --git a/crates/session/src/mentions.rs b/crates/session/src/mentions.rs index c66c10fd..6bb27ade 100644 --- a/crates/session/src/mentions.rs +++ b/crates/session/src/mentions.rs @@ -139,6 +139,20 @@ pub(crate) fn mentioned_jids(message: Option<&wa::Message>) -> Vec { jids } +/// Whether the wire's explicit mention list names this account under either +/// its phone-number or LID address. A literal `@` in the text is not enough: +/// it may be prose, a price, or a mention of somebody else. +pub(crate) fn mentions_own_account(message: Option<&wa::Message>, own_jids: &[String]) -> bool { + if own_jids.is_empty() { + return false; + } + mentioned_jids(message).iter().any(|raw| { + raw.parse::() + .ok() + .is_some_and(|jid| own_jids.iter().any(|own| *own == jid.to_non_ad_string())) + }) +} + /// Rewrite the `@`-mentions in `text` to the names the book has for them. /// /// `message` is the proto the text was read off, or `None` where no proto @@ -331,6 +345,40 @@ mod tests { ); } + #[test] + fn only_an_explicit_own_pn_or_lid_mention_counts() { + let own = vec![ + "559900000001@s.whatsapp.net".to_string(), + "123456789@lid".to_string(), + ]; + assert!(mentions_own_account( + Some(&text_message( + "oi @559900000001", + &["559900000001@s.whatsapp.net"] + )), + &own + )); + assert!(mentions_own_account( + Some(&text_message("oi @123456789", &["123456789@lid"])), + &own + )); + assert!(!mentions_own_account( + Some(&text_message("oi @559900000001", &[])), + &own + )); + assert!(!mentions_own_account( + Some(&text_message( + "oi @559900000002", + &["559900000002@s.whatsapp.net"] + )), + &own + )); + assert!(!mentions_own_account( + Some(&text_message("oi @123456789", &["not-a-jid"])), + &own + )); + } + #[test] fn a_caption_mention_lists_its_jids() { let message = wa::Message { diff --git a/crates/session/src/names.rs b/crates/session/src/names.rs index e75e3f07..b614083f 100644 --- a/crates/session/src/names.rs +++ b/crates/session/src/names.rs @@ -35,7 +35,7 @@ use oxidezap_core::fallback_chat_name; use whatsapp_rust::anyhow::Result; use whatsapp_rust::client::Client; use whatsapp_rust::lid_pn_cache::LidPnEntry; -use whatsapp_rust::wacore_binary::jid::Jid; +use whatsapp_rust::wacore_binary::jid::{Jid, JidExt}; use crate::exec::MaybeSend; @@ -138,6 +138,13 @@ impl NameBook { } } + /// The same durable store the live path uses for names and chat policy. + /// History-only and synthetic call fixtures have none; alert decisions + /// from those paths are unknown, never implicitly allowed. + pub(crate) fn chat_store(&self) -> Option<&ChatStore> { + self.chat_store.as_deref() + } + /// Drop everything learned. Called where the address book is being /// re-read anyway, so a contact renamed on the phone appears under its /// new name without a restart. @@ -223,7 +230,9 @@ impl NameBook { } } - if let Some(name) = offered.filter(|name| usable_name(name, identity.has_phone)) { + if let Some(name) = + offered.filter(|name| usable_offered_name(jid, name, identity.has_phone)) + { return (name.to_string(), priority::SELF_CHOSEN); } @@ -347,6 +356,21 @@ fn usable_name(name: &str, has_phone: bool) -> bool { !(name.trim().is_empty() || has_phone && is_masked_phone_label(name)) } +/// Whether a name carried by a stored chat is a real user/server answer. +/// +/// Group rows created by older builds could persist the UI fallback as if it +/// were a subject. Treat both generations of that fallback as unresolved so +/// history does not give them `SELF_CHOSEN` priority and block metadata repair. +/// The rule is deliberately scoped to groups: a person may legitimately name a +/// direct chat "Group name unavailable". +fn usable_offered_name(jid: &Jid, name: &str, has_phone: bool) -> bool { + usable_name(name, has_phone) && !is_generated_group_placeholder(jid, name) +} + +fn is_generated_group_placeholder(jid: &Jid, name: &str) -> bool { + jid.is_group() && matches!(name.trim(), "Unnamed group" | "Group name unavailable") +} + /// The server's own stand-in for a number it will not spell out, e.g. /// `+55 ·· ···· ··43`. Worse than the number we already hold. fn is_masked_phone_label(name: &str) -> bool { @@ -535,6 +559,26 @@ mod tests { assert!(usable_name("Ana", true)); } + #[test] + fn generated_group_labels_are_not_taken_from_history_as_self_chosen() { + let group = jid(GROUP); + + assert!(!usable_offered_name(&group, "Unnamed group", false)); + assert!(!usable_offered_name( + &group, + "Group name unavailable", + false + )); + assert!(usable_offered_name(&group, "Trip planning", false)); + // The same text is still a valid custom label for a direct chat; the + // placeholder rule is scoped to group conversations. + assert!(usable_offered_name( + &jid(PEER), + "Group name unavailable", + false + )); + } + #[test] fn a_masked_label_is_recognised_by_its_dots() { assert!(is_masked_phone_label("+55 ·· ···· ··43")); diff --git a/crates/session/src/whatsapp/chat_names.rs b/crates/session/src/whatsapp/chat_names.rs index 9098affb..a2d5016c 100644 --- a/crates/session/src/whatsapp/chat_names.rs +++ b/crates/session/src/whatsapp/chat_names.rs @@ -20,11 +20,13 @@ //! and a real change emits `StoreChange::Chats` — which is what re-renders //! the list, with no extra publish step. //! -//! The query shape is "selective, not N+1": unnamed groups cost one -//! `fetch_overviews` each under a small concurrency cap, deduplicated per -//! connection generation; channels cost a single `list_subscribed` that -//! materializes every subscribed name at once, with a selective -//! `get_metadata` only for channels absent from that list. A lookup that +//! The query shape is bulk-first: a full pass uses one `list_participating` +//! call for all groups, with a selective `fetch_overviews` fallback only for +//! groups absent from that list (for example, a newly created group that has +//! not appeared in the participating projection yet). Live sightings keep the +//! selective lookup path, so a single new group does not refresh every group +//! in the account. Channels use the same bulk-first shape with +//! `list_subscribed` and selective `get_metadata` fallback. A lookup that //! fails is deliberately not remembered, so a transient failure is retried //! the next time its chat is sighted rather than filed as nameless for the //! life of the session. @@ -110,6 +112,9 @@ const STORE_RETRY_LIMIT: u8 = 3; #[allow(clippy::manual_async_fn)] pub(crate) trait MetadataSource { fn group_subject(&self, jid: &Jid) -> impl Future + MaybeSend; + fn participating_groups( + &self, + ) -> impl Future, NameRetry>> + MaybeSend; fn subscribed_channels( &self, ) -> impl Future, NameRetry>> + MaybeSend; @@ -148,6 +153,21 @@ impl MetadataSource for Client { } } + #[allow(clippy::manual_async_fn)] + fn participating_groups( + &self, + ) -> impl Future, NameRetry>> + MaybeSend { + async move { + match self.groups().list_participating().await { + Ok(groups) => Ok(groups + .into_iter() + .map(|meta| (meta.id.to_string(), meta.subject.unwrap_or_default())) + .collect()), + Err(e) => Err(name_retry_after(&e)), + } + } + } + #[allow(clippy::manual_async_fn)] fn subscribed_channels( &self, @@ -236,6 +256,12 @@ impl MetadataSource for Arc { (**self).group_subject(jid) } + fn participating_groups( + &self, + ) -> impl Future, NameRetry>> + MaybeSend { + (**self).participating_groups() + } + fn subscribed_channels( &self, ) -> impl Future, NameRetry>> + MaybeSend { @@ -546,6 +572,19 @@ fn usable_name(name: &str) -> bool { !name.trim().is_empty() } +/// Whether a group subject is a real metadata answer rather than one of the +/// labels this client generated for an unresolved row. These labels must stay +/// eligible for another bulk/selective lookup, including rows written by older +/// releases before the resolver existed. +fn usable_stored_group_name(name: &str) -> bool { + let name = name.trim(); + usable_name(name) && !matches!(name, "Unnamed group" | "Group name unavailable") +} + +fn is_generated_group_placeholder(jid: &Jid, name: &str) -> bool { + jid.is_group() && matches!(name.trim(), "Unnamed group" | "Group name unavailable") +} + /// The stored special chats: every `@g.us` and `@newsletter` row, named or /// not — archived included, since the archived list draws them with the /// same fallback. A full pass revalidates them all rather than only the @@ -732,7 +771,13 @@ pub(super) async fn run_pass( // chats skip, failed-and-cooled chats retry. if !request.full && !forced - && stored.as_deref().is_some_and(usable_name) + && stored.as_deref().is_some_and(|name| { + if jid.is_group() { + usable_stored_group_name(name) + } else { + usable_name(name) + } + }) && !resolver.needs(&jid.to_string(), generation) { continue; @@ -769,7 +814,66 @@ pub(super) async fn run_pass( let mut resolved: Vec = Vec::new(); let mut failed: Vec<(String, std::time::Duration)> = Vec::new(); let mut global_backoff: Option = None; - for (chunk_index, chunk) in groups.chunks(CHAT_NAME_CONCURRENCY).enumerate() { + // A full pass has enough scope to use the participating projection once + // for every group. Live sightings remain selective: a new group should + // not refresh the account-wide group list just to learn one subject. + let mut selective_groups = groups; + if request.full { + let listed = source.participating_groups(); + tokio::pin!(listed); + let listed = tokio::select! { + out = &mut listed => out, + _ = stop.changed() => return, + }; + match listed { + Ok(participating) => { + let mut fallback = Vec::new(); + for jid in selective_groups { + match participating.get(&jid.to_string()) { + Some(name) if usable_name(name) => { + let name = name.trim().to_owned(); + let key = jid.to_string(); + settled.push((key.clone(), Some(name.clone()))); + resolved.push(oxidezap_chat_store::ChatNameWrite::checked( + jid, + pre.get(&key).cloned().flatten(), + name, + )); + } + _ => fallback.push(jid), + } + } + // The overview is authoritative for participating groups it + // contains. A row absent from a successful projection may be + // newly created or temporarily outside that projection, so it + // keeps the selective metadata fallback. + selective_groups = fallback + .into_iter() + .filter(|jid| resolver.needs(&jid.to_string(), generation)) + .collect(); + } + Err(retry) => { + if retry.scope == RetryScope::Global { + // A server-directed account-wide backoff must not fan out + // one request per group into the same throttle window. + global_backoff = Some( + global_backoff + .map_or(retry.retry_after, |current| current.max(retry.retry_after)), + ); + failed.extend( + selective_groups + .iter() + .map(|jid| (jid.to_string(), retry.retry_after)), + ); + selective_groups.clear(); + } + // A chat-scoped failure only means the participating list was + // unavailable. Keep the rows for their selective metadata + // fallback; each lookup has its own cooldown if it fails too. + } + } + } + for (chunk_index, chunk) in selective_groups.chunks(CHAT_NAME_CONCURRENCY).enumerate() { let lookup = whatsapp_rust::futures::future::join_all(chunk.iter().map(|jid| async { let name = source.group_subject(jid).await; (jid.clone(), name) @@ -827,7 +931,7 @@ pub(super) async fn run_pass( // window; those JIDs will be retried by the timer-driven pass. let remaining_start = (chunk_index + 1) * CHAT_NAME_CONCURRENCY; failed.extend( - groups + selective_groups .iter() .skip(remaining_start) .map(|jid| (jid.to_string(), retry_after)), @@ -1035,6 +1139,20 @@ pub(super) async fn run_pass( .collect(); for (jid, learned_name) in settled { match rows.get(&jid) { + Some(entry) + if learned_name.is_none() + && jid.parse::().ok().is_some_and(|jid| { + entry + .name + .as_deref() + .is_some_and(|name| is_generated_group_placeholder(&jid, name)) + }) => + { + // A blank answer cannot settle a row that only has a + // generated placeholder. Keep it due so a later live + // sighting can retry selective metadata lookup. + resolver.forget(&jid, generation); + } Some(entry) if learned_name.is_none() || entry.name.as_deref() == learned_name.as_deref() => @@ -1142,6 +1260,7 @@ impl WhatsAppClient { mod tests { use super::*; use std::sync::Mutex as StdMutex; + use std::sync::atomic::AtomicUsize; use std::time::Duration; /// A metadata source the test drives: names on cue, failures and delays @@ -1150,6 +1269,9 @@ mod tests { groups: StdMutex>, channels: StdMutex>, listed: StdMutex>>, + participating_groups: StdMutex>>, + group_list_calls: AtomicUsize, + group_lookup_calls: AtomicUsize, fail_groups: portable_atomic::AtomicBool, fail_list: portable_atomic::AtomicBool, fail_channel_global: portable_atomic::AtomicBool, @@ -1161,6 +1283,9 @@ mod tests { groups: StdMutex::new(HashMap::new()), channels: StdMutex::new(HashMap::new()), listed: StdMutex::new(Some(HashMap::new())), + participating_groups: StdMutex::new(Some(HashMap::new())), + group_list_calls: AtomicUsize::new(0), + group_lookup_calls: AtomicUsize::new(0), fail_groups: portable_atomic::AtomicBool::new(false), fail_list: portable_atomic::AtomicBool::new(false), fail_channel_global: portable_atomic::AtomicBool::new(false), @@ -1176,6 +1301,24 @@ mod tests { fake } + fn with_participating_groups(groups: &[(&str, &str)]) -> Self { + let fake = Self::new(); + let mut all = fake + .participating_groups + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let participating = all.as_mut().expect("group list is present"); + for (jid, subject) in groups { + fake.groups + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert((*jid).to_string(), (*subject).to_string()); + participating.insert((*jid).to_string(), (*subject).to_string()); + } + drop(all); + fake + } + fn with_channel(jid: &str, name: &str, listed: bool) -> Self { let fake = Self::new(); fake.channels @@ -1204,6 +1347,7 @@ mod tests { impl MetadataSource for FakeMeta { #[allow(clippy::manual_async_fn)] fn group_subject(&self, jid: &Jid) -> impl Future + MaybeSend { + self.group_lookup_calls.fetch_add(1, Ordering::Relaxed); let answer = if self.fail_groups.load(Ordering::Relaxed) { NameLookup::Failed { retry_after: NAME_RETRY_COOLDOWN, @@ -1230,6 +1374,23 @@ mod tests { async move { answer } } + #[allow(clippy::manual_async_fn)] + fn participating_groups( + &self, + ) -> impl Future, NameRetry>> + MaybeSend { + self.group_list_calls.fetch_add(1, Ordering::Relaxed); + let listed = self + .participating_groups + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + .ok_or(NameRetry { + retry_after: NAME_RETRY_COOLDOWN, + scope: RetryScope::Chat, + }); + async move { listed } + } + #[allow(clippy::manual_async_fn)] fn subscribed_channels( &self, @@ -1279,6 +1440,7 @@ mod tests { } const GROUP: &str = "120363000000000001@g.us"; + const GROUP_TWO: &str = "120363000000000002@g.us"; const CHANNEL: &str = "120363400000000001@newsletter"; async fn test_store(name: &str) -> Arc { @@ -1384,6 +1546,175 @@ mod tests { stored_name(&store, GROUP).await.as_deref(), Some("Trip planning") ); + assert_eq!(source.group_list_calls.load(Ordering::Relaxed), 0); + assert_eq!(source.group_lookup_calls.load(Ordering::Relaxed), 1); + } + + /// A full revalidation uses the participating overview once for all + /// groups, without falling back to one metadata IQ per row. + #[tokio::test] + async fn a_full_pass_uses_bulk_group_overviews() { + let store = test_store("group-bulk-overviews").await; + feed(&store, group_message(GROUP, "MSG-GB1")).await; + feed(&store, group_message(GROUP_TWO, "MSG-GB2")).await; + + let source = FakeMeta::with_participating_groups(&[ + (GROUP, "Trip planning"), + (GROUP_TWO, "Dinner plans"), + ]); + let signal = ChatNameResolveSignal::new(); + signal.request_full(); + let request = signal.next().await; + let mut resolver = NameResolver::new(); + drive(&source, &store, &signal, &mut resolver, request).await; + + assert_eq!( + stored_name(&store, GROUP).await.as_deref(), + Some("Trip planning") + ); + assert_eq!( + stored_name(&store, GROUP_TWO).await.as_deref(), + Some("Dinner plans") + ); + assert_eq!(source.group_list_calls.load(Ordering::Relaxed), 1); + assert_eq!(source.group_lookup_calls.load(Ordering::Relaxed), 0); + } + + /// The server is authoritative for subjects: a person may intentionally + /// choose the same text as our fallback, and that custom name must not be + /// filtered merely because it matches a sentinel. + #[tokio::test] + async fn a_server_group_subject_matching_the_fallback_is_preserved() { + let store = test_store("group-custom-fallback-text").await; + feed(&store, group_message(GROUP, "MSG-GB8")).await; + + let source = FakeMeta::with_participating_groups(&[(GROUP, "Unnamed group")]); + let signal = ChatNameResolveSignal::new(); + signal.request_full(); + let request = signal.next().await; + let mut resolver = NameResolver::new(); + drive(&source, &store, &signal, &mut resolver, request).await; + + assert_eq!( + stored_name(&store, GROUP).await.as_deref(), + Some("Unnamed group") + ); + assert_eq!(source.group_list_calls.load(Ordering::Relaxed), 1); + assert_eq!(source.group_lookup_calls.load(Ordering::Relaxed), 0); + } + + /// A successful bulk projection still keeps a selective fallback for a + /// newly sighted row that the server did not return in its projection. + #[tokio::test] + async fn a_full_pass_selectively_falls_back_for_an_unlisted_group() { + let store = test_store("group-bulk-fallback").await; + feed(&store, group_message(GROUP, "MSG-GB3")).await; + feed(&store, group_message(GROUP_TWO, "MSG-GB4")).await; + + let source = FakeMeta::with_participating_groups(&[(GROUP, "Trip planning")]); + source.rename_group(GROUP_TWO, "Dinner plans"); + let signal = ChatNameResolveSignal::new(); + signal.request_full(); + let request = signal.next().await; + let mut resolver = NameResolver::new(); + drive(&source, &store, &signal, &mut resolver, request).await; + + assert_eq!( + stored_name(&store, GROUP).await.as_deref(), + Some("Trip planning") + ); + assert_eq!( + stored_name(&store, GROUP_TWO).await.as_deref(), + Some("Dinner plans") + ); + assert_eq!(source.group_list_calls.load(Ordering::Relaxed), 1); + assert_eq!(source.group_lookup_calls.load(Ordering::Relaxed), 1); + } + + /// A failed participating projection still falls back to a selective + /// lookup when the failure is chat-scoped, so a full pass can name rows + /// even when the account-wide list is temporarily unavailable. + #[tokio::test] + async fn a_failed_bulk_group_list_falls_back_to_selective_lookup() { + let store = test_store("group-bulk-list-failure").await; + feed(&store, group_message(GROUP, "MSG-GB5")).await; + + let source = FakeMeta::with_group(GROUP, "Trip planning"); + *source + .participating_groups + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + let signal = ChatNameResolveSignal::new(); + signal.request_full(); + let request = signal.next().await; + let mut resolver = NameResolver::new(); + drive(&source, &store, &signal, &mut resolver, request).await; + + assert_eq!( + stored_name(&store, GROUP).await.as_deref(), + Some("Trip planning") + ); + assert_eq!(source.group_list_calls.load(Ordering::Relaxed), 1); + assert_eq!(source.group_lookup_calls.load(Ordering::Relaxed), 1); + } + + /// A participating overview without a usable subject keeps its selective + /// fallback, because the slim projection did not actually answer the name. + #[tokio::test] + async fn an_empty_bulk_group_subject_falls_back_to_selective_lookup() { + let store = test_store("group-empty-bulk-subject").await; + feed(&store, group_message(GROUP, "MSG-GB6")).await; + + let source = FakeMeta::with_participating_groups(&[(GROUP, "")]); + source.rename_group(GROUP, "Trip planning"); + let signal = ChatNameResolveSignal::new(); + signal.request_full(); + let request = signal.next().await; + let mut resolver = NameResolver::new(); + drive(&source, &store, &signal, &mut resolver, request).await; + + assert_eq!( + stored_name(&store, GROUP).await.as_deref(), + Some("Trip planning") + ); + assert_eq!(source.group_list_calls.load(Ordering::Relaxed), 1); + assert_eq!(source.group_lookup_calls.load(Ordering::Relaxed), 1); + } + + /// A generated label already persisted by an older build must not be + /// settled as a real subject. If the first pass also gets no subject, a + /// later sighting still gets a selective lookup after the server has a + /// usable answer. + #[tokio::test] + async fn a_generated_group_label_does_not_block_a_later_selective_recovery() { + let store = test_store("group-placeholder-recovery").await; + feed(&store, group_message(GROUP, "MSG-GB7")).await; + store + .set_chat_name(&GROUP.parse().expect("test JID"), "Unnamed group") + .expect("queue legacy placeholder"); + store.flush().await.expect("persist placeholder"); + + let source = FakeMeta::with_participating_groups(&[(GROUP, "")]); + let signal = ChatNameResolveSignal::new(); + signal.request_full(); + let request = signal.next().await; + let mut resolver = NameResolver::new(); + drive(&source, &store, &signal, &mut resolver, request).await; + assert_eq!( + stored_name(&store, GROUP).await.as_deref(), + Some("Unnamed group") + ); + + source.rename_group(GROUP, "Trip planning"); + signal.request_named([GROUP.to_string()]); + let request = signal.next().await; + drive(&source, &store, &signal, &mut resolver, request).await; + + assert_eq!( + stored_name(&store, GROUP).await.as_deref(), + Some("Trip planning") + ); + assert_eq!(source.group_lookup_calls.load(Ordering::Relaxed), 2); } /// A live channel message resolves through the subscribed list, with no @@ -1746,6 +2077,13 @@ mod tests { } } #[allow(clippy::manual_async_fn)] + fn participating_groups( + &self, + ) -> impl Future, NameRetry>> + MaybeSend + { + async move { Ok(HashMap::new()) } + } + #[allow(clippy::manual_async_fn)] fn subscribed_channels( &self, ) -> impl Future, NameRetry>> + MaybeSend diff --git a/crates/session/src/whatsapp/history.rs b/crates/session/src/whatsapp/history.rs index 4c116a70..335d6583 100644 --- a/crates/session/src/whatsapp/history.rs +++ b/crates/session/src/whatsapp/history.rs @@ -426,10 +426,9 @@ impl WhatsAppClient { }; match chat_store.chat(&parsed).await { // A pin on an archived chat must not resurrect it in the - // main list: the page above excludes archived rows, and - // `Chat` carries no archived flag for the daemon or the - // GUI to reject it with — so an incomplete load carrying - // it would be upserted beside the chats it belongs with. + // main list: the page above excludes archived rows. The + // explicit include-archived request hydrates these rows; + // an ordinary scoped reload must keep the same boundary. Ok(Some(entry)) if entry.archived => {} Ok(Some(entry)) => entries.push(entry), // No row: the chat is live-only, or gone. Either way this @@ -603,6 +602,13 @@ impl WhatsAppClient { if entry.pinned_at > existing.pinned_at { existing.pinned_at = entry.pinned_at; } + if entry.muted_until > existing.muted_until { + existing.muted_until = entry.muted_until; + } + // `entries` are in display order; the first alias is the + // row this logical thread is represented by. A stale second + // PN/LID row must not make an unarchived primary row archived + // again merely because aliases are being collapsed. existing.set_name_if_better(name, name_priority); continue; } @@ -610,6 +616,8 @@ impl WhatsAppClient { // later complete load no longer returns it. let mut chat = oxidezap_core::Chat::from_store(jid_str.clone(), name, name_priority); chat.pinned_at = entry.pinned_at; + chat.muted_until = entry.muted_until; + chat.archived = entry.archived; chat.unread_count = entry.unread_count.max(0) as u32; // -1 = manually marked unread (WA Web convention); .max(0) above // must not silently eat the flag. diff --git a/crates/session/src/whatsapp/lanes.rs b/crates/session/src/whatsapp/lanes.rs index b41d62c2..58fdb309 100644 --- a/crates/session/src/whatsapp/lanes.rs +++ b/crates/session/src/whatsapp/lanes.rs @@ -140,8 +140,11 @@ pub(super) struct DispatchOutcome { pub(super) special_chat_jids: Vec, } -fn recoverable(event: &Event) -> bool { - matches!(event, Event::Messages(_) | Event::Receipt(_)) +pub(super) fn recoverable(event: &Event) -> bool { + matches!( + event, + Event::Messages(_) | Event::ServerAck(_) | Event::Receipt(_) + ) } /// One event per subject it is about, which for everything but a batch of @@ -248,6 +251,7 @@ pub(super) fn event_subject(event: &Event) -> Option { .iter() .next() .map(|inbound| Subject::Chat(inbound.info.source.chat.clone())), + Event::ServerAck(ack) => ack.from.clone().map(Subject::Chat), Event::Receipt(receipt) => Some(Subject::Chat(receipt.source.chat.clone())), Event::ChatPresence(update) => Some(Subject::Chat(update.source.chat.clone())), // Both name somebody, and both handlers go to the store for a name or diff --git a/crates/session/src/whatsapp/mod.rs b/crates/session/src/whatsapp/mod.rs index 3b1c34c9..d532b3b3 100644 --- a/crates/session/src/whatsapp/mod.rs +++ b/crates/session/src/whatsapp/mod.rs @@ -77,6 +77,7 @@ use log::{debug, error, info, warn}; use oxidezap_chat_store::ChatStore; use portable_atomic::{AtomicU64, Ordering}; use tokio::sync::{Mutex, mpsc}; +use whatsapp_rust::PresencePolicy; use whatsapp_rust::bot::Bot; use whatsapp_rust::client::Client; // The same type either way; only the road to it differs. On a desktop the @@ -109,6 +110,23 @@ use crate::video::{self, CameraLost, PictureLost, VideoPublisher, VideoSenderSlo use whatsapp_rust::voip::KeyframeUrgency; use whatsapp_rust::wacore::download::MediaType as DownloadMediaType; +/// Configure the WhatsApp client as a persistent background companion. +/// +/// Automatic presence matches a browser tab: it announces `available` on +/// every connection. This process outlives every window, so that announcement +/// would make WhatsApp route new-message attention away from the phone. +fn persistent_companion( + builder: whatsapp_rust::bot::BotBuilder, +) -> whatsapp_rust::bot::BotBuilder { + builder.with_presence_policy(PresencePolicy::Manual) +} + +/// Retract a stale `available` announcement left on the server by an older +/// client version, as soon as each new connection is ready to send. +async fn announce_background_presence(client: &Client) -> Result<(), whatsapp_rust::PresenceError> { + client.presence().set_unavailable().await +} + use crate::store::StoreRegistry; struct InterestedEventHandler { @@ -151,6 +169,20 @@ fn interested_channel( ) } +/// Durable data changes whose live projection keeps an open conversation +/// current while the history reloader coalesces store invalidations. +const DATA_EVENT_KINDS: &[EventKind] = &[ + EventKind::Messages, + // A positive message ack is the first delivery state of an optimistic + // send. The store also persists it, but its history reloader waits for a + // quiet window that a continuous sync may not reach; carry the tick live + // as a Sent receipt. + EventKind::ServerAck, + EventKind::Receipt, + EventKind::ChatPresence, + EventKind::Presence, +]; + /// Where the store lives on this platform. See [`crate::store`]. pub use crate::store::{database_path as resolve_database_path, prepare as prepare_store}; @@ -1032,7 +1064,7 @@ impl WhatsAppClient { // override makes the library skip its own resolution *and* the // day-long cache stamp behind it. let building = wacore::time::Instant::now(); - let builder = crate::net::with_platform_plugins(Bot::builder()) + let builder = persistent_companion(crate::net::with_platform_plugins(Bot::builder())) .with_backend(backend) .with_inbound_durability_hook(ChatStoreDurabilityHook::new(chat_store.clone())); let bot = match builder.build().await { @@ -1199,15 +1231,7 @@ impl WhatsAppClient { ], 64, ); - let (data_events, data_incoming, data_stats) = interested_channel( - &[ - EventKind::Messages, - EventKind::Receipt, - EventKind::ChatPresence, - EventKind::Presence, - ], - 256, - ); + let (data_events, data_incoming, data_stats) = interested_channel(DATA_EVENT_KINDS, 256); // What tells this session's own tasks that it is over. // // A desktop session ends by dropping the runtime it was built on, @@ -1477,6 +1501,13 @@ impl WhatsAppClient { } Event::Connected(_) => { info!("Connected to WhatsApp!"); + // Manual policy prevents future automatic `available` + // announcements. Retract any presence left on the server by + // an older build or a previous connection as soon as this + // socket is ready. + if let Err(error) = announce_background_presence(&client).await { + warn!("could not announce background presence: {error}"); + } if let Some(reload) = reload { reload.notify_one(); } @@ -1692,6 +1723,34 @@ impl WhatsAppClient { resolve.request_named(sighted); } } + Event::ServerAck(ack) => { + // Acks cover every stanza class, and a nack is explicitly not + // a successful send. The chat store handles both durably; the + // live path only supplies the positive message transition the + // optimistic bubble can draw immediately. + if ack.class.as_deref() != Some("message") || ack.error.is_some() { + return; + } + let Some(from) = &ack.from else { + // A chatless ack can be resolved safely by the store + // against its outgoing rows, but this stateless path + // cannot guess which conversation owns the id. + return; + }; + let Some(chat_jid) = names.chat_key(&client, from).await else { + warn!( + "dropping sent acknowledgement for {}: the PN/LID pair behind it could not be read", + from.observe() + ); + return; + }; + + let _ = ui_tx.send(UiEvent::ReceiptReceived { + chat_jid, + message_ids: vec![ack.id.clone()], + receipt_type: ReceiptType::Sent, + }); + } Event::Receipt(receipt) => { // Delivered used to be dropped here, which is why the // second tick never appeared: only Read and Played reached @@ -2079,10 +2138,67 @@ impl WhatsAppClient { .await }; + // The inbound durability hook commits a live batch before this event + // is dispatched, so the store is the authority even for its *first* + // message. GUI hydration is paged and can lag this event (or omit an + // archived chat entirely); its in-memory Chat cannot decide mute or + // archive policy. Offline drains are history, not new attention. + // Only the protocol's explicit mention list counts. WhatsApp lets a + // personal @mention through a group's mute/archive settings, but an + // ordinary `@` in prose must not bypass them. + let mentions_me = info.source.chat.is_group() + && !info.source.is_from_me + && crate::mentions::mentions_own_account(Some(msg), &history::own_jids(client)); + let (notification_allowed, notification_title, notification_archived) = if eager { + if let Some(store) = names.chat_store() { + match store.notification_metadata(&info.source.chat).await { + Ok(Some(metadata)) => { + let allowed = metadata.allowed || mentions_me; + let title = if allowed { + let identity = names.identity(client, &info.source.chat).await; + let (title, priority) = names + .resolve( + store, + &info.source.chat, + metadata.name.as_deref(), + &identity, + ) + .await; + if mentions_me { + let group = if priority > 0 { + title.as_str() + } else { + "group" + }; + Some(format!("Mentioned in {group}")) + } else { + (priority > 0).then_some(title) + } + } else { + None + }; + (allowed, title, metadata.archived) + } + Ok(None) => (false, None, false), + Err(error) => { + warn!("could not read chat notification policy: {error}"); + (false, None, false) + } + } + } else { + (false, None, false) + } + } else { + (false, None, false) + }; + let _ = ui_tx.send(UiEvent::MessageReceived { chat_jid, message: Box::new(chat_message), sender_name, + notification_allowed, + notification_title, + notification_archived, }); } diff --git a/crates/session/src/whatsapp/tests.rs b/crates/session/src/whatsapp/tests.rs index 30c98b85..6a38c128 100644 --- a/crates/session/src/whatsapp/tests.rs +++ b/crates/session/src/whatsapp/tests.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; +use super::calls::CallRegistry; use super::history::{ LoadedHistory, ReloadScope, apply_status_views, merge_alias_history_messages, }; @@ -14,8 +15,10 @@ use crate::StoreRegistry; use oxidezap_chat_store::{ChatEntry, StoreChange}; use oxidezap_core::{Chat, ChatMessage, MessageStatus, fallback_chat_name}; use std::sync::Arc; +use whatsapp_rust::PresencePolicy; use whatsapp_rust::buffa::MessageField; use whatsapp_rust::wacore::proto_helpers::MessageBuilderExt; +use whatsapp_rust::wacore::types::events::{Event, ServerAck}; use whatsapp_rust::wacore_binary::Jid; use whatsapp_rust::waproto::whatsapp as wa; @@ -30,6 +33,184 @@ fn book_with(store: &Arc) -> NameBook { NameBook::new(Some(store.clone())) } +#[tokio::test] +async fn persistent_companion_uses_manual_presence() { + let store = SqliteStore::new("file:oxidezap-presence-policy?mode=memory&cache=shared") + .await + .expect("in-memory store"); + let bot = super::persistent_companion(whatsapp_rust::bot::Bot::builder()) + .with_backend(store) + .build() + .await + .expect("offline bot"); + + assert_eq!(bot.client().presence_policy(), PresencePolicy::Manual); +} + +/// A previously available session must explicitly retract that state on +/// reconnect; `Manual` alone prevents future automatic announcements but does +/// not retract presence already known to the server. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn connected_companion_announces_unavailable() { + let fixture = whatsapp_rust::test_support::CallFixture::new() + .await + .expect("synthetic connected session"); + let before = fixture.outgoing_stanzas().expect("outbound stanzas").len(); + + super::announce_background_presence(fixture.client()) + .await + .expect("unavailable presence"); + + let sent = fixture.outgoing_stanzas().expect("outbound stanzas"); + assert!( + sent[before..].iter().any(|node| { + let stanza = node.as_node_ref(); + stanza.tag == "presence" + && stanza.attrs().optional_string("type").as_deref() == Some("unavailable") + }), + "reconnect must retract an older available state" + ); + fixture.shutdown().await.expect("fixture shutdown"); +} + +#[test] +fn the_live_data_lane_subscribes_to_server_acks() { + assert!( + super::DATA_EVENT_KINDS + .contains(&whatsapp_rust::wacore::types::events::EventKind::ServerAck) + ); +} + +#[test] +fn a_server_ack_uses_its_chats_recoverable_lane() { + let ack = Event::ServerAck( + ServerAck::builder() + .id("ACKED-LANE".to_string()) + .class("message".to_string()) + .from(TEST_PEER.parse().expect("test JID")) + .build(), + ); + + assert!(super::lanes::recoverable(&ack)); + assert_eq!( + super::lanes::event_subject(&ack) + .map(|subject| subject.as_written()) + .as_deref(), + Some(TEST_PEER) + ); +} + +/// A positive message ack is the first delivery state after the optimistic +/// clock. It must travel live rather than wait for the history reloader's +/// quiet window, which a continuous history sync may never reach. +#[tokio::test] +async fn a_server_ack_publishes_a_live_sent_receipt() { + let (chat_store, client) = test_session("live-server-ack").await; + let (ui_tx, mut ui_rx) = super::ui_queue::channel( + Arc::new(tokio::sync::Notify::new()), + Arc::new(super::ui_queue::HistoryBudget::new()), + ); + let ack = Event::ServerAck( + ServerAck::builder() + .id("ACKED-1".to_string()) + .class("message".to_string()) + .from(TEST_PEER.parse().expect("test JID")) + .build(), + ); + + WhatsAppClient::handle_event( + Arc::new(ack), + client, + ui_tx, + CallRegistry::default(), + Arc::new(book_with(&chat_store)), + None, + None, + None, + ) + .await; + + assert_eq!( + ui_rx.try_recv(), + Ok(oxidezap_core::UiEvent::ReceiptReceived { + chat_jid: TEST_PEER.to_string(), + message_ids: vec!["ACKED-1".to_string()], + receipt_type: oxidezap_core::ReceiptType::Sent, + }) + ); +} + +/// A stanza ack is not necessarily a successful outgoing message. Publishing +/// either of these as `Sent` would replace an honest clock/failure with a lie. +#[tokio::test] +async fn a_nack_or_non_message_ack_does_not_publish_sent() { + for (class, error) in [("message", Some("500")), ("receipt", None)] { + let (chat_store, client) = test_session(&format!("ignored-ack-{class}-{error:?}")).await; + let (ui_tx, mut ui_rx) = super::ui_queue::channel( + Arc::new(tokio::sync::Notify::new()), + Arc::new(super::ui_queue::HistoryBudget::new()), + ); + let ack = Event::ServerAck( + ServerAck::builder() + .id("NOT-SENT".to_string()) + .class(class.to_string()) + .from(TEST_PEER.parse().expect("test JID")) + .maybe_error(error.map(str::to_string)) + .build(), + ); + + WhatsAppClient::handle_event( + Arc::new(ack), + client, + ui_tx, + CallRegistry::default(), + Arc::new(book_with(&chat_store)), + None, + None, + None, + ) + .await; + + assert!( + ui_rx.try_recv().is_err(), + "{class} {error:?} published Sent" + ); + } +} + +/// The store can resolve a chatless ack by looking for a unique outgoing row; +/// the live path cannot guess that ownership and leaves this rare shape to the +/// durable recovery path. +#[tokio::test] +async fn a_chatless_ack_does_not_guess_a_live_destination() { + let (chat_store, client) = test_session("chatless-ack").await; + let (ui_tx, mut ui_rx) = super::ui_queue::channel( + Arc::new(tokio::sync::Notify::new()), + Arc::new(super::ui_queue::HistoryBudget::new()), + ); + let ack = Event::ServerAck( + ServerAck::builder() + .id("CHATLESS-1".to_string()) + .class("message".to_string()) + .build(), + ); + + WhatsAppClient::handle_event( + Arc::new(ack), + client, + ui_tx, + CallRegistry::default(), + Arc::new(book_with(&chat_store)), + None, + None, + None, + ) + .await; + + assert!(ui_rx.try_recv().is_err()); +} + /// A store-hydrated chat list must label a photo the way the live path /// does. The store's preview column holds the newest message's TEXT, and a /// photo with no caption has none — the bubble does, and a row rendered @@ -463,6 +644,261 @@ async fn a_live_self_mention_uses_the_sender_push_name() { } } +/// The live wire event carries the durable alert decision, not the GUI's +/// possibly-unhydrated chat row. The first committed message is eligible. +#[tokio::test] +async fn live_message_carries_store_notification_decision() { + let (chat_store, client) = test_session("notification-policy").await; + let info = live_info( + TEST_GROUP, + TEST_PEER, + Some("Example"), + "MSG-N1", + 1_700_000_100, + ); + feed( + &chat_store, + incoming_in( + TEST_GROUP, + wa::Message::text("first"), + "MSG-N1", + 1_700_000_100, + ), + ) + .await; + chat_store + .set_chat_name(&TEST_GROUP.parse().expect("test JID"), "Example group") + .unwrap(); + chat_store.flush().await.unwrap(); + let (ui_tx, mut ui_rx) = super::ui_queue::channel( + Arc::new(tokio::sync::Notify::new()), + Arc::new(super::ui_queue::HistoryBudget::new()), + ); + WhatsAppClient::handle_inbound_message( + &wa::Message::text("first"), + &info, + &client, + &ui_tx, + &book_with(&chat_store), + true, + ) + .await; + assert!(matches!( + ui_rx.try_recv(), + Ok(oxidezap_core::UiEvent::MessageReceived { + notification_allowed: true, + notification_title: Some(title), + .. + }) if title == "Example group" + )); + + feed( + &chat_store, + Event::MuteUpdate( + whatsapp_rust::wacore::types::events::MuteUpdate::builder() + .jid(TEST_GROUP.parse().expect("test JID")) + .timestamp(whatsapp_rust::wacore::time::from_secs(1_700_000_200).unwrap()) + .action(Box::new(wa::sync_action_value::MuteAction { + muted: Some(true), + ..Default::default() + })) + .from_full_sync(false) + .build(), + ), + ) + .await; + WhatsAppClient::handle_inbound_message( + &wa::Message::text("second"), + &live_info( + TEST_GROUP, + TEST_PEER, + Some("Example"), + "MSG-N2", + 1_700_000_201, + ), + &client, + &ui_tx, + &book_with(&chat_store), + true, + ) + .await; + assert!(matches!( + ui_rx.try_recv(), + Ok(oxidezap_core::UiEvent::MessageReceived { + notification_allowed: false, + .. + }) + )); + + // A reconnect's offline drain updates the conversation but is not a + // newly arrived alert, even after the phone unmutes the group. + feed( + &chat_store, + Event::MuteUpdate( + whatsapp_rust::wacore::types::events::MuteUpdate::builder() + .jid(TEST_GROUP.parse().expect("test JID")) + .timestamp(whatsapp_rust::wacore::time::from_secs(1_700_000_300).unwrap()) + .action(Box::new(wa::sync_action_value::MuteAction { + muted: Some(false), + ..Default::default() + })) + .from_full_sync(false) + .build(), + ), + ) + .await; + WhatsAppClient::handle_inbound_message( + &wa::Message::text("older"), + &live_info( + TEST_GROUP, + TEST_PEER, + Some("Example"), + "MSG-N3", + 1_700_000_050, + ), + &client, + &ui_tx, + &book_with(&chat_store), + false, + ) + .await; + assert!(matches!( + ui_rx.try_recv(), + Ok(oxidezap_core::UiEvent::MessageReceived { + notification_allowed: false, + .. + }) + )); +} + +/// WhatsApp's explicit personal @mention is the exception to a group's mute +/// and archive state. A bare @ in text is not, and an offline drain is never +/// a fresh desktop alert even when it contains a mention. +#[tokio::test] +async fn direct_group_mention_alerts_through_mute_and_archive() { + use whatsapp_rust::waproto::buffa; + use whatsapp_rust::waproto::whatsapp::message; + + const OWN: &str = "559900000001@s.whatsapp.net"; + let (chat_store, client) = test_session("mention-notification-policy").await; + client + .persistence_manager() + .modify_device(|device| device.pn = Some(OWN.parse().expect("test JID"))) + .await; + feed( + &chat_store, + incoming_in( + TEST_GROUP, + wa::Message::text("first"), + "MSG-M1", + 1_700_000_100, + ), + ) + .await; + chat_store + .set_chat_name(&TEST_GROUP.parse().expect("test JID"), "Example group") + .unwrap(); + chat_store.flush().await.unwrap(); + feed( + &chat_store, + Event::MuteUpdate( + whatsapp_rust::wacore::types::events::MuteUpdate::builder() + .jid(TEST_GROUP.parse().expect("test JID")) + .timestamp(whatsapp_rust::wacore::time::from_secs(1_700_000_200).unwrap()) + .action(Box::new(wa::sync_action_value::MuteAction { + muted: Some(true), + ..Default::default() + })) + .from_full_sync(false) + .build(), + ), + ) + .await; + feed( + &chat_store, + Event::ArchiveUpdate( + whatsapp_rust::wacore::types::events::ArchiveUpdate::builder() + .jid(TEST_GROUP.parse().expect("test JID")) + .timestamp(whatsapp_rust::wacore::time::from_secs(1_700_000_200).unwrap()) + .action(Box::new(wa::sync_action_value::ArchiveChatAction { + archived: Some(true), + ..Default::default() + })) + .from_full_sync(false) + .build(), + ), + ) + .await; + + let mention = wa::Message { + extended_text_message: buffa::MessageField::some(message::ExtendedTextMessage { + text: Some("oi @559900000001".to_string()), + context_info: buffa::MessageField::some(wa::ContextInfo { + mentioned_jid: vec![OWN.to_string()], + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + let (ui_tx, mut ui_rx) = super::ui_queue::channel( + Arc::new(tokio::sync::Notify::new()), + Arc::new(super::ui_queue::HistoryBudget::new()), + ); + WhatsAppClient::handle_inbound_message( + &mention, + &live_info( + TEST_GROUP, + TEST_PEER, + Some("Example"), + "MSG-M2", + 1_700_000_201, + ), + &client, + &ui_tx, + &book_with(&chat_store), + true, + ) + .await; + assert!(matches!( + ui_rx.try_recv(), + Ok(oxidezap_core::UiEvent::MessageReceived { + notification_allowed: true, + notification_title: Some(title), + notification_archived: true, + .. + }) if title == "Mentioned in Example group" + )); + + for (message, eager) in [ + (wa::Message::text("oi @559900000001"), true), + (mention.clone(), false), + ] { + WhatsAppClient::handle_inbound_message( + &message, + &live_info( + TEST_GROUP, + TEST_PEER, + Some("Example"), + "MSG-M3", + 1_700_000_202, + ), + &client, + &ui_tx, + &book_with(&chat_store), + eager, + ) + .await; + assert!(matches!( + ui_rx.try_recv(), + Ok(oxidezap_core::UiEvent::MessageReceived { + notification_allowed: false, + .. + }) + )); + } +} + /// One inbound message's envelope, spelled out: the same fields [`from`] /// builds, without the event around them, for driving the live path directly. fn live_info( @@ -863,7 +1299,7 @@ async fn test_session(name: &str) -> (Arc, Arc) { .await .expect("in-memory store"); let chat_store = ChatStore::new(&store).await.expect("chat store"); - let bot = whatsapp_rust::bot::Bot::builder() + let bot = super::persistent_companion(whatsapp_rust::bot::Bot::builder()) .with_backend(store) .with_inbound_durability_hook(super::ChatStoreDurabilityHook::new(chat_store.clone())) .build() @@ -1031,7 +1467,7 @@ fn history_fallbacks_do_not_expose_internal_lids() { assert_eq!(fallback_chat_name(&lid), "Unknown contact"); assert_eq!(fallback_chat_name(&pn), "+12025550143"); - assert_eq!(fallback_chat_name(&group), "Unnamed group"); + assert_eq!(fallback_chat_name(&group), "Group name unavailable"); } #[test] @@ -1368,6 +1804,20 @@ async fn a_scoped_load_skips_an_archived_chat() { loaded.chats.iter().all(|chat| chat.jid != peer), "a scoped pin answer leaves an archived chat out" ); + + let archived_entries = chat_store + .chats(true, 10) + .await + .expect("include-archived page loads"); + let archived = + WhatsAppClient::hydrate_entries(&chat_store, &client, &book(), archived_entries, |_| 8) + .await + .expect("archived rows hydrate"); + let restored = archived + .iter() + .find(|chat| chat.jid == peer) + .expect("archived chat is discoverable in its explicit list"); + assert!(restored.archived, "archive state survives hydration"); } /// A cursor is this crate's to write and to read, and the only thing that diff --git a/crates/session/src/whatsapp/ui_queue.rs b/crates/session/src/whatsapp/ui_queue.rs index 3dd1d9fe..3a637339 100644 --- a/crates/session/src/whatsapp/ui_queue.rs +++ b/crates/session/src/whatsapp/ui_queue.rs @@ -484,11 +484,17 @@ fn estimated_bytes(event: &UiEvent) -> usize { chat_jid, message, sender_name, + notification_title, + .. } => { std::mem::size_of_val(event) + string_bytes(chat_jid) + message_bytes(message) + sender_name.as_deref().map(string_bytes).unwrap_or_default() + + notification_title + .as_deref() + .map(string_bytes) + .unwrap_or_default() } _ => std::mem::size_of_val(event) + event_strings(event), } @@ -714,6 +720,9 @@ mod tests { "body".into(), )), sender_name: None, + notification_allowed: false, + notification_title: None, + notification_archived: false, }) .unwrap(); sender @@ -725,6 +734,9 @@ mod tests { "body".into(), )), sender_name: None, + notification_allowed: false, + notification_title: None, + notification_archived: false, }) .unwrap(); assert_eq!(sender.stats().dropped_recoverable, 1); From 61fb45868b7388a24b61d0777e49ad8bdb180558 Mon Sep 17 00:00:00 2001 From: Assis Date: Mon, 21 Sep 2026 19:03:32 -0300 Subject: [PATCH 2/7] fix(chat): apply explicit unarchive on live events An absent archive field from older frames is unknown, not false. Carry the optional store answer so the GUI can unarchive promptly without pulling archived chats into the active list on unknown state. --- crates/core/src/events.rs | 13 ++-- crates/daemon/src/session_bridge/tests.rs | 2 +- .../daemon/src/session_bridge/wire_events.rs | 2 +- crates/gui/src/app/mod.rs | 71 +++++++++++++++---- crates/plugin-host/src/event.rs | 2 +- crates/plugin-host/src/tests.rs | 2 +- crates/session/src/whatsapp/mod.rs | 10 +-- crates/session/src/whatsapp/tests.rs | 2 +- crates/session/src/whatsapp/ui_queue.rs | 4 +- 9 files changed, 78 insertions(+), 30 deletions(-) diff --git a/crates/core/src/events.rs b/crates/core/src/events.rs index c5df0aa5..80ce5faa 100644 --- a/crates/core/src/events.rs +++ b/crates/core/src/events.rs @@ -69,10 +69,11 @@ pub enum UiEvent { /// or a generated group placeholder. #[serde(default, skip_serializing_if = "Option::is_none")] notification_title: Option, - /// The store says this conversation is archived. A live @mention may - /// still alert, but the GUI must not resurrect it in the active list. - #[serde(default)] - notification_archived: bool, + /// The store's archive answer. `None` means not known (including an + /// older daemon frame), distinct from an explicit unarchive. A live + /// @mention may still alert without resurrecting an archived chat. + #[serde(default, skip_serializing_if = "Option::is_none")] + notification_archived: Option, }, ReceiptReceived { chat_jid: String, @@ -404,7 +405,7 @@ mod notification_wire_tests { sender_name: None, notification_allowed: true, notification_title: Some("Example group".into()), - notification_archived: true, + notification_archived: Some(true), }; let mut wire = serde_json::to_value(event).unwrap(); let fields = wire @@ -420,7 +421,7 @@ mod notification_wire_tests { UiEvent::MessageReceived { notification_allowed: false, notification_title: None, - notification_archived: false, + notification_archived: None, .. } )); diff --git a/crates/daemon/src/session_bridge/tests.rs b/crates/daemon/src/session_bridge/tests.rs index 7ce51162..619e1c51 100644 --- a/crates/daemon/src/session_bridge/tests.rs +++ b/crates/daemon/src/session_bridge/tests.rs @@ -30,7 +30,7 @@ pub(super) fn received(chat_jid: &str, message: ChatMessage, sender_name: Option sender_name: sender_name.map(str::to_string), notification_allowed: false, notification_title: None, - notification_archived: false, + notification_archived: None, } } diff --git a/crates/daemon/src/session_bridge/wire_events.rs b/crates/daemon/src/session_bridge/wire_events.rs index 0f8e4a0c..d18fcba5 100644 --- a/crates/daemon/src/session_bridge/wire_events.rs +++ b/crates/daemon/src/session_bridge/wire_events.rs @@ -297,7 +297,7 @@ mod tests { sender_name: None, notification_allowed: false, notification_title: None, - notification_archived: false, + notification_archived: None, }), }) .unwrap(); diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index 2bfdb3be..65e50bce 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -942,11 +942,11 @@ pub struct WhatsAppApp { struct IncomingAlert { allowed: bool, title: Option, - archived: bool, + archived: Option, } impl IncomingAlert { - fn new(allowed: bool, title: Option, archived: bool) -> Self { + fn new(allowed: bool, title: Option, archived: Option) -> Self { Self { allowed, title, @@ -3058,8 +3058,8 @@ impl WhatsAppApp { if let Some(index) = chat_index { // Update the existing chat let chat = Arc::make_mut(&mut self.chats[index]); - if alert.archived { - chat.archived = true; + if let Some(archived) = alert.archived { + chat.archived = archived; } // For groups: update participant name, NOT the chat name @@ -3105,7 +3105,7 @@ impl WhatsAppApp { } else { Chat::new(chat_jid.clone()) }; - new_chat.archived = alert.archived; + new_chat.archived = alert.archived.unwrap_or(false); // For groups: track participant if is_group && let Some(ref name) = sender_name { @@ -3953,7 +3953,7 @@ mod tests { "New message".into(), ), Some("Example contact".into()), - IncomingAlert::new(true, None, false), + IncomingAlert::new(true, None, Some(false)), cx, ); }); @@ -3970,7 +3970,7 @@ mod tests { "Quiet message".into(), ), Some("Muted contact".into()), - IncomingAlert::new(false, None, false), + IncomingAlert::new(false, None, None), cx, ); }); @@ -3988,7 +3988,7 @@ mod tests { "No longer quiet".into(), ), Some("Muted contact".into()), - IncomingAlert::new(true, None, false), + IncomingAlert::new(true, None, Some(false)), cx, ); }); @@ -4016,7 +4016,7 @@ mod tests { "Group message".into(), ), Some(sender.into()), - IncomingAlert::new(true, None, false), + IncomingAlert::new(true, None, Some(false)), cx, ); }); @@ -4041,7 +4041,7 @@ mod tests { "Muted before hydration".into(), ), Some("Member".into()), - IncomingAlert::new(false, Some("Stored subject".into()), false), + IncomingAlert::new(false, Some("Stored subject".into()), None), cx, ); app.handle_message_received( @@ -4052,7 +4052,7 @@ mod tests { "Allowed before hydration".into(), ), Some("Member".into()), - IncomingAlert::new(true, Some("Stored subject".into()), false), + IncomingAlert::new(true, Some("Stored subject".into()), Some(false)), cx, ); }); @@ -4071,7 +4071,11 @@ mod tests { "Mentioned you".into(), ), Some("Member".into()), - IncomingAlert::new(true, Some("Mentioned in Archived example".into()), true), + IncomingAlert::new( + true, + Some("Mentioned in Archived example".into()), + Some(true), + ), cx, ); assert!( @@ -4085,6 +4089,49 @@ mod tests { cx.shown_system_notifications()[5].title.as_ref(), "Mentioned in Archived example" ); + + cx.update(|cx| { + app.update(cx, |app, cx| { + // The phone explicitly unarchived this conversation. The + // live store answer must remove it from Archived immediately, + // without waiting for a later paged history reload. + app.handle_message_received( + "archived-group@g.us".into(), + ChatMessage::new_incoming( + "MESSAGE-UNARCHIVED".into(), + "member@example.invalid".into(), + "No longer archived".into(), + ), + Some("Member".into()), + IncomingAlert::new(false, None, Some(false)), + cx, + ); + assert!( + app.find_chat("archived-group@g.us") + .is_some_and(|chat| !chat.archived) + ); + + // A frame without durable metadata is not an instruction to + // unarchive an already-known row (older daemon compatibility). + app.find_chat_mut("archived-group@g.us").unwrap().archived = true; + app.handle_message_received( + "archived-group@g.us".into(), + ChatMessage::new_incoming( + "MESSAGE-UNKNOWN-ARCHIVE".into(), + "member@example.invalid".into(), + "Unknown archive state".into(), + ), + Some("Member".into()), + IncomingAlert::new(false, None, None), + cx, + ); + assert!( + app.find_chat("archived-group@g.us") + .is_some_and(|chat| chat.archived) + ); + }); + }); + assert_eq!(cx.shown_system_notifications().len(), 6); } fn at(secs: i64) -> Option> { diff --git a/crates/plugin-host/src/event.rs b/crates/plugin-host/src/event.rs index dffa3b2c..0c67b80b 100644 --- a/crates/plugin-host/src/event.rs +++ b/crates/plugin-host/src/event.rs @@ -365,7 +365,7 @@ mod tests { sender_name: None, notification_allowed: false, notification_title: None, - notification_archived: false, + notification_archived: None, } } diff --git a/crates/plugin-host/src/tests.rs b/crates/plugin-host/src/tests.rs index b9957476..c80763b1 100644 --- a/crates/plugin-host/src/tests.rs +++ b/crates/plugin-host/src/tests.rs @@ -163,7 +163,7 @@ fn message(chat: &str, text: &str) -> UiEvent { sender_name: None, notification_allowed: false, notification_title: None, - notification_archived: false, + notification_archived: None, } } diff --git a/crates/session/src/whatsapp/mod.rs b/crates/session/src/whatsapp/mod.rs index d532b3b3..64717a57 100644 --- a/crates/session/src/whatsapp/mod.rs +++ b/crates/session/src/whatsapp/mod.rs @@ -2177,19 +2177,19 @@ impl WhatsAppClient { } else { None }; - (allowed, title, metadata.archived) + (allowed, title, Some(metadata.archived)) } - Ok(None) => (false, None, false), + Ok(None) => (false, None, None), Err(error) => { warn!("could not read chat notification policy: {error}"); - (false, None, false) + (false, None, None) } } } else { - (false, None, false) + (false, None, None) } } else { - (false, None, false) + (false, None, None) }; let _ = ui_tx.send(UiEvent::MessageReceived { diff --git a/crates/session/src/whatsapp/tests.rs b/crates/session/src/whatsapp/tests.rs index 6a38c128..f5ea27cc 100644 --- a/crates/session/src/whatsapp/tests.rs +++ b/crates/session/src/whatsapp/tests.rs @@ -865,7 +865,7 @@ async fn direct_group_mention_alerts_through_mute_and_archive() { Ok(oxidezap_core::UiEvent::MessageReceived { notification_allowed: true, notification_title: Some(title), - notification_archived: true, + notification_archived: Some(true), .. }) if title == "Mentioned in Example group" )); diff --git a/crates/session/src/whatsapp/ui_queue.rs b/crates/session/src/whatsapp/ui_queue.rs index 3a637339..56adcd39 100644 --- a/crates/session/src/whatsapp/ui_queue.rs +++ b/crates/session/src/whatsapp/ui_queue.rs @@ -722,7 +722,7 @@ mod tests { sender_name: None, notification_allowed: false, notification_title: None, - notification_archived: false, + notification_archived: None, }) .unwrap(); sender @@ -736,7 +736,7 @@ mod tests { sender_name: None, notification_allowed: false, notification_title: None, - notification_archived: false, + notification_archived: None, }) .unwrap(); assert_eq!(sender.stats().dropped_recoverable, 1); From d55b2092f68d1b94d35b41f93923242dadfcee72 Mon Sep 17 00:00:00 2001 From: Assis Date: Mon, 21 Sep 2026 21:11:22 -0300 Subject: [PATCH 3/7] feat(chat): guard message and media actions Require confirmation before deleting messages or sending picker/drop attachments. Surface accepted edits on own and received bubbles, and allow copying selected message text. --- Cargo.lock | 1 + crates/chat-store/src/store/mod.rs | 32 + crates/chat-store/tests/edits.rs | 33 + crates/core/src/chat/merge.rs | 131 +++ crates/core/src/chat/message.rs | 10 +- crates/daemon/src/server/requests.rs | 34 + crates/daemon/src/server/tests.rs | 84 ++ crates/daemon/src/session_bridge/act.rs | 54 +- crates/daemon/src/session_bridge/action.rs | 10 + crates/gui/Cargo.toml | 1 + crates/gui/src/app/attaching.rs | 235 ++++- crates/gui/src/app/calls_ctl.rs | 13 + crates/gui/src/app/commands.rs | 6 + crates/gui/src/app/message_actions.rs | 969 ++++++++++++++++++ crates/gui/src/app/messages.rs | 116 ++- crates/gui/src/app/mod.rs | 113 +- crates/gui/src/app/notices.rs | 7 + .../gui/src/components/message_bubble/mod.rs | 60 +- crates/gui/src/components/message_list.rs | 1 + crates/gui/src/components/mod.rs | 2 +- crates/gui/src/components/paste_preview.rs | 178 +++- crates/gui/src/components/rich_text.rs | 356 ++++++- crates/gui/src/session/frames.rs | 3 + crates/gui/src/session/mod.rs | 46 +- crates/gui/src/views/chat.rs | 2 + crates/ipc/src/lib.rs | 4 +- crates/ipc/src/protocol.rs | 43 + crates/ipc/src/transport.rs | 6 +- crates/ipc/tests/session_frames.rs | 2 + crates/session/src/whatsapp/convert.rs | 1 + crates/session/src/whatsapp/mod.rs | 1 + crates/session/src/whatsapp/mutations.rs | 93 +- crates/session/src/whatsapp/tests.rs | 36 + docs/stories/1.6.story.md | 125 +++ docs/stories/1.7.story.md | 119 +++ docs/stories/1.8.story.md | 117 +++ 36 files changed, 2953 insertions(+), 91 deletions(-) create mode 100644 crates/gui/src/app/message_actions.rs create mode 100644 docs/stories/1.6.story.md create mode 100644 docs/stories/1.7.story.md create mode 100644 docs/stories/1.8.story.md diff --git a/Cargo.lock b/Cargo.lock index ce08cc44..b1fd42ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6446,6 +6446,7 @@ dependencies = [ "futures-channel", "futures-lite", "getrandom 0.4.3", + "gpui-base", "gpui-component", "gpui-kit-assets", "gpui-pre", diff --git a/crates/chat-store/src/store/mod.rs b/crates/chat-store/src/store/mod.rs index 31483fc0..853a0e89 100644 --- a/crates/chat-store/src/store/mod.rs +++ b/crates/chat-store/src/store/mod.rs @@ -564,6 +564,38 @@ impl ChatStore { .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into()))) } + /// Apply a locally requested delete-for-me through the same materializer + /// as the app-state event that will later arrive from linked devices. + pub fn record_delete_for_me( + &self, + chat: &Jid, + target_id: &str, + from_me: bool, + participant: Option, + message_timestamp_ms: i64, + timestamp: DateTime, + ) -> Result<()> { + use wacore::types::events::DeleteMessageForMeUpdate; + + let update = DeleteMessageForMeUpdate::builder() + .chat_jid(chat.clone()) + .maybe_participant_jid(participant) + .message_id(target_id.to_owned()) + .from_me(from_me) + .timestamp(timestamp) + .action(Box::new(wa::sync_action_value::DeleteMessageForMeAction { + delete_media: Some(false), + message_timestamp: Some(message_timestamp_ms), + })) + .from_full_sync(false) + .build(); + self.tx + .send(WriterMsg::Event(Arc::new(Event::DeleteMessageForMeUpdate( + update, + )))) + .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into()))) + } + /// Record a reaction this client just sent. An empty `emoji` removes this /// client's existing reaction, matching the inbound event semantics. /// diff --git a/crates/chat-store/tests/edits.rs b/crates/chat-store/tests/edits.rs index 3e8257e8..26227bfa 100644 --- a/crates/chat-store/tests/edits.rs +++ b/crates/chat-store/tests/edits.rs @@ -129,6 +129,39 @@ async fn local_edit_updates_own_message_and_preview() { assert_eq!(msg.text.as_deref(), Some("fixed")); } +#[tokio::test] +async fn local_delete_for_me_removes_own_copy_without_a_tombstone() { + let (_store, chat_store) = test_store().await; + let chat = jid(PEER); + chat_store + .record_outgoing( + &chat, + "OUT-LOCAL-DELETE", + &wa::Message::text("private"), + ts(1_700_000_000), + ) + .unwrap(); + chat_store.flush().await.unwrap(); + chat_store + .record_delete_for_me( + &chat, + "OUT-LOCAL-DELETE", + true, + None, + 1_700_000_000_000, + ts(1_700_000_000), + ) + .unwrap(); + chat_store.flush().await.unwrap(); + assert!( + chat_store + .message(&chat, "OUT-LOCAL-DELETE") + .await + .unwrap() + .is_none() + ); +} + #[tokio::test] async fn local_revoke_tombstones_own_message_and_absorbs_edits() { let (_store, chat_store) = test_store().await; diff --git a/crates/core/src/chat/merge.rs b/crates/core/src/chat/merge.rs index a73dfbd7..47df22d8 100644 --- a/crates/core/src/chat/merge.rs +++ b/crates/core/src/chat/merge.rs @@ -8,6 +8,24 @@ use super::message::ChatMessage; use crate::message_status::MessageStatus; impl Chat { + /// Remove one local copy after a delete-for-me succeeded. History loads + /// merge present rows but cannot infer deletion from an absent row. + pub fn remove_message_for_me(&mut self, id: &str) -> bool { + let Some(position) = self.messages.iter().position(|message| message.id == id) else { + return false; + }; + self.messages.remove(position); + if let Some(newest) = self.messages.last() { + self.last_message = Some(newest.preview_text()); + } else { + self.last_message = None; + } + // The chat-list position is its latest activity, not the timestamp + // of the newest surviving preview. The store keeps that activity + // when a message is deleted for me; the live list must agree. + true + } + /// Add a message to the chat, maintaining chronological order by timestamp. /// Returns true when the message became the chat's newest content, so the /// caller knows whether to bump the chat in the list; duplicates and older @@ -178,6 +196,15 @@ impl Chat { // along wins, so a reload can neither un-fail a send nor pull a // read bubble back to delivered. message.status.advance(existing.status); + // An accepted edit cannot become unedited. A history read begun + // before that commit can finish after the live edit acknowledgement; + // its stale row must not briefly remove the label while the next + // store reload catches up. + if message.is_from_me == existing.is_from_me + && (message.is_from_me || message.sender == existing.sender) + { + message.edited |= existing.edited; + } if message.sender_name.is_none() { message.sender_name = existing.sender_name.clone(); } @@ -264,13 +291,117 @@ impl Chat { } } +#[cfg(test)] +mod delete_for_me_tests { + use super::*; + + #[test] + fn local_delete_removes_exact_row_and_updates_preview() { + let mut chat = Chat::new("559900000001@s.whatsapp.net".into()); + let mut first = ChatMessage::new_outgoing("FIRST".into(), "first".into()); + first.timestamp = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(); + let mut second = ChatMessage::new_outgoing("SECOND".into(), "second".into()); + second.timestamp = chrono::DateTime::from_timestamp(1_700_000_060, 0).unwrap(); + chat.add_message(first); + chat.add_message(second); + let activity = chat.last_message_time; + assert!(chat.remove_message_for_me("SECOND")); + assert_eq!(chat.messages.len(), 1); + assert_eq!(chat.messages[0].id, "FIRST"); + assert_eq!(chat.last_message.as_deref(), Some("first")); + assert_eq!(chat.last_message_time, activity); + assert!(!chat.remove_message_for_me("MISSING")); + assert!(chat.remove_message_for_me("FIRST")); + assert!(chat.messages.is_empty()); + assert!(chat.last_message.is_none()); + assert_eq!(chat.last_message_time, activity); + } +} + #[cfg(test)] mod tests { use super::*; use crate::chat::media::make_media; use crate::chat::message::make_message; + use crate::fixtures; use chrono::{TimeZone, Utc}; + #[test] + fn history_rebuild_preserves_edited_fact_on_exact_own_and_peer_rows() { + for jid in [fixtures::PEER, fixtures::GROUP] { + let mut live = Chat::new(jid.into()); + for id in ["OWN", "PEER", "PLAIN"] { + live.add_message(make_message(id, 1_700_000_000)); + } + let mut hydrated = Chat::new(jid.into()); + let mut own = make_message("OWN", 1_700_000_000); + own.is_from_me = true; + own.edited = true; + let mut peer = make_message("PEER", 1_700_000_000); + peer.edited = true; + hydrated.messages = vec![own, peer, make_message("PLAIN", 1_700_000_000)]; + + live.merge_history(hydrated); + assert_eq!(live.messages.len(), 3); + assert!(live.messages.iter().find(|m| m.id == "OWN").unwrap().edited); + assert!( + live.messages + .iter() + .find(|m| m.id == "PEER") + .unwrap() + .edited + ); + assert!( + !live + .messages + .iter() + .find(|m| m.id == "PLAIN") + .unwrap() + .edited + ); + + // A page queried before the edit's commit may be delivered after + // the live acknowledgement. It cannot take the marker back. + let mut stale = Chat::new(jid.into()); + let mut stale_own = make_message("OWN", 1_700_000_000); + stale_own.is_from_me = true; + stale.messages = vec![ + stale_own, + make_message("PEER", 1_700_000_000), + make_message("PLAIN", 1_700_000_000), + ]; + live.merge_history(stale); + assert!(live.messages.iter().find(|m| m.id == "OWN").unwrap().edited); + assert!( + live.messages + .iter() + .find(|m| m.id == "PEER") + .unwrap() + .edited + ); + assert!( + !live + .messages + .iter() + .find(|m| m.id == "PLAIN") + .unwrap() + .edited + ); + } + } + + #[test] + fn a_group_id_collision_does_not_transfer_an_edit_marker_to_another_author() { + let mut chat = Chat::new(fixtures::GROUP.into()); + let mut own = make_message("COLLIDING", 1_700_000_000); + own.is_from_me = true; + own.edited = true; + chat.add_message(own); + let peer = make_message("COLLIDING", 1_700_000_000); + chat.insert_history_message(peer); + assert!(!chat.messages[0].edited); + } + /// A row that arrives already read is not unread. The case that made this /// matter is the call record: it is written as an incoming message, so a /// call the user had just finished raised a badge for itself. diff --git a/crates/core/src/chat/message.rs b/crates/core/src/chat/message.rs index b89122b2..7943f6cc 100644 --- a/crates/core/src/chat/message.rs +++ b/crates/core/src/chat/message.rs @@ -41,8 +41,6 @@ pub struct ChatMessage { /// The message this one replies to, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub quoted: Option, - /// Set when nobody typed this: a call record, a group change. Such a row - /// has no author and no ticks, and renders centred rather than as a bubble. /// Whether the sender took this message back. /// /// Kept as a fact rather than left implicit in the "[Message deleted]" @@ -51,6 +49,12 @@ pub struct ChatMessage { /// deleted update to watch — should not have to recognise a sentence. #[serde(default, skip_serializing_if = "is_false")] pub revoked: bool, + /// Whether the durable store has accepted an edit of this message. + /// The store retains the timestamp and ordering; the UI needs only this fact. + #[serde(default, skip_serializing_if = "is_false")] + pub edited: bool, + /// Set when nobody typed this: a call record, a group change. Such a row + /// has no author and no ticks, and renders centred rather than as a bubble. #[serde(default, skip_serializing_if = "Option::is_none")] pub system: Option, } @@ -97,6 +101,7 @@ impl ChatMessage { status: MessageStatus::Pending, quoted: None, revoked: false, + edited: false, system: None, } } @@ -125,6 +130,7 @@ impl ChatMessage { status: MessageStatus::default(), quoted: None, revoked: false, + edited: false, system: None, } } diff --git a/crates/daemon/src/server/requests.rs b/crates/daemon/src/server/requests.rs index b2effc28..575cdf22 100644 --- a/crates/daemon/src/server/requests.rs +++ b/crates/daemon/src/server/requests.rs @@ -103,6 +103,40 @@ pub(super) async fn handle_request( ClientRequest::SendText(request) => { acted(dispatch(hub, commands, Action::SendText(*request)).await) } + ClientRequest::EditMessage(request) => { + let id = match addressed(id, "an edit needs an id to answer under") { + Ok(id) => id, + Err(refusal) => return refusal, + }; + out_of_band( + hub, + commands, + id, + Action::EditMessage { + id, + request, + answer_to: outbox.clone(), + }, + ) + .await + } + ClientRequest::RevokeMessage(request) => { + let id = match addressed(id, "a delete needs an id to answer under") { + Ok(id) => id, + Err(refusal) => return refusal, + }; + out_of_band( + hub, + commands, + id, + Action::RevokeMessage { + id, + request, + answer_to: outbox.clone(), + }, + ) + .await + } ClientRequest::SendAudio(request) => { acted(dispatch(hub, commands, Action::SendAudio(*request)).await) } diff --git a/crates/daemon/src/server/tests.rs b/crates/daemon/src/server/tests.rs index 6a1dc21b..396abab7 100644 --- a/crates/daemon/src/server/tests.rs +++ b/crates/daemon/src/server/tests.rs @@ -750,6 +750,90 @@ fn describe(action: Action) -> oxidezap_ipc::SendMedia { } } +/// GUI message mutations are addressed, unlike a queued send: the session's +/// network result, not admission into the bridge, must answer the request. +#[tokio::test] +async fn gui_edit_and_delete_reach_the_session_without_an_early_ack() { + let hub = connected_hub(); + let plugins = no_plugins(); + for request in [ + ClientRequest::EditMessage(oxidezap_ipc::EditMessage { + jid: "a@s.whatsapp.net".into(), + message_id: "EDIT-1".into(), + new_text: "after".into(), + }), + ClientRequest::RevokeMessage(oxidezap_ipc::RevokeMessage { + jid: "a@s.whatsapp.net".into(), + message_id: "DELETE-1".into(), + for_everyone: false, + }), + ClientRequest::RevokeMessage(oxidezap_ipc::RevokeMessage { + jid: "a@s.whatsapp.net".into(), + message_id: "DELETE-EVERYONE-1".into(), + for_everyone: true, + }), + ] { + let (commands, taken) = bridge(CommandOutcome::Accepted); + let answer = handle_request( + Request { + id: Some(42), + request, + }, + &hub, + &plugins, + &commands, + &outbox(), + ) + .await; + assert!( + answer.frame.is_none(), + "the bridge has not completed the mutation" + ); + match taken.await.unwrap().unwrap() { + Action::EditMessage { id, request, .. } => { + assert_eq!(id, 42); + assert_eq!(request.message_id, "EDIT-1"); + assert_eq!(request.new_text, "after"); + } + Action::RevokeMessage { id, request, .. } => { + assert_eq!(id, 42); + assert_eq!( + request.for_everyone, + request.message_id == "DELETE-EVERYONE-1" + ); + } + action => panic!("unexpected action: {action:?}"), + } + } +} + +#[tokio::test] +async fn gui_message_mutations_require_an_answer_id() { + let hub = connected_hub(); + let (commands, _taken) = bridge(CommandOutcome::Accepted); + for request in [ + ClientRequest::EditMessage(oxidezap_ipc::EditMessage { + jid: "a@s.whatsapp.net".into(), + message_id: "EDIT-1".into(), + new_text: "after".into(), + }), + ClientRequest::RevokeMessage(oxidezap_ipc::RevokeMessage { + jid: "a@s.whatsapp.net".into(), + message_id: "DELETE-1".into(), + for_everyone: true, + }), + ] { + let answer = handle_request(bare(request), &hub, &no_plugins(), &commands, &outbox()).await; + assert!(matches!( + parse(answer.frame), + DaemonMessage::Error { + id: None, + error: ProtocolError::Malformed { .. }, + } + )); + } +} + /// `Accepted` has to mean the session took it, not that a queue did. The /// account can drop between the check at the door and the moment the /// bridge picks the command up, and a client told yes on admission alone diff --git a/crates/daemon/src/session_bridge/act.rs b/crates/daemon/src/session_bridge/act.rs index 629ca88f..2f9c7c0b 100644 --- a/crates/daemon/src/session_bridge/act.rs +++ b/crates/daemon/src/session_bridge/act.rs @@ -84,6 +84,43 @@ impl Bridge { reply: tokio::sync::oneshot::Sender, ) -> Option<(Action, tokio::sync::oneshot::Sender)> { match action { + Action::EditMessage { + id, + request, + answer_to, + } => { + let Some(permit) = self.permit() else { + let _ = reply.send(too_busy()); + return None; + }; + let task = client.edit_message(request.jid, request.message_id, request.new_text); + oxidezap_session::spawn(async move { + let result = mutation_answer(id, task.await); + answer_now(&answer_to, answered(id, result)); + let _ = reply.send(CommandOutcome::Accepted); + drop(permit); + }); + None + } + Action::RevokeMessage { + id, + request, + answer_to, + } => { + let Some(permit) = self.permit() else { + let _ = reply.send(too_busy()); + return None; + }; + let task = + client.revoke_message(request.jid, request.message_id, request.for_everyone); + oxidezap_session::spawn(async move { + let result = mutation_answer(id, task.await); + answer_now(&answer_to, answered(id, result)); + let _ = reply.send(CommandOutcome::Accepted); + drop(permit); + }); + None + } Action::MarkStatusWatched(oxidezap_ipc::MarkStatusWatched { message_ids }) => { // The other actions are finished when the session has taken // them and what the network makes of them arrives later; this @@ -1358,7 +1395,9 @@ impl Bridge { } match action { - Action::Wire { .. } => unreachable!("Wire actions are handled in begin_slow"), + Action::Wire { .. } | Action::EditMessage { .. } | Action::RevokeMessage { .. } => { + unreachable!("addressed actions are handled in begin_slow") + } Action::SendText(oxidezap_ipc::SendText { jid, text, @@ -1989,6 +2028,19 @@ fn answered(id: RequestId, result: Result) -> Stri .unwrap_or_else(|e| format!(r#"{{"type":"error","error":"malformed","detail":"{e}"}}"#)) } +fn mutation_answer( + id: RequestId, + result: Result, E>, +) -> Result { + match result { + Ok(Ok(_)) => Ok(DaemonMessage::Accepted { id: Some(id) }), + Ok(Err(detail)) => Err(ProtocolError::Refused { detail }), + Err(error) => Err(ProtocolError::NoSession { + detail: format!("session stopped before the message action finished: {error}"), + }), + } +} + /// Unique optimistic-send id. /// /// A millisecond timestamp alone collides on fast double-sends, and the diff --git a/crates/daemon/src/session_bridge/action.rs b/crates/daemon/src/session_bridge/action.rs index a1a509a6..5eee92cc 100644 --- a/crates/daemon/src/session_bridge/action.rs +++ b/crates/daemon/src/session_bridge/action.rs @@ -24,6 +24,16 @@ use oxidezap_ipc::{CallAction, RequestId}; #[derive(Debug)] pub enum Action { SendText(oxidezap_ipc::SendText), + EditMessage { + id: RequestId, + request: oxidezap_ipc::EditMessage, + answer_to: Outbox, + }, + RevokeMessage { + id: RequestId, + request: oxidezap_ipc::RevokeMessage, + answer_to: Outbox, + }, SendAudio(oxidezap_ipc::SendAudio), SendMedia(oxidezap_ipc::SendMedia), MarkRead(oxidezap_ipc::MarkRead), diff --git a/crates/gui/Cargo.toml b/crates/gui/Cargo.toml index c34b0631..08d48c57 100644 --- a/crates/gui/Cargo.toml +++ b/crates/gui/Cargo.toml @@ -34,6 +34,7 @@ chrono = { workspace = true, features = ["std", "clock"] } # Match GPUI Kit's published GPUI family so entities share one implementation. gpui = { package = "gpui-pre", version = "0.3.4" } +gpui-base = { git = "https://github.com/longbridge/gpui-kit.git" } gpui-component = { git = "https://github.com/longbridge/gpui-kit.git" } gpui-kit-assets = { git = "https://github.com/longbridge/gpui-kit.git" } diff --git a/crates/gui/src/app/attaching.rs b/crates/gui/src/app/attaching.rs index 8ca1c972..c2acd2ff 100644 --- a/crates/gui/src/app/attaching.rs +++ b/crates/gui/src/app/attaching.rs @@ -43,6 +43,12 @@ impl WhatsAppApp { &mut self, cx: &mut Context, ) -> Option<(String, Option)> { + // The confirmation surface owns the one pending selection. Do not + // start another picker/drop read behind it: the source would produce + // accepted files with no second surface to present them on. + if self.paste_preview.is_some() { + return None; + } if self.destination != Destination::Chats { return None; } @@ -66,6 +72,9 @@ impl WhatsAppApp { /// rather than being read again at the end: somebody who picks a file and /// then opens another chat meant to send it to the first. pub(super) fn attach_files(&mut self, cx: &mut Context) { + if self.paste_preview.is_some() { + return; + } let Some(jid) = self.selected_chat.clone() else { return; }; @@ -119,29 +128,52 @@ impl WhatsAppApp { self.notify_user(refusal, notices::Tone::Problem, cx); } - // The quote goes on the first file only. Attaching four photos to - // answer one message is one answer, and quoting it four times is what - // the recipient would see otherwise. - // - // And only where there is a first file: a trip that refused everything - // it was given sent nothing, so taking the draft there would clear the - // reply bar over a message the person is still composing an answer to. - let mut quoted = if chosen.files.is_empty() { - None + // Refusals can leave a selection with no accepted files. In that case + // there is nothing to confirm and, importantly, no reply draft to + // consume merely because the chooser returned an error notice. + if chosen.files.is_empty() { + return; + } + + // Keep every accepted file behind one confirmation. The destination + // and reply were captured before asynchronous picker/drop reading, so + // changing chats while the files are being read cannot redirect them. + let files = chosen.files; + let images = crate::components::preview_images(&files); + let chat_was_visible = self.visible_chat.as_deref() == Some(jid); + self.queue_paste_preview( + PendingPastePreview { + jid: jid.to_string(), + reply, + files, + images, + chat_was_visible, + }, + cx, + ); + } + + /// Put one accepted selection behind the shared confirmation surface. + /// + /// Picker, drop and clipboard reads may overlap before their first result + /// reaches the UI. A FIFO here makes that race visible as sequential + /// confirmations rather than silently losing the later accepted files. + pub(super) fn queue_paste_preview( + &mut self, + preview: PendingPastePreview, + cx: &mut Context, + ) { + if self.paste_preview.is_none() { + self.paste_preview = Some(preview); } else { - self.take_reply_draft(reply, cx) - }; - let mut drawn = false; - for file in chosen.files { - drawn |= self.send_attachment(jid, file, quoted.take(), cx); + self.pending_attachment_previews.push_back(preview); } + cx.notify(); + } - // Following the file down is only what the sender expects if they are - // looking at where it landed — the same rule a voice note follows, - // and for the same reason: reading a conversation must not be yanked - // to its newest message by something that finished elsewhere. - if drawn && self.visible_chat.as_deref() == Some(jid) { - self.scroll_to_last_message(); + fn show_next_paste_preview(&mut self) { + if self.paste_preview.is_none() { + self.paste_preview = self.pending_attachment_previews.pop_front(); } } @@ -171,6 +203,7 @@ impl WhatsAppApp { pub(crate) fn cancel_paste_preview(&mut self, cx: &mut Context) -> bool { let cancelled = self.paste_preview.take().is_some(); if cancelled { + self.show_next_paste_preview(); cx.notify(); } cancelled @@ -180,13 +213,20 @@ impl WhatsAppApp { let Some(preview) = self.paste_preview.take() else { return; }; - let quoted = self.take_reply_draft(preview.reply, cx); - let drawn = self.send_attachment(&preview.jid, preview.file, quoted, cx); + // Quote only the first accepted file, just like the old immediate + // multi-file path. Taking the preview before sending makes a repeated + // activation a no-op and therefore cannot duplicate uploads/bubbles. + let mut quoted = self.take_reply_draft(preview.reply, cx); + let mut drawn = false; + for file in preview.files { + drawn |= self.send_attachment(&preview.jid, file, quoted.take(), cx); + } let destination_still_open = self.destination == Destination::Chats && self.selected_chat.as_deref() == Some(preview.jid.as_str()); if drawn && preview.chat_was_visible && destination_still_open { self.scroll_to_last_message(); } + self.show_next_paste_preview(); cx.notify(); } @@ -309,9 +349,11 @@ fn image_size(bytes: &[u8]) -> (Option, Option) { #[cfg(test)] mod tests { + use gpui::AppContext as _; use oxidezap_core::{MediaType, OutgoingMedia}; use super::echo_of; + use crate::app::WhatsAppApp; use crate::platform::picker::{Picked, kind_for}; /// A file of this type, with bytes that are nothing in particular: what is @@ -373,4 +415,153 @@ mod tests { assert_eq!(echo.data.len(), file.bytes.len(), "{photo}"); } } + + #[gpui::test] + fn overlapping_picker_results_are_queued_for_confirmation(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let app = cx.update(|cx| cx.new(WhatsAppApp::new)); + let first = picked("first.pdf", "application/pdf"); + let second = picked("second.mp4", "video/mp4"); + + app.update(cx, |app, cx| { + app.finish_attaching( + "peer@example.invalid", + None, + Ok(crate::platform::picker::Chosen { + files: vec![first.clone()], + refused: Vec::new(), + }), + cx, + ); + app.finish_attaching( + "peer@example.invalid", + None, + Ok(crate::platform::picker::Chosen { + files: vec![second.clone()], + refused: Vec::new(), + }), + cx, + ); + }); + + cx.read(|cx| { + let app = app.read(cx); + assert_eq!( + app.paste_preview.as_ref().unwrap().files[0].file_name, + first.file_name + ); + assert_eq!(app.pending_attachment_previews.len(), 1); + }); + + app.update(cx, |app, cx| { + assert!(app.cancel_paste_preview(cx)); + }); + cx.read(|cx| { + let app = app.read(cx); + assert_eq!( + app.paste_preview.as_ref().unwrap().files[0].file_name, + second.file_name + ); + assert!(app.attachment_attempts.is_empty()); + }); + } + + #[gpui::test] + fn multi_file_choice_waits_for_one_explicit_confirmation(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let app = cx.update(|cx| cx.new(WhatsAppApp::new)); + let files = vec![ + picked("first.pdf", "application/pdf"), + picked("second.mp4", "video/mp4"), + ]; + let reply = crate::components::ReplyDraft { + message_id: "QUOTED-1".into(), + sender: "peer@example.invalid".into(), + sender_name: "Peer".into(), + preview: "Earlier message".into(), + kind: None, + }; + + app.update(cx, |app, cx| { + app.reply_to = Some(reply.clone()); + app.finish_attaching( + "original-chat@example.invalid", + Some(reply.clone()), + Ok(crate::platform::picker::Chosen { + files: files.clone(), + refused: vec!["third.bin was too large".into()], + }), + cx, + ); + assert!(app.attachment_attempts.is_empty()); + let preview = app.paste_preview.as_ref().expect("choice opens preview"); + assert_eq!(preview.jid, "original-chat@example.invalid"); + assert_eq!(preview.files.len(), 2); + assert_eq!(preview.files[0].file_name, "first.pdf"); + assert_eq!(preview.files[1].file_name, "second.mp4"); + assert_eq!(app.reply_to.as_ref().unwrap().message_id, "QUOTED-1"); + }); + + // Switching chats cannot redirect what the picker originally chose. + app.update(cx, |app, cx| { + app.selected_chat = Some("other-chat@example.invalid".into()); + app.confirm_paste_preview(cx); + app.confirm_paste_preview(cx); + assert!(app.paste_preview.is_none()); + assert_eq!(app.attachment_attempts.len(), 2); + assert_eq!(app.attachment_attempts[0].file_name, "first.pdf"); + assert_eq!(app.attachment_attempts[1].file_name, "second.mp4"); + assert!(app.reply_to.is_none()); + }); + } + + #[gpui::test] + fn refusal_only_and_cancel_never_send_or_consume_reply(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let app = cx.update(|cx| cx.new(WhatsAppApp::new)); + let reply = crate::components::ReplyDraft { + message_id: "QUOTED-2".into(), + sender: "peer@example.invalid".into(), + sender_name: "Peer".into(), + preview: "Earlier message".into(), + kind: None, + }; + app.update(cx, |app, cx| { + app.reply_to = Some(reply.clone()); + app.finish_attaching( + "peer@example.invalid", + Some(reply.clone()), + Ok(crate::platform::picker::Chosen { + files: Vec::new(), + refused: vec!["oversized.pdf was too large".into()], + }), + cx, + ); + assert!(app.paste_preview.is_none()); + assert!(app.attachment_attempts.is_empty()); + assert_eq!(app.reply_to.as_ref().unwrap().message_id, "QUOTED-2"); + + app.finish_attaching( + "peer@example.invalid", + Some(reply), + Ok(crate::platform::picker::Chosen { + files: vec![picked("safe.pdf", "application/pdf")], + refused: Vec::new(), + }), + cx, + ); + assert!(app.cancel_paste_preview(cx)); + assert!(app.attachment_attempts.is_empty()); + assert_eq!(app.reply_to.as_ref().unwrap().message_id, "QUOTED-2"); + }); + } } diff --git a/crates/gui/src/app/calls_ctl.rs b/crates/gui/src/app/calls_ctl.rs index 6a7b0a78..ef581729 100644 --- a/crates/gui/src/app/calls_ctl.rs +++ b/crates/gui/src/app/calls_ctl.rs @@ -1024,6 +1024,13 @@ impl WhatsAppApp { log::debug!("keyboard: {:?} -> {wanted:?}", self.keyboard_owner); match &wanted { KeyboardOwner::RingingCall(_) => window.focus(&self.call_focus, cx), + KeyboardOwner::MessageDelete => window.focus(&self.message_delete_focus, cx), + KeyboardOwner::MessageEdit => { + if let Some(draft) = self.edit_draft.as_ref() { + let focus = draft.input.read(cx).focus_handle(cx); + window.focus(&focus, cx); + } + } KeyboardOwner::PastePreview => window.focus(&self.paste_preview_focus, cx), KeyboardOwner::Viewer => { let handle = self.viewer.read(cx).focus().clone(); @@ -1140,6 +1147,8 @@ fn keyboard_owner_for( let composing = intent == ChatOpen::ToCompose; match ringing_call.filter(|_| surfaces.call_card) { Some(call_id) => KeyboardOwner::RingingCall(call_id), + None if surfaces.message_delete => KeyboardOwner::MessageDelete, + None if surfaces.message_edit => KeyboardOwner::MessageEdit, None if surfaces.paste_preview => KeyboardOwner::PastePreview, None if surfaces.viewer => KeyboardOwner::Viewer, None if showing_settings => KeyboardOwner::Screen, @@ -1215,6 +1224,8 @@ mod keyboard_owner_tests { composer: false, viewer: false, paste_preview: false, + message_edit: false, + message_delete: false, call_card: false, }; @@ -1237,6 +1248,8 @@ mod keyboard_owner_tests { composer: true, viewer: true, paste_preview: true, + message_edit: false, + message_delete: false, call_card: true, }; assert_eq!( diff --git a/crates/gui/src/app/commands.rs b/crates/gui/src/app/commands.rs index 4355a2c2..2783d3d6 100644 --- a/crates/gui/src/app/commands.rs +++ b/crates/gui/src/app/commands.rs @@ -327,6 +327,12 @@ impl WhatsAppApp { self.decline_waiting_call(cx); return; } + if self.cancel_message_delete(cx) { + return; + } + if self.cancel_message_edit(cx) { + return; + } if self.cancel_paste_preview(cx) { return; } diff --git a/crates/gui/src/app/message_actions.rs b/crates/gui/src/app/message_actions.rs new file mode 100644 index 00000000..7b2dbc16 --- /dev/null +++ b/crates/gui/src/app/message_actions.rs @@ -0,0 +1,969 @@ +//! Sent-message actions, scoped to the chat and row the menu named. + +use super::*; +use gpui_component::ActiveTheme as _; +use gpui_component::FocusTrapElement as _; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::{Textarea, TextareaState}; + +pub(super) struct EditDraft { + pub jid: String, + pub message_id: String, + pub input: Entity, +} + +/// A deletion choice captured from one message's context menu. The request is +/// not made until the user confirms this exact target and scope. +pub(super) struct DeleteConfirmation { + pub jid: String, + pub message_id: String, + pub for_everyone: bool, + pub chat_name: String, + pub message_preview: String, + pub message_time: String, +} + +pub(crate) fn can_edit_sent(message: &ChatMessage, now_ms: i64) -> bool { + can_delete_sent(message, false, now_ms) + && message.media.is_none() + && message.system.is_none() + && !message.content.trim().is_empty() + && now_ms.saturating_sub(message.timestamp.timestamp_millis()) <= 15 * 60 * 1_000 +} + +pub(crate) fn can_delete_sent(message: &ChatMessage, for_everyone: bool, now_ms: i64) -> bool { + message.is_from_me + && !message.revoked + && message.system.is_none() + && message.status.has_left_this_device() + && (!for_everyone + || now_ms.saturating_sub(message.timestamp.timestamp_millis()) + <= 2 * 24 * 60 * 60 * 1_000) +} + +/// Apply only the daemon-acknowledged edit to the addressed own row. +fn apply_accepted_edit(chat: &mut Chat, message_id: &str, text: &str) { + let newest = chat + .messages + .last() + .is_some_and(|message| message.id == message_id); + if let Some(message) = chat + .messages + .iter_mut() + .find(|message| message.id == message_id && message.is_from_me) + { + message.content = text.to_owned(); + message.edited = true; + if newest { + chat.last_message = Some(text.to_owned()); + } + } +} + +impl WhatsAppApp { + pub(super) fn begin_message_edit( + &mut self, + jid: &str, + message_id: &str, + window: &mut Window, + cx: &mut Context, + ) { + if self.delete_confirmation.is_some() { + return; + } + let Some(message) = self + .find_chat(jid) + .filter(|_| self.selected_chat.as_deref() == Some(jid)) + .and_then(|chat| { + chat.messages + .iter() + .find(|message| message.id == message_id) + }) + else { + return; + }; + if !self.can_send() + || !can_edit_sent(message, wacore::time::now_millis()) + || self + .pending_message_actions + .contains(&(jid.to_owned(), message_id.to_owned())) + { + self.notify_user( + "This message can no longer be edited.", + notices::Tone::Problem, + cx, + ); + return; + } + let text = message.content.clone(); + let input = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(1, 5) + .placeholder("Edit message") + }); + input.update(cx, |input, cx| input.set_value(&text, window, cx)); + let focus = input.read(cx).focus_handle(cx); + self.edit_draft = Some(EditDraft { + jid: jid.to_owned(), + message_id: message_id.to_owned(), + input, + }); + window.focus(&focus, cx); + cx.notify(); + } + + pub(super) fn cancel_message_edit(&mut self, cx: &mut Context) -> bool { + if self.edit_draft.take().is_some() { + cx.notify(); + true + } else { + false + } + } + + pub(super) fn save_message_edit(&mut self, cx: &mut Context) { + let Some(draft) = self.edit_draft.as_ref() else { + return; + }; + let text = draft.input.read(cx).text().to_string(); + if text.trim().is_empty() { + self.notify_user( + "An edited message cannot be empty.", + notices::Tone::Problem, + cx, + ); + return; + } + let jid = draft.jid.clone(); + let message_id = draft.message_id.clone(); + let key = (jid.clone(), message_id.clone()); + if self.pending_message_actions.contains(&key) { + return; + } + let eligible = self + .find_chat(&jid) + .and_then(|chat| { + chat.messages + .iter() + .find(|message| message.id == message_id) + }) + .is_some_and(|message| can_edit_sent(message, wacore::time::now_millis())); + if !self.can_send() || !eligible { + self.notify_user( + "This message can no longer be edited.", + notices::Tone::Problem, + cx, + ); + return; + } + self.pending_message_actions.insert(key.clone()); + let Some(client) = self.client.as_ref() else { + self.pending_message_actions.remove(&key); + self.notify_user( + "Could not edit message: the daemon is unavailable.", + notices::Tone::Problem, + cx, + ); + return; + }; + let answer = client.edit_message(jid, message_id, text.clone()); + cx.spawn(async move |entity: WeakEntity, cx| { + let result = answer.await; + let _ = + entity.update(cx, |app, cx| { + app.pending_message_actions.remove(&key); + match result { + Ok(Ok(())) => { + if let Some(chat) = app.find_chat_mut(&key.0) { + apply_accepted_edit(chat, &key.1, &text); + } + app.invalidate_message_cache(&key.0, cx); + app.invalidate_chat_cache(); + if app.edit_draft.as_ref().is_some_and(|draft| { + draft.jid == key.0 && draft.message_id == key.1 + }) { + app.edit_draft = None; + } + cx.notify(); + } + Ok(Err(failure)) => app.notify_user( + what_went_wrong("Could not edit message", &failure), + notices::Tone::Problem, + cx, + ), + Err(_) => app.notify_user( + "Could not edit message: the daemon connection ended.", + notices::Tone::Problem, + cx, + ), + } + }); + }) + .detach(); + } + + pub(super) fn delete_sent_message( + &mut self, + jid: &str, + message_id: &str, + for_everyone: bool, + cx: &mut Context, + ) { + let key = (jid.to_owned(), message_id.to_owned()); + if self.pending_message_actions.contains(&key) { + return; + } + let eligible = self + .find_chat(jid) + .filter(|_| self.selected_chat.as_deref() == Some(jid)) + .and_then(|chat| { + chat.messages + .iter() + .find(|message| message.id == message_id) + }) + .is_some_and(|message| { + can_delete_sent(message, for_everyone, wacore::time::now_millis()) + }); + if !self.can_send() || !eligible { + self.notify_user( + "This message can no longer be deleted.", + notices::Tone::Problem, + cx, + ); + return; + } + #[cfg(test)] + self.delete_attempts + .push((jid.to_owned(), message_id.to_owned(), for_everyone)); + self.pending_message_actions.insert(key.clone()); + let Some(client) = self.client.as_ref() else { + self.pending_message_actions.remove(&key); + self.notify_user( + "Could not delete message: the daemon is unavailable.", + notices::Tone::Problem, + cx, + ); + return; + }; + let answer = client.revoke_message(jid.to_owned(), message_id.to_owned(), for_everyone); + cx.spawn(async move |entity: WeakEntity, cx| { + let result = answer.await; + let _ = entity.update(cx, |app, cx| { + app.pending_message_actions.remove(&key); + match result { + Ok(Ok(())) => { + if let Some(chat) = app.find_chat_mut(&key.0) { + if for_everyone { + let newest = chat.messages.last().is_some_and(|m| m.id == key.1); + if let Some(message) = + chat.messages.iter_mut().find(|m| m.id == key.1) + { + message.revoked = true; + message.content = "[Message deleted]".to_owned(); + message.media = None; + message.quoted = None; + if newest { + chat.last_message = Some("[Message deleted]".to_owned()); + } + } + } else { + chat.remove_message_for_me(&key.1); + } + } + app.invalidate_message_cache(&key.0, cx); + app.invalidate_chat_cache(); + cx.notify(); + } + Ok(Err(failure)) => app.notify_user( + what_went_wrong("Could not delete message", &failure), + notices::Tone::Problem, + cx, + ), + Err(_) => app.notify_user( + "Could not delete message: the daemon connection ended.", + notices::Tone::Problem, + cx, + ), + } + }); + }) + .detach(); + } + + pub(super) fn begin_message_delete( + &mut self, + jid: &str, + message_id: &str, + for_everyone: bool, + cx: &mut Context, + ) { + if self.delete_confirmation.is_some() + || self.edit_draft.is_some() + || self.paste_preview.is_some() + { + return; + } + let Some(chat) = self + .find_chat(jid) + .filter(|_| self.selected_chat.as_deref() == Some(jid)) + else { + return; + }; + let Some(message) = chat + .messages + .iter() + .find(|message| message.id == message_id) + else { + return; + }; + if !self.can_send() + || !can_delete_sent(message, for_everyone, wacore::time::now_millis()) + || self + .pending_message_actions + .contains(&(jid.to_owned(), message_id.to_owned())) + { + self.notify_user( + "This message can no longer be deleted.", + notices::Tone::Problem, + cx, + ); + return; + } + let preview = message.preview_text(); + let message_preview: String = preview.chars().take(120).collect(); + self.delete_confirmation = Some(DeleteConfirmation { + jid: jid.to_owned(), + message_id: message_id.to_owned(), + for_everyone, + chat_name: if chat.name.trim().is_empty() { + jid.to_owned() + } else { + chat.name.clone() + }, + message_preview, + message_time: crate::utils::format_time_local(&message.timestamp), + }); + cx.notify(); + } + + pub(super) fn cancel_message_delete(&mut self, cx: &mut Context) -> bool { + if self.delete_confirmation.take().is_some() { + cx.notify(); + true + } else { + false + } + } + + pub(super) fn confirm_message_delete(&mut self, cx: &mut Context) { + let Some(confirmation) = self.delete_confirmation.take() else { + return; + }; + cx.notify(); + self.delete_sent_message( + &confirmation.jid, + &confirmation.message_id, + confirmation.for_everyone, + cx, + ); + } +} + +pub(super) fn render_message_delete( + confirmation: &DeleteConfirmation, + app: Entity, + focus: &FocusHandle, + cx: &App, +) -> impl IntoElement + use<> { + let metrics = cx.product().metrics; + let scope = if confirmation.for_everyone { + "Apagar para todos" + } else { + "Apagar para mim" + }; + let chat_name = &confirmation.chat_name; + let preview = &confirmation.message_preview; + let message_time = &confirmation.message_time; + let cancel = app.clone(); + div() + .id("message-delete-modal") + .debug_selector(|| "message-delete-modal".into()) + .track_focus(focus) + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .p(metrics.space_xxl()) + .bg(crate::components::parts::scrim(cx).opacity(0.92)) + .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + div() + .w_full() + .max_w(metrics.bubble_max_width()) + .p(metrics.space_xxl()) + .rounded(metrics.radius_lg()) + .bg(cx.theme().background) + .flex() + .flex_col() + .gap(metrics.space_lg()) + .child( + div() + .id("message-delete-scope") + .debug_selector(|| "message-delete-scope".into()) + .child(scope), + ) + .child( + div() + .id("message-delete-chat") + .debug_selector(|| "message-delete-chat".into()) + .child(format!("Conversa: {chat_name}")), + ) + .child( + div() + .id("message-delete-preview") + .debug_selector(|| "message-delete-preview".into()) + .child(format!("Mensagem: {preview}")), + ) + .child( + div() + .id("message-delete-time") + .debug_selector(|| "message-delete-time".into()) + .child(format!("Horário: {message_time}")), + ) + .child("Confirmar exclusão desta mensagem?") + .child( + div() + .flex() + .justify_end() + .gap(metrics.space_md()) + .child( + div() + .id("message-delete-cancel") + .debug_selector(|| "message-delete-cancel".into()) + .child( + Button::new("message-delete-cancel-button") + .label("Cancelar") + .on_click(move |_, _, cx| { + cancel.update(cx, |app, cx| { + app.cancel_message_delete(cx); + }); + }), + ), + ) + .child( + div() + .id("message-delete-confirm") + .debug_selector(|| "message-delete-confirm".into()) + .child( + Button::new("message-delete-confirm-button") + .label(scope) + .danger() + .on_click(move |_, _, cx| { + app.update(cx, |app, cx| { + app.confirm_message_delete(cx) + }); + }), + ), + ), + ), + ) + .focus_trap("message-delete-trap", focus) +} + +pub(super) fn render_message_edit( + draft: &EditDraft, + app: Entity, + cx: &App, +) -> impl IntoElement + use<> { + let metrics = cx.product().metrics; + let input = draft.input.clone(); + let focus = input.read(cx).focus_handle(cx); + let cancel = app.clone(); + div() + .id("message-edit-modal") + .debug_selector(|| "message-edit-modal".into()) + .track_focus(&focus) + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .p(metrics.space_xxl()) + .bg(crate::components::parts::scrim(cx).opacity(0.92)) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + div() + .w_full() + .max_w(metrics.bubble_max_width()) + .p(metrics.space_xxl()) + .rounded(metrics.radius_lg()) + .bg(cx.theme().background) + .flex() + .flex_col() + .gap(metrics.space_lg()) + .child("Edit message") + .child(Textarea::new(&input).w_full()) + .child( + div() + .flex() + .justify_end() + .gap(metrics.space_md()) + .child( + div() + .id("message-edit-cancel") + .debug_selector(|| "message-edit-cancel".into()) + .child( + Button::new("message-edit-cancel-button") + .label("Cancel") + .on_click(move |_, _, cx| { + cancel.update(cx, |app, cx| { + app.cancel_message_edit(cx); + }); + }), + ), + ) + .child( + div() + .id("message-edit-save") + .debug_selector(|| "message-edit-save".into()) + .child( + Button::new("message-edit-save-button") + .label("Save") + .primary() + .on_click(move |_, _, cx| { + app.update(cx, |app, cx| app.save_message_edit(cx)); + }), + ), + ), + ), + ) + .focus_trap("message-edit-trap", &focus) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + fn delete_fixture( + cx: &mut gpui::TestAppContext, + ) -> (gpui::VisualTestContext, Entity) { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + init_app_bindings(cx); + }); + let mut app_entity = None; + let window = cx.open_window(gpui::size(gpui::px(1000.), gpui::px(800.)), |window, cx| { + let app = cx.new(|cx| { + let mut app = WhatsAppApp::new(cx); + app.app_state = AppState::Connected; + app.destination = Destination::Chats; + let mut chat = Chat::new("peer@example.invalid".into()); + chat.name = "Test chat".into(); + chat.add_message(sent(wacore::time::now_millis())); + let mut second = sent(wacore::time::now_millis()); + second.id = "SENT-2".into(); + second.content = "second message".into(); + chat.add_message(second); + app.chats.push(Arc::new(chat)); + let mut other = Chat::new("other@example.invalid".into()); + other.add_message(sent(wacore::time::now_millis())); + app.chats.push(Arc::new(other)); + app + }); + app_entity = Some(app.clone()); + gpui_component::Root::new(app, window, cx) + }); + let app = app_entity.unwrap(); + let mut cx = gpui::VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + app.update(cx, |app, cx| { + app.select_chat( + "peer@example.invalid".into(), + ChatOpen::ToCompose, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (cx, app) + } + + fn sent(now_ms: i64) -> ChatMessage { + let mut message = ChatMessage::new_outgoing("SENT-1".into(), "hello".into()); + message.timestamp = chrono::Utc.timestamp_millis_opt(now_ms).unwrap(); + message.status = MessageStatus::Sent; + message + } + + #[test] + fn edit_and_delete_windows_are_independent() { + let now = 1_700_000_000_000; + let mut message = sent(now - 14 * 60 * 1_000); + assert!(can_edit_sent(&message, now)); + assert!(can_delete_sent(&message, true, now)); + message.timestamp = chrono::Utc + .timestamp_millis_opt(now - 16 * 60 * 1_000) + .unwrap(); + assert!(!can_edit_sent(&message, now)); + assert!(can_delete_sent(&message, true, now)); + message.timestamp = chrono::Utc + .timestamp_millis_opt(now - 3 * 24 * 60 * 60 * 1_000) + .unwrap(); + assert!(!can_delete_sent(&message, true, now)); + assert!(can_delete_sent(&message, false, now)); + } + + #[test] + fn unsent_incoming_media_and_revoked_messages_are_ineligible() { + let now = 1_700_000_000_000; + let mut message = sent(now); + message.status = MessageStatus::Pending; + assert!(!can_edit_sent(&message, now)); + message.status = MessageStatus::Sent; + message.is_from_me = false; + assert!(!can_delete_sent(&message, false, now)); + message.is_from_me = true; + message.revoked = true; + assert!(!can_delete_sent(&message, false, now)); + } + + #[test] + fn accepted_edit_marks_only_the_addressed_own_message_in_direct_or_group_chat() { + for jid in ["peer@example.invalid", "120363000000000001@g.us"] { + let now = 1_700_000_000_000; + let mut chat = Chat::new(jid.into()); + chat.add_message(sent(now)); + let mut other = sent(now); + other.id = "SENT-2".into(); + chat.add_message(other); + chat.add_message(ChatMessage::new_incoming( + "PEER-1".into(), + "peer@example.invalid".into(), + "peer text".into(), + )); + apply_accepted_edit(&mut chat, "SENT-1", "corrected"); + assert_eq!( + chat.messages + .iter() + .find(|m| m.id == "SENT-1") + .unwrap() + .content, + "corrected" + ); + assert!( + chat.messages + .iter() + .find(|m| m.id == "SENT-1") + .unwrap() + .edited + ); + assert!( + !chat + .messages + .iter() + .find(|m| m.id == "SENT-2") + .unwrap() + .edited + ); + assert!( + !chat + .messages + .iter() + .find(|m| m.id == "PEER-1") + .unwrap() + .edited + ); + apply_accepted_edit(&mut chat, "PEER-1", "must not change"); + assert_eq!( + chat.messages + .iter() + .find(|m| m.id == "PEER-1") + .unwrap() + .content, + "peer text" + ); + } + } + + #[gpui::test] + fn delete_confirmation_identifies_target_and_cancel_never_attempts_revoke( + cx: &mut gpui::TestAppContext, + ) { + let (mut cx, app) = delete_fixture(cx); + cx.update(|_window, cx| { + app.update(cx, |app, cx| { + app.begin_message_delete("peer@example.invalid", "SENT-2", true, cx); + assert_eq!(app.delete_attempts.len(), 0); + assert_eq!( + app.find_chat("peer@example.invalid") + .unwrap() + .messages + .len(), + 2 + ); + }); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("message-delete-modal").is_some()); + assert!(cx.debug_bounds("message-delete-scope").is_some()); + assert!(cx.debug_bounds("message-delete-chat").is_some()); + assert!(cx.debug_bounds("message-delete-preview").is_some()); + assert!(cx.debug_bounds("message-delete-time").is_some()); + cx.read(|cx| { + let app = app.read(cx); + let confirmation = app.delete_confirmation.as_ref().unwrap(); + assert_eq!(confirmation.jid, "peer@example.invalid"); + assert_eq!(confirmation.message_id, "SENT-2"); + assert!(confirmation.for_everyone); + assert_eq!(confirmation.chat_name, "Test chat"); + assert_eq!(confirmation.message_preview, "second message"); + assert_eq!( + confirmation.message_time, + crate::utils::format_time_local( + &app.find_chat("peer@example.invalid").unwrap().messages[1].timestamp + ) + ); + assert_eq!(app.keyboard_owner, Some(KeyboardOwner::MessageDelete)); + }); + let cancel = cx.debug_bounds("message-delete-cancel").unwrap(); + cx.simulate_click(cancel.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + cx.read(|cx| { + let app = app.read(cx); + assert!(app.delete_confirmation.is_none()); + assert!(app.delete_attempts.is_empty()); + assert_eq!( + app.find_chat("peer@example.invalid").unwrap().messages[1].content, + "second message" + ); + }); + } + + #[gpui::test] + fn delete_confirmation_escape_and_chat_switch_send_nothing(cx: &mut gpui::TestAppContext) { + let (mut cx, app) = delete_fixture(cx); + cx.update(|_window, cx| { + app.update(cx, |app, cx| { + app.begin_message_delete("peer@example.invalid", "SENT-1", false, cx); + }); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.read(|cx| { + let app = app.read(cx); + assert!(app.delete_confirmation.is_none()); + assert!(app.delete_attempts.is_empty()); + assert_eq!(app.keyboard_owner, Some(KeyboardOwner::Composer)); + }); + cx.update(|window, cx| { + app.update(cx, |app, cx| { + app.begin_message_delete("peer@example.invalid", "SENT-1", false, cx); + app.select_chat( + "other@example.invalid".into(), + ChatOpen::ToCompose, + window, + cx, + ); + app.confirm_message_delete(cx); + assert!(app.delete_confirmation.is_none()); + assert!(app.delete_attempts.is_empty()); + }); + }); + } + + #[gpui::test] + fn delete_confirmation_submits_each_scope_once_to_exact_message(cx: &mut gpui::TestAppContext) { + let (mut cx, app) = delete_fixture(cx); + for (id, for_everyone) in [("SENT-1", false), ("SENT-2", true)] { + cx.update(|_window, cx| { + app.update(cx, |app, cx| { + app.begin_message_delete("peer@example.invalid", id, for_everyone, cx); + assert!(app.delete_attempts.len() <= 1); + }); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let confirm = cx.debug_bounds("message-delete-confirm").unwrap(); + cx.simulate_click(confirm.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + cx.simulate_click(confirm.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + } + cx.read(|cx| { + let app = app.read(cx); + assert_eq!( + app.delete_attempts, + vec![ + ("peer@example.invalid".into(), "SENT-1".into(), false), + ("peer@example.invalid".into(), "SENT-2".into(), true), + ] + ); + assert_eq!( + app.find_chat("peer@example.invalid") + .unwrap() + .messages + .len(), + 2 + ); + assert!(app.notices().read(cx).has_problem("daemon is unavailable")); + }); + } + + #[gpui::test] + fn stale_or_busy_delete_confirmation_cannot_attempt_revoke(cx: &mut gpui::TestAppContext) { + let (mut cx, app) = delete_fixture(cx); + cx.update(|_window, cx| { + app.update(cx, |app, cx| { + app.begin_message_delete("peer@example.invalid", "SENT-1", true, cx); + // A second action cannot replace the first modal's target. + app.begin_message_delete("peer@example.invalid", "SENT-2", false, cx); + assert_eq!( + app.delete_confirmation.as_ref().unwrap().message_id, + "SENT-1" + ); + app.pending_message_actions + .insert(("peer@example.invalid".into(), "SENT-1".into())); + app.confirm_message_delete(cx); + app.confirm_message_delete(cx); + assert!(app.delete_attempts.is_empty()); + assert!(app.delete_confirmation.is_none()); + }); + }); + } + + #[gpui::test] + fn deleted_or_expired_target_is_rechecked_after_confirmation_opens( + cx: &mut gpui::TestAppContext, + ) { + let (mut cx, app) = delete_fixture(cx); + cx.update(|_window, cx| { + app.update(cx, |app, cx| { + app.begin_message_delete("peer@example.invalid", "SENT-1", true, cx); + app.find_chat_mut("peer@example.invalid") + .unwrap() + .messages + .retain(|message| message.id != "SENT-1"); + app.confirm_message_delete(cx); + assert!(app.delete_attempts.is_empty()); + assert!(app.delete_confirmation.is_none()); + + app.begin_message_delete("peer@example.invalid", "SENT-2", true, cx); + app.find_chat_mut("peer@example.invalid") + .unwrap() + .messages + .iter_mut() + .find(|message| message.id == "SENT-2") + .unwrap() + .timestamp = chrono::Utc + .timestamp_millis_opt(wacore::time::now_millis() - 3 * 24 * 60 * 60 * 1_000) + .unwrap(); + app.confirm_message_delete(cx); + assert!(app.delete_attempts.is_empty()); + assert_eq!( + app.find_chat("peer@example.invalid").unwrap().messages[0].content, + "second message" + ); + }); + }); + } + + #[gpui::test] + fn edit_modal_cancel_keeps_the_composer_draft(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + init_app_bindings(cx); + }); + let mut app_entity = None; + let window = cx.open_window(gpui::size(gpui::px(1000.), gpui::px(800.)), |window, cx| { + let app = cx.new(|cx| { + let mut app = WhatsAppApp::new(cx); + app.app_state = AppState::Connected; + app.destination = Destination::Chats; + let mut chat = Chat::new("peer@example.invalid".into()); + chat.add_message(sent(wacore::time::now_millis())); + app.chats.push(Arc::new(chat)); + app + }); + app_entity = Some(app.clone()); + gpui_component::Root::new(app, window, cx) + }); + let app = app_entity.unwrap(); + let mut cx = gpui::VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + app.update(cx, |app, cx| { + app.select_chat( + "peer@example.invalid".into(), + ChatOpen::ToCompose, + window, + cx, + ); + app.input_area.as_ref().unwrap().update(cx, |input, cx| { + input.swap_text("unsent composer draft", window, cx); + }); + app.begin_message_edit("peer@example.invalid", "SENT-1", window, cx); + }); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("message-edit-modal").is_some()); + assert!(cx.debug_bounds("message-edit-save").is_some()); + let cancel = cx.debug_bounds("message-edit-cancel").unwrap(); + cx.simulate_click(cancel.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + cx.read(|cx| assert!(app.read(cx).edit_draft.is_none())); + cx.read(|cx| { + assert!( + !app.read(cx) + .find_chat("peer@example.invalid") + .unwrap() + .messages[0] + .edited + ); + }); + cx.update(|window, cx| { + let composer = app.read(cx).input_area.as_ref().unwrap().clone(); + let draft = composer.update(cx, |input, cx| { + input.swap_text("unsent composer draft", window, cx) + }); + assert_eq!(draft, "unsent composer draft"); + }); + + // A rejected Save must keep the edit visible and must not claim that + // the replacement reached the server. This fixture has no daemon. + cx.update(|window, cx| { + app.update(cx, |app, cx| { + app.begin_message_edit("peer@example.invalid", "SENT-1", window, cx); + }); + let input = app.read(cx).edit_draft.as_ref().unwrap().input.clone(); + input.update(cx, |input, cx| input.set_value("replacement", window, cx)); + window.draw(cx).clear(cx); + }); + let save = cx.debug_bounds("message-edit-save").unwrap(); + cx.simulate_click(save.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + cx.read(|cx| { + let app = app.read(cx); + assert!(app.edit_draft.is_some()); + assert_eq!( + app.find_chat("peer@example.invalid").unwrap().messages[0].content, + "hello" + ); + assert!(!app.find_chat("peer@example.invalid").unwrap().messages[0].edited); + }); + } +} diff --git a/crates/gui/src/app/messages.rs b/crates/gui/src/app/messages.rs index 7413cce1..39f1b489 100644 --- a/crates/gui/src/app/messages.rs +++ b/crates/gui/src/app/messages.rs @@ -114,6 +114,8 @@ pub struct BubbleIds { pub react: SharedString, pub reply: SharedString, pub copy: SharedString, + /// The optional edited indicator beside the time. + pub edited: SharedString, /// The button a failed send grows. pub retry: SharedString, } @@ -128,6 +130,7 @@ impl BubbleIds { react: format!("react-{id}").into(), reply: format!("reply-{id}").into(), copy: format!("copy-{id}").into(), + edited: format!("edited-{id}").into(), retry: format!("retry-{id}").into(), } } @@ -263,8 +266,56 @@ fn build_items(messages: &[ChatMessage], typing: Option) -> Vec, + rows: Vec<(String, bool, ChatMessage)>, + } + + impl gpui::Render for EditedBubbleFixture { + fn render( + &mut self, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let layout = ResponsiveLayout::new( + window.viewport_size(), + MobilePanel::Chat, + cx.product().metrics, + ); + gpui::div() + .size_full() + .flex() + .flex_col() + .children(self.rows.iter().cloned().map(|(jid, is_group, message)| { + let props = BubbleProps { + chat_jid: jid, + ids: BubbleIds::of(&message), + text: BubbleText::of(&message.content), + message: Arc::new(message), + playing_message_id: None, + is_group, + is_own_number: false, + starts_run: true, + video_player_state: None, + video_frame: None, + decoded_image: None, + audio: None, + playback_speed: 1.0, + is_downloading: false, + }; + render_message_bubble(props, self.app.clone(), layout, cx) + })) + } + } fn at(day: u32, hour: u32) -> DateTime { Utc.with_ymd_and_hms(2026, 3, day, hour, 0, 0).unwrap() @@ -485,6 +536,69 @@ mod tests { assert!(!cache.is_valid_for(1, true, Some(&typing(&["Ana", "Marcos"])))); } + #[gpui::test] + fn edited_marker_renders_only_on_exact_own_and_peer_rows_in_direct_and_group_chats( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let mut rows = Vec::new(); + for (jid, prefix) in [(fixtures::PEER, "D"), (fixtures::GROUP, "G")] { + let is_group = prefix == "G"; + let mut own = fixtures::outgoing(&format!("{prefix}-OWN"), "corrected"); + own.status = MessageStatus::Sent; + own.edited = true; + let mut peer = fixtures::message( + &format!("{prefix}-PEER"), + fixtures::PEER, + "corrected by peer", + ); + peer.edited = true; + peer.sender_name = Some("Test peer".into()); + let mut revoked = fixtures::message( + &format!("{prefix}-REVOKED"), + fixtures::PEER, + "[Message deleted]", + ); + revoked.edited = true; + revoked.revoked = true; + rows.push((jid.into(), is_group, own)); + rows.push((jid.into(), is_group, peer)); + rows.push(( + jid.into(), + is_group, + fixtures::message(&format!("{prefix}-PLAIN"), fixtures::PEER, "not edited"), + )); + rows.push((jid.into(), is_group, revoked)); + } + let window = cx.open_window(gpui::size(gpui::px(1000.), gpui::px(800.)), |window, cx| { + let app = cx.new(WhatsAppApp::new); + let fixture = cx.new(|_| EditedBubbleFixture { app, rows }); + gpui_component::Root::new(fixture, window, cx) + }); + let mut cx = gpui::VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + for edited in [ + "edited-D-OWN", + "edited-D-PEER", + "edited-G-OWN", + "edited-G-PEER", + ] { + assert!(cx.debug_bounds(edited).is_some(), "{edited}"); + } + for unmarked in [ + "edited-D-PLAIN", + "edited-D-REVOKED", + "edited-G-PLAIN", + "edited-G-REVOKED", + ] { + assert!(cx.debug_bounds(unmarked).is_none(), "{unmarked}"); + } + } + fn typing(names: &[&str]) -> TypingSummary { TypingSummary { typists: names diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index 65e50bce..ca420116 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -18,6 +18,7 @@ mod events; mod frame_cost; mod media; mod media_ctl; +mod message_actions; mod messages; pub mod notices; mod paging; @@ -38,6 +39,7 @@ pub use chats::{ ChatFilter, ChatListCache, Survival, survives_archived_scan, survives_complete_load, }; pub use media::RecordingState; +pub(crate) use message_actions::{can_delete_sent, can_edit_sent}; pub use messages::{BubbleIds, MessageListCache, TimelineItem}; pub use paging::nearing_end; @@ -125,6 +127,10 @@ enum KeyboardOwner { /// A call that is ringing — not one that has been answered, which is a /// call people type through. RingingCall(String), + /// A sent message's replacement text, in its own modal field. + MessageEdit, + /// An addressed sent-message deletion awaiting final confirmation. + MessageDelete, /// An image pasted into the composer, waiting for explicit confirmation. PastePreview, /// The fullscreen viewer, which owns the arrow keys while it is up. @@ -156,17 +162,24 @@ pub struct KeyboardSurfaces { /// `leave_connected_view` does not close it — while the error screen that /// replaces the conversation draws nothing of it. pub viewer: bool, - /// The modal preview for a pasted image. + /// The modal preview for a pending attachment selection. pub paste_preview: bool, + pub message_edit: bool, + pub message_delete: bool, /// The call card, which only the connected screens float. pub call_card: bool, } +/// Files held behind the shared attachment confirmation surface. +/// +/// Pasted images were the first source to gain this boundary, so the state +/// keeps its historical name. Picker and drop inputs use the same owner and +/// consume-once confirmation path rather than creating a second modal flow. struct PendingPastePreview { jid: String, reply: Option, - file: crate::platform::picker::Picked, - image: Arc, + files: Vec, + images: Vec>>, /// Whether the captured destination was the conversation on screen before /// this modal deliberately hid it from read/paging accounting. chat_was_visible: bool, @@ -286,7 +299,7 @@ pub use status::{Destination, StatusPane}; pub use viewer::MediaViewer; use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use indexmap::IndexMap; @@ -363,8 +376,23 @@ pub struct RetryMessage { pub id: gpui::SharedString, } +#[derive(Clone, PartialEq, gpui::Action)] +#[action(namespace = message, no_json)] +pub struct EditSentMessage { + pub jid: gpui::SharedString, + pub id: gpui::SharedString, +} + +#[derive(Clone, PartialEq, gpui::Action)] +#[action(namespace = message, no_json)] +pub struct DeleteSentMessage { + pub jid: gpui::SharedString, + pub id: gpui::SharedString, + pub for_everyone: bool, +} + use crate::components::{ - AccountSummary, InputAreaEvent, InputAreaView, ReplyDraft, new_timeline_state, + AccountSummary, InputAreaEvent, InputAreaView, ReplyDraft, new_timeline_state, preview_images, render_paste_preview, }; use log::{debug, error, info, warn}; @@ -656,6 +684,10 @@ pub struct WhatsAppApp { /// Destination captured while an asynchronous clipboard read is pending. pending_pastes: HashMap)>, paste_preview: Option, + /// Accepted selections that completed while another confirmation was open. + /// Keeping these instead of dropping a late picker/drop result guarantees + /// every accepted attachment gets the same explicit confirmation surface. + pending_attachment_previews: VecDeque, #[cfg(test)] attachment_attempts: Vec, /// Scroll handle for chat list @@ -670,6 +702,8 @@ pub struct WhatsAppApp { call_focus: FocusHandle, /// Focus target for the pasted-image confirmation modal. paste_preview_focus: FocusHandle, + /// Focus target for the sent-message deletion confirmation modal. + message_delete_focus: FocusHandle, /// Focus target for the window itself, so the actions hung off the root /// are reachable whatever else is on screen — including on the screens on /// the way to a conversation, which have no list and no composer to @@ -900,6 +934,11 @@ pub struct WhatsAppApp { /// The message being replied to, mirrored here so the send path can /// attach it and the composer can show it. reply_to: Option, + edit_draft: Option, + delete_confirmation: Option, + pending_message_actions: HashSet<(String, String)>, + #[cfg(test)] + delete_attempts: Vec<(String, String, bool)>, /// Who is typing and who is around. Expires on its own, so it is view /// state rather than anything the store carries. presence: PresenceRegistry, @@ -1220,6 +1259,7 @@ impl WhatsAppApp { client: None, pending_pastes: HashMap::new(), paste_preview: None, + pending_attachment_previews: VecDeque::new(), #[cfg(test)] attachment_attempts: Vec::new(), chat_list_scroll: VirtualListScrollHandle::new(), @@ -1227,6 +1267,7 @@ impl WhatsAppApp { chat_list_focus: cx.focus_handle(), call_focus: cx.focus_handle(), paste_preview_focus: cx.focus_handle(), + message_delete_focus: cx.focus_handle(), root_focus: cx.focus_handle(), keyboard_owner: None, window_focused: false, @@ -1288,6 +1329,11 @@ impl WhatsAppApp { mobile_panel: MobilePanel::default(), chat_filter: ChatFilter::default(), reply_to: None, + edit_draft: None, + delete_confirmation: None, + pending_message_actions: HashSet::new(), + #[cfg(test)] + delete_attempts: Vec::new(), presence: PresenceRegistry::new(), account_name: None, account_jid: None, @@ -1861,6 +1907,8 @@ impl WhatsAppApp { self.leave_connected_view(cx); self.pending_pastes.clear(); self.paste_preview = None; + self.delete_confirmation = None; + self.pending_attachment_previews.clear(); self.notified_messages.clear(); // A call is account state as much as a chat is. See // [`calls_ctl::Calls::forget`]. @@ -1949,6 +1997,8 @@ impl WhatsAppApp { /// Not [`AppState::Offline`]: that keeps the conversation on screen and /// only refuses to send. fn leave_connected_view(&mut self, cx: &mut Context) { + self.edit_draft = None; + self.delete_confirmation = None; if self.recorder.read(cx).state() != RecordingState::Idle { self.cancel_recording(cx); } @@ -2411,6 +2461,10 @@ impl WhatsAppApp { window: &mut Window, cx: &mut Context, ) { + if self.selected_chat.as_deref() != Some(jid.as_str()) { + self.cancel_message_edit(cx); + self.cancel_message_delete(cx); + } self.stop_current_media(); // Leaving a chat mid-composition: release its typing indicator now, // or it would stay "typing..." and the eventual paused would land on @@ -2702,21 +2756,19 @@ impl WhatsAppApp { let Some((jid, reply)) = self.pending_pastes.remove(paste_id) else { return; }; - let Some(format) = gpui::ImageFormat::from_mime_type(&file.mime_type) else { - return; - }; - if self.paste_preview.is_none() { - let image = Arc::new(gpui::Image::from_bytes(format, file.bytes.clone())); - let chat_was_visible = self.visible_chat.as_deref() == Some(jid.as_str()); - self.paste_preview = Some(PendingPastePreview { + let files = vec![file]; + let images = preview_images(&files); + let chat_was_visible = self.visible_chat.as_deref() == Some(jid.as_str()); + self.queue_paste_preview( + PendingPastePreview { jid, reply, - file, - image, + files, + images, chat_was_visible, - }); - cx.notify(); - } + }, + cx, + ); } InputAreaEvent::PasteImageError(paste_id, error) => { if self.pending_pastes.remove(paste_id).is_some() { @@ -3709,6 +3761,12 @@ impl Render for WhatsAppApp { .on_action(cx.listener(|app, retry: &RetryMessage, window, cx| { app.retry_send(&retry.id, window, cx); })) + .on_action(cx.listener(|app, edit: &EditSentMessage, window, cx| { + app.begin_message_edit(&edit.jid, &edit.id, window, cx); + })) + .on_action(cx.listener(|app, delete: &DeleteSentMessage, _window, cx| { + app.begin_message_delete(&delete.jid, &delete.id, delete.for_everyone, cx); + })) .on_action(|copy: &CopyMessage, _window, cx| { cx.write_to_clipboard(gpui::ClipboardItem::new_string(copy.text.to_string())); }) @@ -3731,7 +3789,8 @@ impl Render for WhatsAppApp { let paste_preview = self.paste_preview.as_ref().map(|preview| { render_paste_preview( - preview.image.clone(), + &preview.files, + &preview.images, cx.entity().clone(), self.can_send(), &self.paste_preview_focus, @@ -3740,11 +3799,25 @@ impl Render for WhatsAppApp { ) .into_any_element() }); + let message_edit = self.edit_draft.as_ref().map(|draft| { + message_actions::render_message_edit(draft, cx.entity().clone(), cx).into_any_element() + }); + let message_delete = self.delete_confirmation.as_ref().map(|confirmation| { + message_actions::render_message_delete( + confirmation, + cx.entity().clone(), + &self.message_delete_focus, + cx, + ) + .into_any_element() + }); // The card is the one surface the root draws itself, so it is the // one the root answers for. let call_card = call_overlay.is_some(); let paste_preview_open = paste_preview.is_some(); + let message_edit_open = message_edit.is_some(); + let message_delete_open = message_delete.is_some(); // Above the call card as well as the body: a notice raised by // something the call did is about the call, and a card that covered @@ -3754,6 +3827,8 @@ impl Render for WhatsAppApp { // nothing at all while it is empty. root.child(body.cached(gpui::StyleRefinement::default().size_full())) .children(paste_preview) + .children(message_edit) + .children(message_delete) .children(call_overlay) .child(self.notices().clone()) // Cached views report their surfaces in prepaint. Move focus after @@ -3764,6 +3839,8 @@ impl Render for WhatsAppApp { entity.update(cx, |app, _| { app.keyboard_surfaces.call_card = call_card; app.keyboard_surfaces.paste_preview = paste_preview_open; + app.keyboard_surfaces.message_edit = message_edit_open; + app.keyboard_surfaces.message_delete = message_delete_open; }); let entity = entity.downgrade(); window.defer(cx, move |window, cx| { diff --git a/crates/gui/src/app/notices.rs b/crates/gui/src/app/notices.rs index 6e276bff..b1430c4d 100644 --- a/crates/gui/src/app/notices.rs +++ b/crates/gui/src/app/notices.rs @@ -103,6 +103,13 @@ impl Notices { } } + #[cfg(test)] + pub(super) fn has_problem(&self, text: &str) -> bool { + self.shown + .iter() + .any(|notice| notice.tone == Tone::Problem && notice.text.contains(text)) + } + /// Say one sentence to whoever is looking. /// /// The text is shown verbatim, so it is written for a reader rather than diff --git a/crates/gui/src/components/message_bubble/mod.rs b/crates/gui/src/components/message_bubble/mod.rs index d2bad6ac..5c626ec6 100644 --- a/crates/gui/src/components/message_bubble/mod.rs +++ b/crates/gui/src/components/message_bubble/mod.rs @@ -32,7 +32,8 @@ use quote::render_quote; use reactions::{render_hover_actions, render_reactions}; use crate::app::{ - BubbleIds, CopyMessage, OpenMessageLink, ReplyToMessage, RetryMessage, WhatsAppApp, + BubbleIds, CopyMessage, DeleteSentMessage, EditSentMessage, OpenMessageLink, ReplyToMessage, + RetryMessage, WhatsAppApp, can_delete_sent, can_edit_sent, }; use crate::components::parts; use crate::components::{BubbleText, bubble_status_ticks, render_rich_text}; @@ -44,6 +45,7 @@ use oxidezap_core::ChatMessage; /// Everything one bubble needs, gathered by the list. pub struct BubbleProps { + pub chat_jid: String, /// This row's element ids, formatted when the timeline was built. pub ids: BubbleIds, /// This row's text, parsed when the timeline was built. Travels with the @@ -117,6 +119,7 @@ pub fn render_message_bubble( let content = &props.text; let time: SharedString = format_time_local(&message.timestamp).into(); let status = message.delivery_in(props.is_own_number); + let edited = message.edited && !message.revoked; let is_playing = props.playing_message_id.as_deref() == Some(message_id.as_str()); let has_reactions = !message.reactions.is_empty(); @@ -151,6 +154,11 @@ pub fn render_message_bubble( let menu_id = message_id.clone(); let menu_text = message.content.clone(); let menu_failed = can_retry; + let menu_jid = props.chat_jid; + let now_ms = wacore::time::now_millis(); + let menu_edit = can_edit_sent(&message, now_ms); + let menu_delete_for_me = can_delete_sent(&message, false, now_ms); + let menu_delete_for_everyone = can_delete_sent(&message, true, now_ms); // A refcount, not a rescan: the row's text already parsed these when the // timeline was built, and the targets were shared then. let menu_links = content.link_targets().clone(); @@ -290,7 +298,15 @@ pub fn render_message_bubble( .child(render_rich_text(content, cx)), ) }) - .child(render_meta(time, status, is_from_me, metrics, cx)), + .child(render_meta( + time, + status, + is_from_me, + edited, + ids.edited.clone(), + metrics, + cx, + )), ), ), ) @@ -399,6 +415,35 @@ pub fn render_message_bubble( }), ); } + if menu_edit { + menu = menu.separator().menu( + "Edit", + Box::new(EditSentMessage { + jid: menu_jid.clone().into(), + id: menu_id.clone().into(), + }), + ); + } + if menu_delete_for_everyone { + menu = menu.separator().menu( + "Apagar para todos", + Box::new(DeleteSentMessage { + jid: menu_jid.clone().into(), + id: menu_id.clone().into(), + for_everyone: true, + }), + ); + } + if menu_delete_for_me { + menu = menu.menu( + "Apagar para mim", + Box::new(DeleteSentMessage { + jid: menu_jid.clone().into(), + id: menu_id.clone().into(), + for_everyone: false, + }), + ); + } if menu_failed { menu.separator().menu( "Send again", @@ -418,6 +463,8 @@ fn render_meta( time: SharedString, status: Option, is_from_me: bool, + edited: bool, + edited_id: SharedString, metrics: Metrics, cx: &App, ) -> impl IntoElement + use<> { @@ -439,6 +486,15 @@ fn render_meta( .flex_shrink_0() .items_center() .gap(metrics.space_xs()) + .children(edited.then(|| { + let selector = edited_id.clone(); + div() + .id(edited_id) + .debug_selector(move || selector.to_string()) + .text_size(metrics.text_micro()) + .text_color(colour) + .child("editada") + })) .child( div() .font_family(cx.theme().mono_font_family.clone()) diff --git a/crates/gui/src/components/message_list.rs b/crates/gui/src/components/message_list.rs index 3cc6ce9c..364b1da7 100644 --- a/crates/gui/src/components/message_list.rs +++ b/crates/gui/src/components/message_list.rs @@ -221,6 +221,7 @@ fn render_row( elapsed_secs: app.audio_elapsed_secs(), }); let props = BubbleProps { + chat_jid: app.selected_chat_jid().unwrap_or_default(), ids: ids.clone(), text: text.clone(), message: Arc::clone(msg), diff --git a/crates/gui/src/components/mod.rs b/crates/gui/src/components/mod.rs index e6341b66..5dc68598 100644 --- a/crates/gui/src/components/mod.rs +++ b/crates/gui/src/components/mod.rs @@ -34,7 +34,7 @@ pub use media_viewer::{ViewerProps, render_media_viewer}; pub use message_bubble::render_message_bubble; pub use message_list::{new_timeline_state, render_message_list}; pub use nav_rail::render_nav_rail; -pub use paste_preview::render_paste_preview; +pub use paste_preview::{preview_images, render_paste_preview}; pub use plugin_ui::PluginContext; pub use rich_text::{BubbleText, render_rich_text}; pub use status::{ diff --git a/crates/gui/src/components/paste_preview.rs b/crates/gui/src/components/paste_preview.rs index cd082eac..1383c7c3 100644 --- a/crates/gui/src/components/paste_preview.rs +++ b/crates/gui/src/components/paste_preview.rs @@ -1,20 +1,40 @@ -//! Confirmation surface for an image read from the clipboard. +//! Confirmation surface shared by clipboard, picker, and file-drop attachments. use std::sync::Arc; use gpui::{ App, Entity, FocusHandle, Image, ImageSource, InteractiveElement as _, IntoElement, ObjectFit, - ParentElement as _, Styled as _, StyledImage as _, div, img, + ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, + StyledImage as _, div, img, }; use gpui_component::button::{Button, ButtonVariants as _}; -use gpui_component::{Disableable as _, FocusTrapElement as _}; +use gpui_component::{ActiveTheme as _, Disableable as _, FocusTrapElement as _}; use crate::app::WhatsAppApp; use crate::components::parts; +use crate::platform::picker::{Picked, kind_for}; use crate::theme::Metrics; +use crate::utils::{format_size, mime_to_image_format}; + +/// Build the visual payloads for a pending attachment selection once, while +/// the confirmation surface owns the files. Videos, audio and documents do +/// not have a local still renderer here, so their card carries the identity +/// that will be sent instead of pretending a thumbnail exists. +pub fn preview_images(files: &[Picked]) -> Vec>> { + files + .iter() + .map(|file| { + (kind_for(&file.mime_type) == oxidezap_core::OutgoingMedia::Image) + .then(|| mime_to_image_format(&file.mime_type)) + .flatten() + .map(|format| Arc::new(Image::from_bytes(format, file.bytes.clone()))) + }) + .collect() +} pub fn render_paste_preview( - image: Arc, + files: &[Picked], + images: &[Option>], app: Entity, can_send: bool, focus_handle: &FocusHandle, @@ -40,7 +60,11 @@ pub fn render_paste_preview( div() .text_size(metrics.text_title()) .text_color(parts::on_scrim(cx)) - .child("Send this image?"), + .child(if files.len() == 1 { + "Send this attachment?" + } else { + "Send these attachments?" + }), ) .child( div() @@ -51,10 +75,13 @@ pub fn render_paste_preview( .flex() .items_center() .justify_center() - .child( - img(ImageSource::Image(image)) - .size_full() - .object_fit(ObjectFit::Contain), + .gap(metrics.space_lg()) + .flex_wrap() + .overflow_y_scroll() + .children( + files.iter().zip(images.iter()).map(|(file, image)| { + render_preview_item(file, image.as_ref(), metrics, cx) + }), ), ) .child( @@ -96,3 +123,136 @@ pub fn render_paste_preview( ) .focus_trap("paste-preview-trap", focus_handle) } + +fn render_file_card(file: &Picked, metrics: Metrics, cx: &App) -> impl IntoElement + use<> { + let kind = match kind_for(&file.mime_type) { + oxidezap_core::OutgoingMedia::Image => "Image", + oxidezap_core::OutgoingMedia::Video => "Video", + oxidezap_core::OutgoingMedia::Document => "Document", + }; + let name: SharedString = file.file_name.clone().into(); + let mime: SharedString = file.mime_type.clone().into(); + let size = format_size(file.bytes.len() as u64); + div() + .max_w_full() + .p(metrics.space_xl()) + .gap(metrics.space_sm()) + .flex() + .flex_col() + .items_center() + .bg(cx.theme().secondary) + .border_1() + .border_color(cx.theme().border) + .rounded(metrics.radius_lg()) + .child( + div() + .text_size(metrics.text_title()) + .text_color(cx.theme().foreground) + .child(kind), + ) + .child( + div() + .max_w_full() + .text_size(metrics.text_body()) + .text_color(cx.theme().foreground) + .overflow_hidden() + .child(name), + ) + .child( + div() + .max_w_full() + .text_size(metrics.text_small()) + .text_color(cx.theme().muted_foreground) + .overflow_hidden() + .child(format!("{mime} · {size}")), + ) +} + +fn render_preview_item( + file: &Picked, + image: Option<&Arc>, + metrics: Metrics, + cx: &App, +) -> gpui::AnyElement { + match image { + Some(image) => div() + .flex_1() + .min_w_0() + .min_h_0() + .flex() + .flex_col() + .gap(metrics.space_sm()) + .child( + img(ImageSource::Image(image.clone())) + .flex_1() + .min_h_0() + .size_full() + .object_fit(ObjectFit::Contain), + ) + .child(render_file_meta(file, metrics, cx)) + .into_any_element(), + None => render_file_card(file, metrics, cx).into_any_element(), + } +} + +fn render_file_meta(file: &Picked, metrics: Metrics, cx: &App) -> impl IntoElement + use<> { + let name: SharedString = file.file_name.clone().into(); + let detail = format!( + "{} · {}", + file.mime_type, + format_size(file.bytes.len() as u64) + ); + div() + .max_w_full() + .flex() + .flex_col() + .items_center() + .text_size(metrics.text_small()) + .text_color(cx.theme().foreground) + .overflow_hidden() + .child(name) + .child( + div() + .max_w_full() + .text_size(metrics.text_micro()) + .text_color(cx.theme().muted_foreground) + .overflow_hidden() + .child(detail), + ) +} + +#[cfg(test)] +mod tests { + use super::preview_images; + use crate::platform::picker::Picked; + + fn picked(file_name: &str, mime_type: &str) -> Picked { + Picked { + file_name: file_name.to_owned(), + mime_type: mime_type.to_owned(), + bytes: vec![1, 2, 3], + } + } + + #[test] + fn preview_payloads_keep_picker_order_and_media_identity() { + let files = vec![ + picked("photo.png", "image/png"), + picked("clip.mp4", "video/mp4"), + picked("notes.pdf", "application/pdf"), + ]; + + let images = preview_images(&files); + + assert_eq!(images.len(), files.len()); + assert!(images[0].is_some(), "supported image gets a thumbnail"); + assert!(images[1].is_none(), "video remains an identified file card"); + assert!( + images[2].is_none(), + "document remains an identified file card" + ); + assert_eq!(files[0].file_name, "photo.png"); + assert_eq!(files[1].file_name, "clip.mp4"); + assert_eq!(files[2].file_name, "notes.pdf"); + } +} diff --git a/crates/gui/src/components/rich_text.rs b/crates/gui/src/components/rich_text.rs index 0b24a7e7..b443eb28 100644 --- a/crates/gui/src/components/rich_text.rs +++ b/crates/gui/src/components/rich_text.rs @@ -11,13 +11,18 @@ //! string goes straight into a `div` with no highlight vector built and no //! second string allocated. +use std::cell::Cell; use std::ops::Range; +use std::rc::Rc; use std::sync::Arc; use gpui::{ - App, FontStyle, FontWeight, HighlightStyle, InteractiveText, IntoElement, SharedString, - StrikethroughStyle, StyledText, UnderlineStyle, + App, BorderStyle, Bounds, Corners, Edges, Element, ElementId, FontStyle, FontWeight, + GlobalElementId, HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, + LayoutId, MouseButton, MouseDownEvent, MouseUpEvent, PaintQuad, Pixels, Point, SharedString, + StrikethroughStyle, StyledText, UnderlineStyle, Window, transparent_black, }; +use gpui_base::{TextSelection, TextSelectionRegistration, TextSelectionRun}; use gpui_component::ActiveTheme as _; use crate::theme::ActiveProductTheme as _; @@ -112,9 +117,17 @@ pub fn render_rich_text(parsed: &BubbleText, cx: &App) -> gpui::AnyElement { return render_with_links(parsed, cx).into_any_element(); } if parsed.runs.is_empty() { - // Nothing to say about any range, so say nothing: `StyledText` with an - // empty highlight list still walks and allocates runs. - return parsed.text.clone().into_any_element(); + // The plain path participates in selection too. Layout and paint are + // still delegated to StyledText, so wrapping and inherited styling + // remain identical to the old fast path. + return SelectableRichText::new( + "message-text", + parsed.text.clone(), + StyledText::new(parsed.text.clone()), + Vec::new(), + Arc::default(), + ) + .into_any_element(); } let runs = &parsed.runs; @@ -134,10 +147,247 @@ pub fn render_rich_text(parsed: &BubbleText, cx: &App) -> gpui::AnyElement { .map(|(range, emphasis)| (range.clone(), style_for(*emphasis, metrics))) .collect(); - StyledText::new(text) - .with_highlights(highlights) - .with_font_family_overrides(code) - .into_any_element() + SelectableRichText::new( + "message-text", + text.clone(), + StyledText::new(text) + .with_highlights(highlights) + .with_font_family_overrides(code), + Vec::new(), + Arc::default(), + ) + .into_any_element() +} + +/// A StyledText-backed window selection run. +/// +/// `gpui_base::SelectableText` accepts only an unformatted string. Message +/// bubbles need the same window-level selection protocol without splitting a +/// message into one element per emphasis/link range (which would break inline +/// wrapping). This adapter keeps one StyledText/layout and supplies its text +/// to `TextSelectionLayer` as one run. +struct SelectableRichText { + id: ElementId, + text: SharedString, + styled_text: StyledText, + links: Vec>, + link_targets: Arc<[SharedString]>, +} + +impl SelectableRichText { + fn new( + id: impl Into, + text: SharedString, + styled_text: StyledText, + links: Vec>, + link_targets: Arc<[SharedString]>, + ) -> Self { + Self { + id: id.into(), + text, + styled_text, + links, + link_targets, + } + } + + fn paint_selection( + layout: &gpui::TextLayout, + range: Range, + color: gpui::Hsla, + window: &mut Window, + ) { + let (Some(start), Some(end)) = ( + layout.position_for_index(range.start), + layout.position_for_index(range.end), + ) else { + return; + }; + for bounds in selection_quad_bounds(start, end, layout.bounds(), layout.line_height()) { + window.paint_quad(PaintQuad { + bounds, + background: color.into(), + corner_radii: Corners::default(), + border_widths: Edges::default(), + border_color: transparent_black(), + border_style: BorderStyle::default(), + }); + } + } +} + +fn selection_quad_bounds( + start: Point, + end: Point, + bounds: Bounds, + line_height: Pixels, +) -> Vec> { + if start.y == end.y { + return vec![Bounds::from_corners( + start, + Point::new(end.x, end.y + line_height), + )]; + } + + let mut quads = vec![Bounds::from_corners( + start, + Point::new(bounds.right(), start.y + line_height), + )]; + if end.y > start.y + line_height { + quads.push(Bounds::from_corners( + Point::new(bounds.left(), start.y + line_height), + Point::new(bounds.right(), end.y), + )); + } + quads.push(Bounds::from_corners( + Point::new(bounds.left(), end.y), + Point::new(end.x, end.y + line_height), + )); + quads +} + +impl IntoElement for SelectableRichText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for SelectableRichText { + type RequestLayoutState = gpui_base::TextSelectionHandle; + type PrepaintState = Hitbox; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let handle = window.with_element_state( + global_id.expect("SelectableRichText must have a stable element id"), + |retained: Option, _| { + let handle = retained + .unwrap_or_else(|| gpui_base::TextSelectionHandle::new(self.text.clone(), cx)); + handle.set_fallback_copy_text(self.text.to_string(), cx); + (handle.clone(), handle) + }, + ); + let (layout_id, ()) = self + .styled_text + .request_layout(global_id, inspector_id, window, cx); + (layout_id, handle) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + handle: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.styled_text + .prepaint(global_id, inspector_id, bounds, &mut (), window, cx); + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + handle.register( + TextSelectionRegistration::new(hitbox.clone(), bounds) + .with_document_order(0) + .with_text_bounds(vec![bounds]), + window, + cx, + ); + hitbox + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + handle: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let layout = self.styled_text.layout().clone(); + let selected_text_before = TextSelection::selected_text(window, cx); + let projection = handle.update_runs( + &[ + TextSelectionRun::new(self.text.clone(), layout.clone(), bounds) + .with_document_order(0), + ], + cx, + ); + if selected_text_before != TextSelection::selected_text(window, cx) { + window.refresh(); + } + let color = gpui_base::Theme::global(cx).tokens.colors.selection; + for range in projection.ranges().iter().flatten().cloned() { + Self::paint_selection(&layout, range, color, window); + } + self.styled_text.paint( + global_id, + inspector_id, + bounds, + &mut (), + &mut (), + window, + cx, + ); + + if self.links.is_empty() { + return; + } + let links = self.links.clone(); + let targets = self.link_targets.clone(); + let text_layout = layout.clone(); + let link_hitbox = hitbox.clone(); + let mouse_down_index = Rc::new(Cell::new(None)); + let down_index = mouse_down_index.clone(); + let down_layout = text_layout.clone(); + let down_hitbox = link_hitbox.clone(); + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, _cx| { + if !phase.bubble() || event.button != MouseButton::Left { + return; + } + down_index.set(if down_hitbox.is_hovered(window) { + down_layout.index_for_position(event.position).ok() + } else { + None + }); + }); + window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| { + if !phase.bubble() + || event.button != MouseButton::Left + || !link_hitbox.is_hovered(window) + || mouse_down_index.replace(None) + != text_layout.index_for_position(event.position).ok() + || TextSelection::has_selection(window, cx) + { + return; + } + let Ok(index) = text_layout.index_for_position(event.position) else { + return; + }; + let Some(link_ix) = links.iter().position(|range| range.contains(&index)) else { + return; + }; + TextSelection::end(window, cx); + cx.stop_propagation(); + cx.open_url(&targets[link_ix]); + }); + } } /// One run's appearance. @@ -162,7 +412,7 @@ fn style_for(emphasis: Emphasis, metrics: crate::theme::Metrics) -> HighlightSty /// Message text that holds links, in one inline flow. /// /// A `StyledText` paints but answers no clicks, so the addresses ride along -/// as clickable ranges on an `InteractiveText` instead of becoming elements +/// as clickable ranges on the selection adapter instead of becoming elements /// of their own. Nothing is split into flex children, so a newline before an /// address starts a line the way it does without one, and a long address /// wraps the way plain text does rather than overflowing its item into the @@ -170,7 +420,7 @@ fn style_for(emphasis: Emphasis, metrics: crate::theme::Metrics) -> HighlightSty /// which is why this needs no platform split of its own: GPUI answers that /// on the desktop and in the page alike. Size and colour are inherited from /// the parent; only the link ink comes from the theme. -fn render_with_links(parsed: &BubbleText, cx: &App) -> impl IntoElement + use<> { +fn render_with_links(parsed: &BubbleText, cx: &App) -> SelectableRichText { let metrics = cx.product().metrics; let ink = cx.theme().link; let mono = cx.theme().mono_font_family.clone(); @@ -228,17 +478,17 @@ fn render_with_links(parsed: &BubbleText, cx: &App) -> impl IntoElement + use<> code.push((start..end, mono.clone())); } } - let styled = StyledText::new(text) + let styled = StyledText::new(text.clone()) .with_highlights(highlights) .with_font_family_overrides(code); let ranges: Vec> = parsed.links.iter().map(|link| link.range.clone()).collect(); // A refcount per frame, not a copy per target: the strings were shared // when the bubble was parsed. let targets = parsed.link_targets.clone(); - // One instance per bubble, scoped under the row's own id. - InteractiveText::new("message-links", styled).on_click(ranges, move |ix, _window, cx| { - cx.open_url(&targets[ix]); - }) + // One instance per bubble, scoped under the row's own id. The adapter + // keeps the complete StyledText flow while TextSelectionLayer handles + // drag selection over it. + SelectableRichText::new("message-text", text, styled, ranges, targets) } /// One run's appearance inside a link: its own emphasis, inked and underlined @@ -262,6 +512,8 @@ fn link_style( #[cfg(test)] mod tests { + use gpui::{ParentElement as _, Styled as _}; + use super::BubbleText; /// Links are resolved where the rows are built, beside the markup — so a @@ -426,6 +678,78 @@ mod tests { assert_eq!(targets[1].as_str(), parsed.links[1].target.as_str()); } + struct SelectableRichTextTestView { + source: &'static str, + } + + impl gpui::Render for SelectableRichTextTestView { + fn render( + &mut self, + _: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let parsed = BubbleText::of(self.source); + gpui::div() + .size_full() + .child(gpui_base::TextSelectionLayer) + .child( + gpui::div() + .w(gpui::px(240.)) + .h(gpui::px(32.)) + .child(super::render_rich_text(&parsed, cx)), + ) + } + } + + fn selected_prefix(source: &'static str, cx: &mut gpui::TestAppContext) -> String { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let (_, cx) = cx.add_window_view(|_, _| SelectableRichTextTestView { source }); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + + cx.simulate_mouse_down( + gpui::point(gpui::px(1.), gpui::px(12.)), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move( + gpui::point(gpui::px(58.), gpui::px(12.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + gpui::point(gpui::px(58.), gpui::px(12.)), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.update(|window, cx| { + let _ = window.draw(cx); + gpui_base::TextSelection::selected_text(window, cx) + }) + } + + #[gpui::test] + fn rich_text_selection_copies_a_substring(cx: &mut gpui::TestAppContext) { + assert_eq!(selected_prefix("alpha beta", cx), "alpha "); + } + + #[gpui::test] + fn formatted_selection_copies_visible_text_without_markup(cx: &mut gpui::TestAppContext) { + let selected = selected_prefix("*alpha* beta", cx); + assert!(selected.starts_with("alpha"), "selected {selected:?}"); + assert!(!selected.contains('*'), "selected {selected:?}"); + } + + #[gpui::test] + fn linked_message_allows_selection_without_opening_link(cx: &mut gpui::TestAppContext) { + let selected = selected_prefix("alpha https://example.invalid", cx); + assert_eq!(selected, "alpha "); + } + /// A stopwatch rather than an assertion: what a conversation pays to /// re-derive text nothing changed, and what it pays now that it does not. /// diff --git a/crates/gui/src/session/frames.rs b/crates/gui/src/session/frames.rs index 2a2f4fcb..cbf695bf 100644 --- a/crates/gui/src/session/frames.rs +++ b/crates/gui/src/session/frames.rs @@ -286,6 +286,9 @@ impl<'a> Frames<'a> { // For most requests this only releases the entry. For the few // whose whole answer is that they were done, it is the answer. match take_pending(self.pending, id) { + Some(Awaiting::Mutation(tx)) => { + let _ = tx.send(Ok(())); + } Some(Awaiting::Acted(tx)) => { let _ = tx.send(()); } diff --git a/crates/gui/src/session/mod.rs b/crates/gui/src/session/mod.rs index cdf63394..ce2b33a8 100644 --- a/crates/gui/src/session/mod.rs +++ b/crates/gui/src/session/mod.rs @@ -91,8 +91,8 @@ use oxidezap_ipc::{CallAction, ClientRequest, Link, PageCursor, Request, Request // so `Typing` and `Download` read at the call site as what they are: the // request's own payload, built here and moved onto the wire unchanged. use oxidezap_ipc::{ - Download, LoadChats, LoadMessages, MarkRead, MarkStatusWatched, SendAudio, SendMedia, SendText, - Typing, + Download, EditMessage, LoadChats, LoadMessages, MarkRead, MarkStatusWatched, RevokeMessage, + SendAudio, SendMedia, SendText, Typing, }; use portable_atomic::AtomicU64; use tokio::sync::oneshot; @@ -296,6 +296,8 @@ impl From<&oxidezap_ipc::ProtocolError> for Failure { /// is waiting, and a send that was refused becomes the failure the message it /// drew is already able to render. enum Awaiting { + /// A message mutation whose result must reach the action that opened it. + Mutation(oneshot::Sender>), Download(oneshot::Sender>, Failure>>), /// What this account occupies on disk, for the Storage pane. Storage(oneshot::Sender), @@ -367,6 +369,7 @@ impl Awaiting { /// Whether nobody is listening for this any more. fn is_abandoned(&self) -> bool { match self { + Self::Mutation(tx) => tx.is_closed(), Self::Download(tx) => tx.is_closed(), Self::Storage(tx) => tx.is_closed(), Self::Acted(tx) => tx.is_closed(), @@ -399,6 +402,9 @@ impl Awaiting { fn failed(self, failure: &Failure, events: Option<&ReaderSink>) { let detail = failure.detail.as_str(); match self { + Self::Mutation(tx) => { + let _ = tx.send(Err(failure.clone())); + } // The only caller that reads more than the sentence: whether // asking again could work decides what the person is told to do // about it. See [`Failure`]. @@ -1088,6 +1094,42 @@ impl SessionHandle { ); } + pub fn edit_message( + &self, + jid: String, + message_id: String, + new_text: String, + ) -> oneshot::Receiver> { + let (tx, rx) = oneshot::channel(); + self.ask( + ClientRequest::EditMessage(EditMessage { + jid, + message_id, + new_text, + }), + Awaiting::Mutation(tx), + ); + rx + } + + pub fn revoke_message( + &self, + jid: String, + message_id: String, + for_everyone: bool, + ) -> oneshot::Receiver> { + let (tx, rx) = oneshot::channel(); + self.ask( + ClientRequest::RevokeMessage(RevokeMessage { + jid, + message_id, + for_everyone, + }), + Awaiting::Mutation(tx), + ); + rx + } + pub fn send_audio_message( &self, jid: &str, diff --git a/crates/gui/src/views/chat.rs b/crates/gui/src/views/chat.rs index 545c4e82..7ff30ae3 100644 --- a/crates/gui/src/views/chat.rs +++ b/crates/gui/src/views/chat.rs @@ -247,6 +247,8 @@ pub fn render_connected_view( && !is_offline, viewer: viewer.is_some(), paste_preview: false, + message_edit: false, + message_delete: false, // The card floats above this view rather than inside it, so the root // is what knows whether one was drawn. See `WhatsAppApp::render`. call_card: false, diff --git a/crates/ipc/src/lib.rs b/crates/ipc/src/lib.rs index f7cc5fe2..5f107fb4 100644 --- a/crates/ipc/src/lib.rs +++ b/crates/ipc/src/lib.rs @@ -54,9 +54,9 @@ pub use oxidezap_wire as wire; pub use protocol::{ AccountIdentity, AccountOverview, AccountStatus, AccountsSnapshot, AvatarDemand, CallAction, ChatSummary, ClientRequest, ClientScope, ConnectionState, DaemonEvent, DaemonMessage, Download, - EnsureAvatars, GroupMembers, InstallPlugin, LoadChats, LoadMessages, MarkRead, + EditMessage, EnsureAvatars, GroupMembers, InstallPlugin, LoadChats, LoadMessages, MarkRead, MarkStatusWatched, MessagePreview, PageCursor, PairingCode, ProtocolError, Request, RequestId, - SendAudio, SendMedia, SendText, StateSnapshot, StateVersion, Typing, + RevokeMessage, SendAudio, SendMedia, SendText, StateSnapshot, StateVersion, Typing, }; pub use transport::{ ACCOUNT_STAGED_INFIX, DEFAULT_WEB_PORT, MAX_STAGED_BYTES, PROTOCOL_VERSION, STAGED_PREFIX, diff --git a/crates/ipc/src/protocol.rs b/crates/ipc/src/protocol.rs index 8558eef7..ea7498a2 100644 --- a/crates/ipc/src/protocol.rs +++ b/crates/ipc/src/protocol.rs @@ -685,6 +685,23 @@ pub struct SendText { pub quoted: Option, } +/// Replace the text of one sent message. Answered only after the session's +/// mutation and durable local materialization finish. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditMessage { + pub jid: String, + pub message_id: String, + pub new_text: String, +} + +/// Delete one sent message locally or request deletion for everyone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RevokeMessage { + pub jid: String, + pub message_id: String, + pub for_everyone: bool, +} + /// Send a recorded voice note. See [`ClientRequest::SendAudio`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SendAudio { @@ -927,6 +944,8 @@ pub enum ClientRequest { /// — and the serde working set — of every request. The wire is unchanged: /// `Box` is transparent to serde, and the round-trip test pins the bytes. SendText(Box), + EditMessage(EditMessage), + RevokeMessage(RevokeMessage), /// Send a recorded voice note. /// /// The audio arrives through the media cache rather than the socket: it @@ -1804,6 +1823,30 @@ mod tests { })), r#"{"request":"send_text","jid":"559900000001@s.whatsapp.net","text":"oi","local_id":null}"#.to_string(), ), + ( + ClientRequest::EditMessage(EditMessage { + jid: "559900000001@s.whatsapp.net".into(), + message_id: "MSG-E".into(), + new_text: "corrigido".into(), + }), + r#"{"request":"edit_message","jid":"559900000001@s.whatsapp.net","message_id":"MSG-E","new_text":"corrigido"}"#.to_string(), + ), + ( + ClientRequest::RevokeMessage(RevokeMessage { + jid: "559900000001@s.whatsapp.net".into(), + message_id: "MSG-R".into(), + for_everyone: true, + }), + r#"{"request":"revoke_message","jid":"559900000001@s.whatsapp.net","message_id":"MSG-R","for_everyone":true}"#.to_string(), + ), + ( + ClientRequest::RevokeMessage(RevokeMessage { + jid: "559900000001@s.whatsapp.net".into(), + message_id: "MSG-R".into(), + for_everyone: false, + }), + r#"{"request":"revoke_message","jid":"559900000001@s.whatsapp.net","message_id":"MSG-R","for_everyone":false}"#.to_string(), + ), ( ClientRequest::SendAudio(Box::new(SendAudio { jid: "559900000001@s.whatsapp.net".into(), diff --git a/crates/ipc/src/transport.rs b/crates/ipc/src/transport.rs index 6e947170..c643e857 100644 --- a/crates/ipc/src/transport.rs +++ b/crates/ipc/src/transport.rs @@ -5,6 +5,10 @@ use std::path::PathBuf; /// Bumped whenever a frame changes shape in a way an older peer would /// misread. The daemon refuses a mismatch rather than guessing. /// +/// 35: `ClientRequest::EditMessage` and `RevokeMessage` let the GUI request +/// existing session mutations with an addressed completion. An older daemon +/// does not know these commands and would silently refuse a menu action. +/// /// 34: `LoadChats.archived` lets the desktop request the store's /// include-archived view, and full `Chat` frames carry the durable archive /// state used to separate that view. A v33 daemon would ignore the request @@ -257,7 +261,7 @@ use std::path::PathBuf; /// would misparse the first three and not recognise the rest. /// /// [`PairingCode`]: crate::PairingCode -pub const PROTOCOL_VERSION: u32 = 34; +pub const PROTOCOL_VERSION: u32 = 35; /// Where the daemon's web bridge listens when nobody says otherwise. /// diff --git a/crates/ipc/tests/session_frames.rs b/crates/ipc/tests/session_frames.rs index f121fd7a..5d9e765c 100644 --- a/crates/ipc/tests/session_frames.rs +++ b/crates/ipc/tests/session_frames.rs @@ -144,6 +144,7 @@ fn an_omitted_field_comes_back_as_what_it_was() { "media", "system", "revoked", + "edited", "sender_name", ] { assert!( @@ -159,6 +160,7 @@ fn an_omitted_field_comes_back_as_what_it_was() { let mut full = plain.clone(); full.sender_name = Some("Alguém".into()); full.revoked = true; + full.edited = true; full.reactions .insert("🎉".into(), vec!["1@s.whatsapp.net".into()]); // A literal on purpose, and one no constructor could produce: every diff --git a/crates/session/src/whatsapp/convert.rs b/crates/session/src/whatsapp/convert.rs index 49016554..dc025975 100644 --- a/crates/session/src/whatsapp/convert.rs +++ b/crates/session/src/whatsapp/convert.rs @@ -93,6 +93,7 @@ pub(super) fn stored_to_chat_message(stored: oxidezap_chat_store::StoredMessage) }, quoted, revoked: stored.revoked, + edited: stored.edited_at.is_some(), system: None, } } diff --git a/crates/session/src/whatsapp/mod.rs b/crates/session/src/whatsapp/mod.rs index 64717a57..b4369c20 100644 --- a/crates/session/src/whatsapp/mod.rs +++ b/crates/session/src/whatsapp/mod.rs @@ -2069,6 +2069,7 @@ impl WhatsAppClient { }, quoted: quoted_from(base_msg), revoked: false, + edited: false, system: None, }; diff --git a/crates/session/src/whatsapp/mutations.rs b/crates/session/src/whatsapp/mutations.rs index 62ce729c..99b19221 100644 --- a/crates/session/src/whatsapp/mutations.rs +++ b/crates/session/src/whatsapp/mutations.rs @@ -6,6 +6,7 @@ //! synchronous constructor returning a [`Task`] that resolves once the network //! answered, with failures as strings a front end can display. +use oxidezap_chat_store::{MessageKind, MessageStatus}; use whatsapp_rust::wacore_binary::jid::{Jid, JidExt as _, observe_str}; use whatsapp_rust::waproto::whatsapp as wa; @@ -74,21 +75,46 @@ impl WhatsAppClient { let chat: Jid = chat_jid .parse() .map_err(|_| "not a chat address".to_string())?; - if new_text.is_empty() { + if new_text.trim().is_empty() { return Err("new text must not be empty".to_string()); } let Some(live) = session.lock().await.clone() else { return Err("no session yet".to_string()); }; + let stored = live + .chat_store + .message(&chat, &message_id) + .await + .map_err(|e| format!("database query failed: {e}"))? + .ok_or_else(|| "message not found".to_string())?; + if !stored.from_me || stored.revoked || stored.kind != MessageKind::Text { + return Err("only our own non-deleted text messages can be edited".to_string()); + } + if matches!(stored.status, MessageStatus::Pending | MessageStatus::Error) { + return Err("message has not been sent".to_string()); + } + if wacore::time::now_millis().saturating_sub(stored.timestamp.timestamp_millis()) + > 15 * 60 * 1_000 + { + return Err("messages can be edited for 15 minutes after sending".to_string()); + } let content = wa::Message { conversation: Some(new_text), ..Default::default() }; - live.client - .edit_message(chat, message_id, content) + let result = live + .client + .edit_message(chat.clone(), message_id.clone(), content.clone()) .await - .map(|result| result.message_id.clone()) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + live.chat_store + .record_edit(&chat, &message_id, &content, wacore::time::now_utc()) + .map_err(|e| format!("edit was sent but could not be saved locally: {e}"))?; + live.chat_store + .flush() + .await + .map_err(|e| format!("edit was sent but could not be saved locally: {e}"))?; + Ok(result.message_id) }) } @@ -117,16 +143,40 @@ impl WhatsAppClient { .await .map_err(|e| format!("database query failed: {e}"))?; if for_everyone { - if let Some(ref stored) = stored - && !stored.from_me - { + let stored = stored.ok_or_else(|| "message not found".to_string())?; + if !stored.from_me { return Err("only our own messages can be revoked for everyone".to_string()); } + if stored.revoked { + return Err("message was already deleted for everyone".to_string()); + } + if matches!(stored.status, MessageStatus::Pending | MessageStatus::Error) { + return Err("message has not been sent".to_string()); + } + if wacore::time::now_millis().saturating_sub(stored.timestamp.timestamp_millis()) + > 2 * 24 * 60 * 60 * 1_000 + { + return Err( + "messages can be deleted for everyone for two days after sending" + .to_string(), + ); + } live.client - .revoke_message(chat, message_id, whatsapp_rust::send::RevokeType::Sender) + .revoke_message( + chat.clone(), + message_id.clone(), + whatsapp_rust::send::RevokeType::Sender, + ) .await - .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + live.chat_store + .record_revoke(&chat, &message_id, wacore::time::now_utc()) + .map_err(|e| format!("delete was sent but could not be saved locally: {e}"))?; + live.chat_store + .flush() + .await + .map_err(|e| format!("delete was sent but could not be saved locally: {e}"))?; + Ok(()) } else { // Local delete rides the app-state path, so linked devices // converge on it; the store catches up through the event @@ -136,9 +186,9 @@ impl WhatsAppClient { Some(stored) => ( stored.from_me, (!stored.from_me && chat.is_group()).then_some(stored.sender_jid), - Some(stored.timestamp.timestamp_millis()), + stored.timestamp.timestamp_millis(), ), - None => (true, None, None), + None => return Err("message not found".to_string()), }; live.client .chat_actions() @@ -148,10 +198,25 @@ impl WhatsAppClient { &message_id, from_me, false, + Some(timestamp), + ) + .await + .map_err(|e| e.to_string())?; + live.chat_store + .record_delete_for_me( + &chat, + &message_id, + from_me, + participant, timestamp, + wacore::time::now_utc(), ) + .map_err(|e| format!("delete was sent but could not be saved locally: {e}"))?; + live.chat_store + .flush() .await - .map_err(|e| e.to_string()) + .map_err(|e| format!("delete was sent but could not be saved locally: {e}"))?; + Ok(()) } }) } diff --git a/crates/session/src/whatsapp/tests.rs b/crates/session/src/whatsapp/tests.rs index f5ea27cc..f434d387 100644 --- a/crates/session/src/whatsapp/tests.rs +++ b/crates/session/src/whatsapp/tests.rs @@ -1485,6 +1485,7 @@ fn alias_history_unread_deduplicates_only_matching_messages() { status: MessageStatus::default(), quoted: None, revoked: false, + edited: false, system: None, }; let mut chat = Chat::new("111222333444555@lid".to_string()); @@ -1526,6 +1527,7 @@ fn watched_updates_come_back_watched() { status: MessageStatus::default(), quoted: None, revoked: false, + edited: false, system: None, }; let mut broadcast = Chat::new(oxidezap_core::STATUS_BROADCAST_JID.to_string()); @@ -1858,6 +1860,40 @@ fn a_message_cursor_survives_the_round_trip() { assert!(parse_message_cursor("").is_none()); } +#[test] +fn stored_edit_state_reaches_own_and_peer_bubbles_in_direct_and_group_chats() { + use super::convert::stored_to_chat_message; + + const PEER: &str = "559900000001@s.whatsapp.net"; + const GROUP: &str = "120363000000000001@g.us"; + let timestamp = whatsapp_rust::wacore::time::from_millis(1_700_000_000_000).unwrap(); + for chat_jid in [PEER, GROUP] { + for from_me in [false, true] { + let stored = oxidezap_chat_store::StoredMessage { + chat_jid: chat_jid.parse().unwrap(), + id: format!("EDITED-{from_me}"), + sender_jid: PEER.parse().unwrap(), + from_me, + timestamp, + kind: oxidezap_chat_store::MessageKind::Text, + text: Some("corrected".into()), + message: None, + status: oxidezap_chat_store::MessageStatus::Delivered, + starred: false, + edited_at: Some(timestamp), + revoked: false, + seq: 1, + }; + let mut plain = stored.clone(); + plain.edited_at = None; + let edited = stored_to_chat_message(stored); + assert!(edited.edited, "{chat_jid} from_me={from_me}"); + assert_eq!(edited.is_from_me, from_me); + assert!(!stored_to_chat_message(plain).edited); + } + } +} + /// The address goes last and is not split on: a device JID carries a /// colon of its own, and a cursor that lost the tail of one would page /// A load that stopped at its limit knows where it stopped, and saying so diff --git a/docs/stories/1.6.story.md b/docs/stories/1.6.story.md new file mode 100644 index 00000000..54f951a9 --- /dev/null +++ b/docs/stories/1.6.story.md @@ -0,0 +1,125 @@ +# Story 1.6: Sent-Message Actions, Attachment Confirmation, and Text Selection + +## Status + +Ready for Review + +## Executor Assignment + +```yaml +executor: "@dev" +quality_gate: "@architect" +quality_gate_tools: + - "cargo fmt --all -- --check" + - "cargo clippy --workspace --all-targets --all-features -- -D warnings" + - "cargo test --workspace --all-features" +``` + +## Story + +**As an** OxideZap user, +**I want** to edit or delete messages I sent, confirm attachments before sending them regardless of how I added them, and select just the chat text I need, +**so that** I can correct mistakes, avoid accidental media sends, and copy a useful excerpt rather than an entire message. + +## Context and Decisions + +This is one user-requested batch with three independent GUI outcomes. The existing CLI already exposes `messages edit` and `messages revoke` through `oxidezap-wire::ClientRequest`; `messages revoke --for-everyone` requests deletion for everyone, while the default requests deletion for me. The GUI uses the **separate** `oxidezap-ipc::ClientRequest`, which does not yet contain edit/revoke variants. Add the smallest GUI IPC requests and daemon routing needed to reuse the existing session operations, without creating a second WhatsApp session. The pasted-image confirmation from Story 1.2 already has explicit `Cancel` and `Send` actions, whereas picker and drag/drop currently send immediately through `finish_attaching`. + +The user confirmed **both** delete choices: **Apagar para todos** and **Apagar para mim**. These are separate actions with different effects; neither may silently stand in for the other. WhatsApp documents an edit window of up to 15 minutes and a delete-for-everyone request window of up to two days after sending; delete-for-me affects only the user's copy. Server outcomes remain authoritative. [Source: [WhatsApp Help Center — How to delete messages](https://faq.whatsapp.com/1370476507114859/?cms_platform=web&helpref=faq_content)] + +## Acceptance Criteria + +1. A sent, non-revoked text message offers an `Edit` action while it is eligible under WhatsApp's 15-minute window. Editing starts with that message's current text, allows explicit save or cancel, and sends no edit on cancel or an empty replacement. A successful edit updates the conversation through the existing message/store refresh path; a failed request reports the error without presenting the replacement as accepted. +2. A sent message offers `Apagar para mim` and, while eligible within WhatsApp's two-day request window, the distinct action `Apagar para todos`. The former routes `for_everyone: false` to the existing session operation and removes only the user's copy; the latter routes `for_everyone: true` for the user's own message. No incoming-message or group-admin delete-for-everyone action is added by this story. +3. Edit/delete actions target the exact message and chat on which the action was invoked. Repeated activation cannot submit the same pending action twice. Rejected or unavailable operations produce visible feedback and do not falsely remove or edit a message. Successful own edits and delete-for-everyone actions update durable chat state and the visible conversation through the existing store/history path; delete-for-me follows its existing app-state path. +4. Every accepted attachment supplied by the file picker or drag/drop, on desktop and web paths where that source exists, reaches an explicit confirmation surface before any `send_attachment` call, outgoing media bubble, or upload. Pasted images retain their existing Story 1.2 confirmation behavior. +5. The confirmation identifies every accepted file in a multi-file choice/drop. Images use the established visual preview where supported; other file types are represented by their file identity/type rather than being sent invisibly. The surface has explicit `Cancel` and `Send` actions. No caption, media editor, or new media transport is introduced. +6. `Cancel` discards the pending accepted files with zero sends. `Send` consumes the pending choice once and passes each accepted file exactly once through the existing `send_attachment` flow, preserving the destination captured when the picker/drop began, existing reply-on-first-file behavior, size/type checks, and per-file refusal notices. Dismissing a picker or a choice with no accepted files opens no confirmation and sends nothing. +7. In a chat message's visible text, the user can select an arbitrary substring within that message and copy exactly the selected visible characters using the normal platform copy gesture. Plain, formatted, and linked message text remain readable; selection does not accidentally invoke link navigation or a message action. Existing whole-message `Copy text` and link-opening actions remain available. +8. Automated tests cover the edit/save/cancel and both delete request variants, GUI IPC serialization/daemon routing/version compatibility, durable own-edit/revoke visibility, wrong-message/stale/failed-action guards, picker/drop multi-file preview with zero-before-confirmation, cancel-zero and confirm-exactly-once, pasted-image regression, and substring selection/copy alongside whole-message copy and links. Tests use synthetic identifiers and payloads only. +9. Repository Cargo formatting, clippy, and workspace tests pass; manual desktop smoke covers all three outcomes, with web picker/drop smoke where a browser test environment is available. + +## Test Seams + +- Rendered message action -> exact chat/message ID and new `oxidezap-ipc::ClientRequest` edit/revoke variants -> daemon `Action` -> existing session edit/revoke operations -> durable state and refreshed bubble; test both delete boolean values, IPC round-trips, and failures without optimistic false success. +- Picker and native/web drop -> shared `Chosen`/`Picked` result -> visible confirmation with zero attachment attempts -> rendered `Cancel` or `Send` -> zero or exactly one attempt per accepted file. Include a multi-file choice with a refusal and a chat switch while file reading/confirmation is pending. +- Rendered plain/formatted/linked bubble text -> pointer selection and platform copy -> clipboard contains only the selected visible substring; whole-message copy and link activation still work. + +## Tasks / Subtasks + +- [x] Add rendered interaction regressions for sent-text edit, explicit save/cancel, eligible/ineligible states, both delete variants, exact message targeting, duplicate activation, and error feedback. (AC: 1-3, 8) +- [x] Add minimal GUI IPC edit/revoke requests, protocol round-trip coverage and version update, daemon dispatch/action routing, and GUI actions that reuse the existing session operations. (AC: 1-3, 8) +- [x] Ensure successful own edits and revokes materialize locally through the existing chat-store helpers/history refresh; preserve the established app-state delete-for-me behavior. (AC: 1-3, 8) +- [x] Add picker and drag/drop confirmation regressions for one file, multiple files, partial refusal, cancel, confirm once, dismissed/empty choice, captured destination/reply, and the existing paste flow. (AC: 4-6, 8) +- [x] Route accepted picker/drop files into a shared pending confirmation state and reuse `send_attachment` only after explicit `Send`; preserve the existing refusal, reply, and attachment rules. (AC: 4-6) +- [x] Add interaction coverage for selecting/copying a substring of plain, formatted, and linked chat text without regressing whole-message copy or link actions. (AC: 7-8) +- [x] Make message text selectable and copyable through the normal platform gesture while retaining its existing formatting and link behavior. (AC: 7) +- [ ] Run Cargo quality gates and manual smoke checks; update this checklist, Dev Agent Record, and File List with actual changes and results. (AC: 8-9) + +## Dev Notes + +- CLI edit/revoke entry points are in `crates/cli/src/args.rs` and `crates/cli/src/main.rs`; its `oxidezap-wire` contract is in `crates/wire/src/request.rs`, routed by `crates/daemon/src/session_bridge/act.rs` to `crates/session/src/whatsapp/mutations.rs`. The **different** GUI contract in `crates/ipc/src/protocol.rs` has no edit/revoke request yet; `crates/daemon/src/server/requests.rs` and `crates/daemon/src/session_bridge/action.rs` need corresponding routing. Follow `crates/ipc/src/transport.rs` when changing the IPC version, since an older daemon would otherwise reject the new GUI requests. The current session edit API constructs text content; this story limits editing to sent text, not media/captions. The session's sender revoke path already rejects a stored incoming message. [Source: those files] +- Existing store code materializes inbound edits and revokes, and GUI history merges treat hydrated message content as authoritative. `ChatStore::record_edit` and `record_revoke` exist but the current own-message session mutation paths do not call them; wire successful own mutations into durable state and refresh without adding a local-only representation. Delete-for-me uses the session's app-state path, not `record_revoke`. [Source: `crates/chat-store/src/store/mod.rs`; `crates/chat-store/src/store/edit.rs`; `crates/chat-store/src/store/revoke.rs`; `crates/core/src/chat/merge.rs`; `crates/session/src/whatsapp/mutations.rs`] +- `crates/gui/src/components/message_bubble/mod.rs` owns the bubble context menu; `crates/gui/src/app/mod.rs` owns its current whole-message `CopyMessage` action. `crates/gui/src/components/rich_text.rs` renders plain, styled, and linked text via different GPUI elements. A selection solution must account for all three visible forms and avoid changing copied text to markup source characters. [Source: those files] +- `crates/gui/src/app/attaching.rs::finish_attaching` is the current common immediate-send point for picker and drops. Native chat drops enter at `crates/gui/src/views/chat.rs` and browser document drops at `crates/gui/src/platform/drop.rs`; both produce `Chosen`/`Picked` via the existing picker rules. Retain the destination/reply captured before asynchronous reading. [Source: those files] +- The existing pasted-image modal and single-use confirmation live in `crates/gui/src/components/paste_preview.rs`, `crates/gui/src/app/mod.rs`, and `crates/gui/src/app/attaching.rs`. Reuse its explicit-confirmation UX and established theme/metric tokens; do not regress Story 1.2. [Source: those files; `docs/stories/1.2.story.md`] +- WhatsApp's two-day delete-for-everyone and 15-minute edit windows are product eligibility guidance, not a promise that another device has already applied a request. Surface server rejection honestly. [Source: [WhatsApp Help Center](https://faq.whatsapp.com/1370476507114859/?cms_platform=web&helpref=faq_content)] +- A successful delete-for-me is removed by exact message ID in the requesting GUI and in the durable store. A second GUI window that already holds that row may remain stale until its in-memory conversation is rebuilt: ordinary history merge cannot infer a deletion from absence in a partial page. Multi-window deletion fanout needs a separate explicit event and test; it is outside this story's minimal IPC additions. +- No new CLI command, media upload pipeline, attachment kind, caption editor, group-admin moderation, or cross-message text selection is requested. The required GUI IPC variants must stay limited to edit/revoke. Existing whole-message copy remains supported. +- This Rust repository's CI authority is `.github/workflows/ci.yml`; use its Cargo gates rather than the unrelated npm commands in the ancestor AIOX instructions. [Source: `AGENTS.md#build--verify`] + +## CodeRabbit Integration + +> **CodeRabbit Integration**: Not configured +> +> This repository has no `.aiox-core/core-config.yaml`. Use the repository's Cargo, architecture-review, and CI gates. + +## Story Draft Checklist + +- [x] All three user-requested outcomes and their value are explicit. +- [x] Both delete variants are confirmed and mapped to distinct existing requests. +- [x] Existing CLI/session, attachment, and rich-text seams are identified; the required GUI IPC bridge and version policy are explicit. +- [x] Zero-before-confirmation, cancel-zero, confirm-once, and substring-copy outcomes are testable. +- [x] Out-of-scope features and repository-native quality gates are explicit. +- [x] Implementation, automated validation, and File List recorded by `@dev`; manual WhatsApp smoke awaits the user's test. + +## Change Log + +| Date | Version | Description | Author | +| --- | --- | --- | --- | +| 2026-09-21 | 0.1 | Drafted the user-requested batch; recorded both confirmed delete variants and existing implementation/test seams. | River (@sm) | +| 2026-09-21 | 0.2 | Implemented the batch, added regressions, and recorded automated gates; kept manual smoke pending. | @dev | + +## Dev Agent Record + +### Agent Model Used + +Sol and Luna agents (xhigh), integrated by @dev. + +### Debug Log References + +`cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-features` (all passed with local socket access). GUI final: 442 passed, 7 ignored. Release GUI+daemon built and installed in `/Applications/OxideZap.app`; bundle signature verified and one GUI/daemon process pair started. Manual WhatsApp interaction and web smoke were not run. + +### Completion Notes List + +- Sent-message edits and both delete variants use addressed GUI IPC requests; local changes occur after the daemon/session operation succeeds and the store flushes. +- Picker, drop, and clipboard files share consume-once confirmation; overlapping reads queue rather than silently dropping files. +- Chat text uses a StyledText-backed GPUI selection run; formatting and inline links remain rendered. +- The second already-open GUI window limitation for delete-for-me is recorded in Dev Notes; Mac live send/delete smoke remains for the user. The previous installed executables were backed up under `/private/tmp/oxidezap-install-backup.HDAtvp`; build cache was retained. + +### File List + +- `docs/stories/1.6.story.md` — story scope, acceptance, and progress tracking. +- `Cargo.lock`, `crates/gui/Cargo.toml` — direct `gpui-base` selection dependency. +- `crates/ipc/src/{lib,protocol,transport}.rs` — addressed GUI edit/revoke requests and IPC version. +- `crates/daemon/src/server/{requests,tests}.rs`, `crates/daemon/src/session_bridge/{act,action}.rs` — daemon routing, acknowledgments, and tests. +- `crates/session/src/whatsapp/mutations.rs`, `crates/chat-store/src/store/mod.rs`, `crates/chat-store/tests/edits.rs` — server mutations and durable local materialization. +- `crates/core/src/chat/merge.rs` — exact local delete-for-me row removal and preview update. +- `crates/gui/src/session/{mod,frames}.rs` — GUI client requests and response handling. +- `crates/gui/src/app/{mod,message_actions,attaching,calls_ctl,commands}.rs` — edit modal, message actions, shared attachment confirmation, and keyboard focus. +- `crates/gui/src/components/{message_bubble/mod,message_list,mod,paste_preview,rich_text}.rs` — menu actions, attachment preview, selectable text, and tests. +- `crates/gui/src/views/chat.rs` — keyboard surface state. + +## QA Results + +_To be completed by @qa._ diff --git a/docs/stories/1.7.story.md b/docs/stories/1.7.story.md new file mode 100644 index 00000000..b9468b6c --- /dev/null +++ b/docs/stories/1.7.story.md @@ -0,0 +1,119 @@ +# Story 1.7: Confirm Message Deletion + +## Status + +Ready for Review + +## Executor Assignment + +```yaml +executor: "@dev" +quality_gate: "@architect" +strategy: "sol-luna-xhigh" +quality_gate_tools: + - "cargo fmt --all -- --check" + - "cargo clippy --workspace --all-targets --all-features -- -D warnings" + - "cargo test --workspace --all-features" +``` + +## Story + +**As an** OxideZap user, +**I want** to confirm a message deletion before it is applied, +**so that** I do not accidentally remove the wrong message or choose the wrong deletion scope. + +## Context and Decisions + +Story 1.6 added the two requested deletion actions for sent messages: **Apagar para mim** and **Apagar para todos**. The user now requests a confirmation surface before either action performs the real deletion. The confirmation must be a final guard around the existing revoke requests; it does not change the server operation, deletion scopes, or eligibility rules already implemented. + +Both choices remain distinct. Confirming **Apagar para mim** must continue to request `for_everyone: false`; confirming **Apagar para todos** must continue to request `for_everyone: true`. No new deletion mode or destructive operation is introduced. + +## Acceptance Criteria + +1. Activating either existing deletion action opens an explicit confirmation surface for the exact message and chat, and does not send a revoke request, change the durable store, remove the bubble, or show an optimistic deletion before confirmation. +2. The confirmation clearly identifies the pending message and which scope will be used: **Apagar para mim** or **Apagar para todos**. The two scopes remain separate and cannot silently fall back to one another. +3. Canceling or dismissing the confirmation closes it with zero revoke requests and leaves the message, chat history, and durable state unchanged. The user can invoke the deletion action again afterward. +4. Confirming **Apagar para mim** sends exactly one request for the captured chat/message with `for_everyone: false`; confirming **Apagar para todos** sends exactly one request for the captured chat/message with `for_everyone: true`. Repeated clicks or key activation while pending cannot submit duplicates. +5. A successful confirmation follows the existing deletion result path and updates the correct visible and durable message state. A rejected or failed request closes or resolves the pending operation without falsely removing the message and presents the existing error feedback path. +6. The confirmation remains bound to the message and chat that opened it. Switching chats, receiving unrelated messages, or opening another action cannot cause the pending confirmation to delete a different resource; stale or unavailable targets are rejected safely. +7. The confirmation applies only to the existing sent-message deletion actions. Editing, attachment confirmation, whole-message copy, text selection, incoming messages, and group-admin deletion behavior are unchanged by this story. +8. Automated tests cover both deletion scopes, exact message/chat targeting, confirmation rendering, cancel/dismiss with zero requests, confirm with exactly one request, duplicate activation, stale/failed request handling, and preservation of message state before confirmation. +9. Repository Cargo formatting, clippy, and workspace tests pass; manual desktop smoke verifies canceling both choices and confirming each choice against a real chat without deleting an unintended message. + +## Test Seams + +- Existing message context-menu deletion action -> pending confirmation state containing chat JID, message ID, and `for_everyone` -> rendered cancel/confirm actions -> one addressed `RevokeMessage` IPC request or zero requests. +- Confirmation cancel/dismiss and repeated keyboard/pointer activation -> request spy and visible message state; assert no mutation before confirmation and no duplicate after confirmation. +- Daemon/session success and failure -> exact message/chat durable-state update or preserved message plus existing error feedback; include stale target and chat-switch cases. + +## Tasks / Subtasks + +- [x] Add a confirmation state/surface around both existing sent-message deletion actions. (AC: 1-3) +- [x] Preserve and display the selected deletion scope, exact message, and chat while pending. (AC: 2, 6) +- [x] Gate the existing revoke requests behind one-shot confirmation, retaining the two boolean variants and current result/error paths. (AC: 4-6) +- [x] Add regressions for cancel, dismiss, both confirmation outcomes, duplicate activation, stale/failed requests, and no pre-confirmation mutation. (AC: 8) +- [x] Run Cargo formatting, workspace Clippy, workspace tests, and a release build; update this story's checklist, Dev Agent Record, and File List with actual results. (AC: 9) +- [ ] Manually test canceling and confirming both choices in a real chat before marking desktop smoke complete. (AC: 9) + +## Dev Notes + +- Reuse the existing message-action and GUI IPC/session revoke path from `docs/stories/1.6.story.md`; do not create another WhatsApp session or a second deletion API. +- The confirmation should use existing GUI theme, metrics, focus, and modal conventions. Its copy must distinguish the two deletion scopes without introducing unrelated settings or workflows. +- Do not perform a local optimistic removal when the confirmation opens. Server/session success remains authoritative, as established by Story 1.6. +- Keep test fixtures synthetic; do not add real phone numbers, JIDs, names, or message content. +- Preserve the repository's Cargo quality gates from `AGENTS.md`; the ancestor AIOX npm commands do not apply to this Rust workspace. +- Keep the build cache until the user has tested the installed build and explicitly approves cleanup. + +## CodeRabbit Integration + +> **CodeRabbit Integration**: Not configured +> +> This repository has no `.aiox-core/core-config.yaml`. Use the repository's Cargo, architecture-review, and CI gates. + +## Story Draft Checklist + +- [x] The user-requested confirmation guard is isolated from the already implemented deletion operations. +- [x] Both confirmed deletion scopes are mapped to their existing boolean requests. +- [x] Cancel, dismiss, confirm-once, exact-target, and failure behavior are testable. +- [x] Scope excludes unrelated message, attachment, and moderation changes. +- [x] Implementation, automated validation, and File List remain for `@dev` to record. + +## Change Log + +| Date | Version | Description | Author | +| --- | --- | --- | --- | +| 2026-09-21 | 0.1 | Created story from the user's request for confirmation before either sent-message deletion action. | River (@sm) | +| 2026-09-21 | 0.2 | Added addressed confirmation modal, focus/dismissal handling, and GUI regressions. | @dev | +| 2026-09-21 | 0.3 | Passed workspace gates and release build; installed and launched updated macOS app. Real-chat deletion smoke awaits user confirmation. | @dev | +| 2026-09-21 | 0.4 | Added captured message time to the confirmation and kept delete-for-me chat activity ordering aligned with durable history; workspace gates passed and updated macOS build installed/launched. | @dev | + +## Dev Agent Record + +### Agent Model Used + +Sol (xhigh), integrated by @dev. + +### Debug Log References + +`cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-features`; `cargo test -p oxidezap-gui message_actions::tests` (8 passed); `cargo build --release --bin oxidezap --bin oxidezapd`; `codesign --verify --deep --strict --verbose=2 /Applications/OxideZap.app`; app opened with GUI and daemon processes. Real-chat cancel/confirm smoke is pending. + +### Completion Notes List + +- Both existing delete actions now open a modal showing their selected scope, chat, and message preview. Opening or canceling it makes no revoke request. +- Confirm consumes the captured target once and delegates to the existing deletion path, which revalidates the message and current chat. Escape, switching chats, and leaving the connected view discard the confirmation. +- A test-only spy at the GUI's `delete_sent_message` boundary checks exact target/scope and one-shot invocation; it is not a real IPC-frame spy. Story 1.6 separately covers IPC serialization and daemon routing. The no-daemon test verifies preservation and visible failure feedback, not server behavior. +- The modal also shows the captured message time. Delete-for-me updates the preview but retains the chat's activity time, as the durable store does. These follow-up edits passed the complete workspace gates and are in the installed build. +- Independent review identified a separate pre-existing limitation: a delete-for-me performed in one GUI window may remain visible in a second simultaneously open window because history merging does not prune absent message IDs. This is not addressed by the confirmation guard; it requires a separate synchronization change and live validation. + +### File List + +- `docs/stories/1.7.story.md` — acceptance, progress, and validation record. +- `crates/gui/src/app/message_actions.rs` — pending deletion state, confirmation surface, gating, and GPUI regressions. +- `crates/gui/src/app/mod.rs` — addressed menu dispatch, modal state/rendering, focus surface, lifecycle cleanup, and test spy. +- `crates/gui/src/app/calls_ctl.rs`, `crates/gui/src/app/commands.rs`, `crates/gui/src/views/chat.rs` — modal keyboard ownership and Escape dismissal. +- `crates/gui/src/app/notices.rs` — test-only inspection of existing failure feedback. +- `crates/core/src/chat/merge.rs` — keep live delete-for-me list ordering consistent with the durable store; test deleting the newest and final row. + +## QA Results + +_To be completed by @qa._ diff --git a/docs/stories/1.8.story.md b/docs/stories/1.8.story.md new file mode 100644 index 00000000..a0bc07b9 --- /dev/null +++ b/docs/stories/1.8.story.md @@ -0,0 +1,117 @@ +# Story 1.8: Indicate Edited Messages + +## Status + +Ready for Review + +## Executor Assignment + +```yaml +executor: "@dev" +quality_gate: "@architect" +strategy: "sol-luna-xhigh" +quality_gate_tools: + - "cargo fmt --all -- --check" + - "cargo clippy --workspace --all-targets --all-features -- -D warnings" + - "cargo test --workspace --all-features" +``` + +## Story + +**As an** OxideZap user, +**I want** edited messages to be visibly identified, +**so that** I can distinguish the current text from a message that was never edited. + +## Context and Decisions + +The existing message-edit flow already represents successful edits in the conversation. The user requests a visible indication for those messages, regardless of whether the message was sent by this account or received from another participant, and regardless of whether the conversation is direct or a group chat. + +This story adds presentation of the existing edited state. It does not add another edit API, change edit eligibility, alter message content, or introduce a new edit workflow. A message must be marked edited only when the existing edit/store path has accepted the edit. + +## Acceptance Criteria + +1. A successfully edited message sent by this account displays a clear edited indication in its message bubble. +2. A successfully edited message received from another participant displays the same clear edited indication, while preserving the existing sender and message layout. +3. The indication appears in both direct chats and group chats, without changing group sender names, avatars, mentions, replies, or other existing message metadata. +4. An unedited message does not display the indication. A revoked message, failed edit, canceled edit, or message whose edit has not been accepted by the existing store/history path is not presented as edited. +5. The indication remains associated with the exact edited message when several messages are visible, including adjacent own and received messages; an edit event must not mark another message in the same chat. +6. After the conversation is rebuilt from the existing durable/history path, the edited indication remains consistent with the stored edited state for both own and received messages. +7. The indication uses the existing GUI theme, typography, spacing, localization conventions, and message-bubble layout. No new edit controls, message actions, transport/API, or media-editing behavior are introduced by this story. +8. Automated tests cover successful own and received edits in direct and group-chat message models, unedited/revoked/failed-edit states, exact-message association with multiple messages, and rebuilt/history-rendered state. +9. Repository Cargo formatting, clippy, and workspace tests pass; manual desktop smoke verifies the indication for an own edited message and a received edited message in both a direct and a group conversation where available. + +## Test Seams + +- Existing durable/live edited-message state -> message model/history merge -> own and received bubble rendering; assert the indication appears only for the edited message. +- Direct and group chat message fixtures -> bubble layout with sender metadata, reply/mention context, and adjacent messages; assert the edited marker does not alter unrelated metadata. +- Rebuilt conversation/history hydration -> same edited state -> same visible indication; failed/canceled/revoked paths remain unmarked. + +## Tasks / Subtasks + +- [x] Identify the existing persisted/live edited-state field and expose it to the message-bubble presentation without creating a parallel state. (AC: 1-6) +- [x] Render a clear edited indication for own and received bubbles in direct and group chats using existing theme/layout/localization conventions. (AC: 1-3, 7) +- [x] Preserve unedited, failed, canceled, and revoked rendering behavior. (AC: 4-6) +- [x] Add regressions for both authorship cases, both chat types, exact-message targeting, adjacent messages, and durable/history rebuild. (AC: 8) +- [x] Run Cargo formatting, workspace Clippy, workspace tests, and a release build; update this story's checklist, Dev Agent Record, and File List with actual results. (AC: 9) +- [ ] Manually verify own and received edited messages in direct and group chats where available. (AC: 9) + +## Dev Notes + +- Reuse the edit state produced by the existing message/store flow from `docs/stories/1.6.story.md`; do not infer edited status from text differences or timestamps. +- Keep the behavior symmetric for own and received messages. Group rendering must retain existing sender identity and message metadata. +- Do not expand the scope to editing media, captions, incoming-message moderation, or a new edit transport. Those require separate requirements if needed. +- Keep test fixtures synthetic; do not add real phone numbers, JIDs, names, or message content. +- Preserve the repository's Cargo quality gates from `AGENTS.md`; the ancestor AIOX npm commands do not apply to this Rust workspace. +- Keep the build cache until the user has tested the installed build and explicitly approves cleanup. + +## CodeRabbit Integration + +> **CodeRabbit Integration**: Not configured +> +> This repository has no `.aiox-core/core-config.yaml`. Use the repository's Cargo, architecture-review, and CI gates. + +## Story Draft Checklist + +- [x] The user's request is limited to a visible edited-message indication. +- [x] Own/received and direct/group cases are explicit. +- [x] Success, persistence, unedited, failed, canceled, and revoked states are testable. +- [x] Existing edit behavior and unrelated message features remain out of scope. +- [x] Implementation, automated validation, and File List remain for `@dev` to record. + +## Change Log + +| Date | Version | Description | Author | +| --- | --- | --- | --- | +| 2026-09-21 | 0.1 | Created story from the user's request to identify edited own and received messages in direct and group chats. | River (@sm) | +| 2026-09-21 | 0.2 | Propagated durable edited state to the message model and bubble, with regression tests; workspace gates and live smoke pending. | @dev | +| 2026-09-21 | 0.3 | Passed workspace gates and release build; installed and launched updated macOS app. Real-chat observation awaits user confirmation. | @dev | + +## Dev Agent Record + +### Agent Model Used + +Sol (xhigh), integrated by @dev. + +### Debug Log References + +`cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-features -q`; targeted core/session/IPC/GUI tests for edited-state hydration, merge, wire compatibility, exact bubble rendering, and accepted own edits (all passed); `cargo build --release --bin oxidezap --bin oxidezapd`; `codesign --verify --deep --strict --verbose=2 /Applications/OxideZap.app`; app opened with GUI and daemon processes. Manual WhatsApp smoke is pending. + +### Completion Notes List + +- The store's existing `edited_at_ms` is exposed as a serialized-default `ChatMessage.edited` boolean; history hydration covers own/received messages in direct and group chats without a new transport or edit request. +- The GUI sets that fact after the existing successful edit response and renders `editada` alongside the bubble time for both authorship directions, hiding it on revoked rows. Cancel and failure keep it unset. +- A stale history page cannot unset an accepted marker on the same author's row, nor transfer it to a different group author on an ID collision; the durable row remains authoritative for content and timestamp. Test fixtures contain synthetic identities only. +- Full workspace gates and installation passed. Manual observation of actual edited messages in WhatsApp remains for the user. + +### File List + +- `docs/stories/1.8.story.md` — implementation and validation record. +- `crates/core/src/chat/{message,merge}.rs` — edited flag, serde default, and monotonic history merge tests. +- `crates/session/src/whatsapp/{convert,mod,tests}.rs` — durable edited-state hydration and own/peer direct/group tests. +- `crates/gui/src/app/{message_actions,messages}.rs` — mark accepted own edits and test exact bubble association. +- `crates/gui/src/components/message_bubble/mod.rs` — symmetric edited label beside time. +- `crates/ipc/tests/session_frames.rs` — edited field omitted by default and retained on wire when true. + +## QA Results + +_To be completed by @qa._ From 9b7c914c909939b7bdd6e2f66b97940200835713 Mon Sep 17 00:00:00 2001 From: Assis Date: Tue, 22 Sep 2026 11:50:55 -0300 Subject: [PATCH 4/7] fix(chat): address CodeRabbit reliability review Prevent stale archive and read state during asynchronous sync, and retain notification clicks through chat hydration. Keep macOS alerts ordered and audible where notification policy permits. --- crates/chat-store/src/store/event.rs | 11 +- crates/chat-store/src/store/history_sync.rs | 6 +- crates/chat-store/tests/history_sync.rs | 70 +++ crates/daemon/src/session_bridge/act.rs | 10 +- crates/daemon/src/session_bridge/tests.rs | 23 + crates/daemon/src/session_bridge/translate.rs | 3 + crates/gui/src/app/mod.rs | 421 +++++++++++++++++- crates/gui/src/app/paging.rs | 320 +++++++++++-- crates/gui/src/main.rs | 2 + crates/gui/src/platform/notifications.rs | 140 +++++- crates/gui/src/views/settings/panes.rs | 7 +- crates/session/src/whatsapp/history.rs | 7 +- crates/session/src/whatsapp/mod.rs | 30 +- crates/session/src/whatsapp/tests.rs | 49 ++ docs/stories/1.10.story.md | 127 ++++++ 15 files changed, 1171 insertions(+), 55 deletions(-) create mode 100644 docs/stories/1.10.story.md diff --git a/crates/chat-store/src/store/event.rs b/crates/chat-store/src/store/event.rs index 627e4a55..e37acf75 100644 --- a/crates/chat-store/src/store/event.rs +++ b/crates/chat-store/src/store/event.rs @@ -168,7 +168,11 @@ pub(super) fn apply_event( Ok(()) } Event::MuteUpdate(update) => { - let muted_until = if update.action.muted.unwrap_or(false) { + let Some(muted) = update.action.muted else { + // Missing is not an explicit unmute or app-state authority. + return Ok(()); + }; + let muted_until = if muted { // Absent or non-positive (WA Web sends -1 for indefinite, // this crate's own mute_chat() included) = muted forever. Some( @@ -202,9 +206,12 @@ pub(super) fn apply_event( Ok(()) } Event::ArchiveUpdate(update) => { + let Some(archived) = update.action.archived else { + // Missing is not an explicit unarchive. + return Ok(()); + }; let chat = crate::lid::route_chat_key(conn, device_id, &update.jid.to_string(), cs)?; ensure_chat(conn, device_id, &chat)?; - let archived = update.action.archived.unwrap_or(false); let (stored, seen): (bool, bool) = chat_row(device_id, &chat) .select(( schema::chats::archived, diff --git a/crates/chat-store/src/store/history_sync.rs b/crates/chat-store/src/store/history_sync.rs index e874aed8..5cebf58e 100644 --- a/crates/chat-store/src/store/history_sync.rs +++ b/crates/chat-store/src/store/history_sync.rs @@ -130,7 +130,11 @@ fn apply_history_conversation( "CASE WHEN mute_appstate_seen THEN muted_until ELSE excluded.muted_until END", )), dsl::archived.eq(diesel::dsl::sql::( - "CASE WHEN archive_appstate_seen THEN archived ELSE excluded.archived END", + if conv.archived.is_some() { + "CASE WHEN archive_appstate_seen THEN archived ELSE excluded.archived END" + } else { + "archived" + }, )), )) .execute(conn)?; diff --git a/crates/chat-store/tests/history_sync.rs b/crates/chat-store/tests/history_sync.rs index a284ec07..e0c446c3 100644 --- a/crates/chat-store/tests/history_sync.rs +++ b/crates/chat-store/tests/history_sync.rs @@ -105,6 +105,76 @@ async fn history_prefills_live_row_without_overwriting_appstate() { assert!(state.allowed); } +#[tokio::test] +async fn missing_preference_options_do_not_unmute_or_unarchive() { + let (_store, chat_store) = test_store().await; + let snapshot = |archived: Option| { + history_sync_event(wa::HistorySync { + sync_type: wa::history_sync::HistorySyncType::RECENT, + conversations: vec![wa::Conversation { + id: PEER.into(), + conversation_timestamp: Some(1_700_000_000), + mute_end_time: Some(1_900_000_000), + archived, + ..Default::default() + }], + ..Default::default() + }) + }; + feed(&chat_store, [snapshot(Some(true))]).await; + feed( + &chat_store, + [ + Event::MuteUpdate( + wacore::types::events::MuteUpdate::builder() + .jid(jid(PEER)) + .timestamp(ts(1_700_000_100)) + .action(Box::new(wa::sync_action_value::MuteAction::default())) + .from_full_sync(false) + .build(), + ), + Event::ArchiveUpdate( + wacore::types::events::ArchiveUpdate::builder() + .jid(jid(PEER)) + .timestamp(ts(1_700_000_100)) + .action(Box::new(wa::sync_action_value::ArchiveChatAction::default())) + .from_full_sync(false) + .build(), + ), + ], + ) + .await; + let state = chat_store + .notification_metadata(&jid(PEER)) + .await + .unwrap() + .unwrap(); + assert!(state.muted); + assert!(state.archived); + + feed(&chat_store, [snapshot(None)]).await; + assert!( + chat_store + .notification_metadata(&jid(PEER)) + .await + .unwrap() + .unwrap() + .archived + ); + + // An explicit false from history still applies: the missing actions + // above must not have marked either preference app-state authoritative. + feed(&chat_store, [snapshot(Some(false))]).await; + assert!( + !chat_store + .notification_metadata(&jid(PEER)) + .await + .unwrap() + .unwrap() + .archived + ); +} + #[tokio::test] async fn history_sync_materializes_without_clobbering_live_rows() { let (_store, chat_store) = test_store().await; diff --git a/crates/daemon/src/session_bridge/act.rs b/crates/daemon/src/session_bridge/act.rs index 629ca88f..121614e8 100644 --- a/crates/daemon/src/session_bridge/act.rs +++ b/crates/daemon/src/session_bridge/act.rs @@ -247,14 +247,16 @@ impl Bridge { // rows here would resurrect them as ordinary // placeholder chats on the next attach. if chat.archived { - if !hub.apply_for( + hub.apply_for( asked_as, Change::from_store(DaemonEvent::ChatRemoved { jid: chat.jid.clone(), }), - ) { - reads.forget(&chat.jid); - } + ); + // An archived row has no active read + // boundary, even if this generation's + // removal was accepted by the hub. + reads.forget(&chat.jid); continue; } // Asked and written under one lock, so a diff --git a/crates/daemon/src/session_bridge/tests.rs b/crates/daemon/src/session_bridge/tests.rs index 619e1c51..d818545e 100644 --- a/crates/daemon/src/session_bridge/tests.rs +++ b/crates/daemon/src/session_bridge/tests.rs @@ -256,6 +256,29 @@ fn a_complete_reload_still_prunes_what_the_store_dropped() { ); } +#[test] +fn an_inactive_chat_does_not_retain_a_live_read_boundary() { + let jid = "2@s.whatsapp.net"; + let mut bridge = bridge(); + bridge.observe(loaded(vec![stored_chat( + jid, + 1, + vec![message("stored", jid, 10, false, false)], + )])); + assert!(bridge.reads().boundary(jid).is_some()); + + bridge.observe(loaded(Vec::new())); + assert!(bridge.hub.chat_is_inactive(jid)); + assert!(bridge.reads().boundary(jid).is_none()); + + bridge.observe(received(jid, message("late", jid, 11, false, false), None)); + assert!(bridge.hub.chat(jid).is_none()); + assert!( + bridge.reads().boundary(jid).is_none(), + "the suppressed live event was observed before translation" + ); +} + /// A pairing code expires. A client that is handed the state late must be /// able to tell, which a relative "expires in N" replayed in a snapshot /// cannot express. diff --git a/crates/daemon/src/session_bridge/translate.rs b/crates/daemon/src/session_bridge/translate.rs index a3738ccc..d329187d 100644 --- a/crates/daemon/src/session_bridge/translate.rs +++ b/crates/daemon/src/session_bridge/translate.rs @@ -304,6 +304,9 @@ impl Bridge { // store is deciding whether the message unarchived/recreated // it; a store-backed ChatUpdated clears the marker shortly. if self.hub.chat(&chat_jid).is_none() && self.hub.chat_is_inactive(&chat_jid) { + // `observe` recorded this message before translation. + // It cannot be a read boundary for an inactive chat. + self.reads().forget(&chat_jid); return Vec::new(); } let mut summary = self.hub.chat(&chat_jid).unwrap_or_else(|| ChatSummary { diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index 65e50bce..15a37907 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -286,7 +286,7 @@ pub use status::{Destination, StatusPane}; pub use viewer::MediaViewer; use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use indexmap::IndexMap; @@ -689,6 +689,24 @@ pub struct WhatsAppApp { /// notification is a user-visible side effect rather than an idempotent /// timeline merge. notified_messages: IndexMap<(String, String, String), ()>, + /// The window handle used to finish opening a notification after its chat + /// arrives. A notification response can beat the first chat snapshot (or + /// a later archived page), so resolving only against `chats` at click time + /// would silently lose the action. + notification_window: Option, + /// Clicked notifications whose chats are not hydrated in this window yet. + /// Keep them in click order so a burst of responses is retried one by one; + /// the newest click is consequently the final selection when both arrive. + pending_notification_tags: VecDeque, + /// The most recent click wins focus. Older queued tags are still retried, + /// but resolving one after a newer click must never navigate backward. + latest_notification_tag: Option, + /// Backoff task for a chat-list page refusal. A refused page otherwise + /// leaves a pending click waiting forever, while retrying inline can spin + /// if the daemon keeps refusing it. + #[allow(dead_code)] + notification_retry_task: Option>, + notification_retry_backoff: std::time::Duration, /// Whether the last gesture that touched a conversation was someone /// meaning to *talk* to it or meaning to *look* at it. /// @@ -945,6 +963,33 @@ struct IncomingAlert { archived: Option, } +const NOTIFICATION_PAGE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(150); +const MAX_NOTIFICATION_PAGE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(30); + +fn next_notification_retry_delay(current: std::time::Duration) -> std::time::Duration { + current + .checked_mul(2) + .unwrap_or(MAX_NOTIFICATION_PAGE_RETRY_DELAY) + .min(MAX_NOTIFICATION_PAGE_RETRY_DELAY) +} + +/// Add a notification response once, retaining the newest occurrence at the +/// back of the FIFO. A repeated click is a new ordering signal, not a second +/// request for the same tag. +fn enqueue_pending_notification(queue: &mut VecDeque, tag: &str) { + if let Some(index) = queue.iter().position(|queued| queued == tag) { + queue.remove(index); + } + queue.push_back(tag.to_string()); +} + +fn remove_pending_notification(queue: &mut VecDeque, tag: &str) -> bool { + let Some(index) = queue.iter().position(|queued| queued == tag) else { + return false; + }; + queue.remove(index).is_some() +} + impl IncomingAlert { fn new(allowed: bool, title: Option, archived: Option) -> Self { Self { @@ -1073,7 +1118,18 @@ impl WhatsAppApp { &*event, UiEvent::MessageReceived { .. } | UiEvent::HistoryLoaded { .. } ); + let hydration = matches!(&*event, UiEvent::HistoryLoaded { .. }); app.handle_event(*event, cx); + if hydration { + app.reset_notification_retry_backoff(); + } + // A notification click may have arrived before the + // list/history event that owns its chat. Retrying + // after every session event is cheap when there is no + // pending tag, and makes HistoryLoaded a valid retry + // point without coupling the paging module to window + // handles. + app.retry_pending_notification(cx); if bearing { app.sweep_retained_media(cx); } @@ -1140,6 +1196,8 @@ impl WhatsAppApp { archived, } => entity.update(cx, |app, cx| { app.apply_chat_page(chats, next, archived, cx); + app.reset_notification_retry_backoff(); + app.retry_pending_notification(cx); }), // Who is in a group, for the line under its name. FromDaemon::Members(roster) => entity.update(cx, |app, cx| { @@ -1159,7 +1217,11 @@ impl WhatsAppApp { app.draw_waiting_call_frames(cx); }), FromDaemon::PageLost { jid, archived } => entity.update(cx, |app, cx| { + let chat_list_page = jid.is_none(); app.page_lost(jid, archived, cx); + if chat_list_page { + app.retry_pending_notification_after_page_loss(cx); + } }), FromDaemon::StatusViewLost(message_ids) => entity.update(cx, |app, cx| { app.forget_status_views(&message_ids, cx); @@ -1232,6 +1294,11 @@ impl WhatsAppApp { window_focused: false, window_activation: None, notified_messages: IndexMap::new(), + notification_window: None, + pending_notification_tags: VecDeque::new(), + latest_notification_tag: None, + notification_retry_task: None, + notification_retry_backoff: NOTIFICATION_PAGE_RETRY_DELAY, // Nothing has been opened to talk to yet, and a window that comes // up on a restored selection is one nobody has typed into. keyboard_intent: ChatOpen::ToPreview, @@ -1862,6 +1929,10 @@ impl WhatsAppApp { self.pending_pastes.clear(); self.paste_preview = None; self.notified_messages.clear(); + self.pending_notification_tags.clear(); + self.latest_notification_tag = None; + self.notification_retry_task = None; + self.notification_retry_backoff = NOTIFICATION_PAGE_RETRY_DELAY; // A call is account state as much as a chat is. See // [`calls_ctl::Calls::forget`]. self.calls.update(cx, |calls, cx| calls.forget(cx)); @@ -3209,6 +3280,120 @@ impl WhatsAppApp { } } + /// Give notification responses a handle that remains usable after the + /// click callback returns. Hydration can finish later, so keeping only the + /// callback's `&mut Window` would make a retry impossible. + pub fn set_notification_window(&mut self, window: gpui::AnyWindowHandle) { + self.notification_window = Some(window); + } + + /// Retry queued notification responses after a chat/list hydration pass. + /// + /// The retry is deferred until the current entity update unwinds. GPUI + /// does not permit re-entering an entity while it is applying an event, + /// and the stored [`WindowHandle`] is the supported way to obtain a live + /// `Window` for the normal selection path. The FIFO is drained in order: + /// an older unresolved tag cannot let a newer click be overwritten by a + /// late selection, and the newest queued tag is the final selection. + pub(super) fn retry_pending_notification(&mut self, cx: &mut Context) { + if self.pending_notification_tags.is_empty() { + self.notification_retry_task = None; + self.notification_retry_backoff = NOTIFICATION_PAGE_RETRY_DELAY; + return; + } + let Some(window) = self.notification_window else { + return; + }; + + let entity = cx.entity().downgrade(); + cx.defer(move |cx| { + let tags = entity + .update(cx, |app, _| { + app.pending_notification_tags + .iter() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + for tag in tags { + let result = window.update(cx, |_, window, cx| { + entity + .update(cx, |app, cx| { + app.retry_queued_notification(&tag, window, cx) + }) + .unwrap_or(false) + }); + if result.is_err() { + // A closed window leaves the + // queue intact. A later hydration/page-loss event can + // retry it without losing a response. + break; + } + } + }); + } + + /// Retry one queued tag. Every snapshot entry is attempted once so an + /// unresolved older click cannot block a newer chat that already + /// hydrated. + fn retry_queued_notification( + &mut self, + tag: &str, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self + .pending_notification_tags + .iter() + .any(|queued| queued == tag) + { + return true; + } + if let Some(jid) = notification_chat_jid(&self.chats, tag) { + let should_select = self.latest_notification_tag.as_deref() == Some(tag); + remove_pending_notification(&mut self.pending_notification_tags, tag); + self.reset_notification_retry_backoff(); + if should_select { + self.select_chat(jid, ChatOpen::ToPreview, window, cx); + } + return true; + } + if self + .request_notification_pages(cx) + .is_some_and(|plan| plan.done()) + { + // Both complete scans say the tag is not held by this account. + remove_pending_notification(&mut self.pending_notification_tags, tag); + self.reset_notification_retry_backoff(); + return true; + } + false + } + + fn reset_notification_retry_backoff(&mut self) { + self.notification_retry_task = None; + self.notification_retry_backoff = NOTIFICATION_PAGE_RETRY_DELAY; + } + + /// Back off a notification retry after a chat-list page refusal. One task + /// serves the queue, so repeated failures cannot create a tight retry + /// loop or duplicate requests. + pub(super) fn retry_pending_notification_after_page_loss(&mut self, cx: &mut Context) { + if self.pending_notification_tags.is_empty() || self.notification_retry_task.is_some() { + return; + } + let delay = self.notification_retry_backoff; + self.notification_retry_backoff = next_notification_retry_delay(delay); + let entity = cx.entity().downgrade(); + self.notification_retry_task = Some(cx.spawn(async move |_, cx| { + crate::platform::sleep(delay).await; + let _ = entity.update(cx, |app, cx| { + app.notification_retry_task = None; + app.retry_pending_notification(cx); + }); + })); + } + /// Open the conversation named by a system-notification response. /// /// The tag carries only a stable hash, not a phone number or JID. Resolve @@ -3220,15 +3405,22 @@ impl WhatsAppApp { window: &mut Window, cx: &mut Context, ) { - let Some(jid) = self - .chats - .iter() - .find(|chat| notification_tag(&chat.jid) == tag) - .map(|chat| chat.jid.clone()) - else { + self.latest_notification_tag = Some(tag.to_string()); + if let Some(jid) = notification_chat_jid(&self.chats, tag) { + remove_pending_notification(&mut self.pending_notification_tags, tag); + self.reset_notification_retry_backoff(); + self.select_chat(jid, ChatOpen::ToPreview, window, cx); + // An older click may still be waiting; it remains queued for + // hydration but can no longer steal focus from this newer click. + self.retry_pending_notification(cx); return; - }; - self.select_chat(jid, ChatOpen::ToPreview, window, cx); + } + // The response may arrive before the first HistoryLoaded/Chats page, + // or before the archived page that contains this chat. Queue it while + // unresolved; the hydrated branch above opens it immediately even if + // an older click is still waiting. + enqueue_pending_notification(&mut self.pending_notification_tags, tag); + self.retry_pending_notification(cx); } /// A server acknowledgement or peer receipt about our own messages: @@ -3531,6 +3723,13 @@ fn notification_tag(jid: &str) -> String { format!("oxidezap-chat-{hash:016x}") } +fn notification_chat_jid(chats: &[Arc], tag: &str) -> Option { + chats + .iter() + .find(|chat| notification_tag(&chat.jid) == tag) + .map(|chat| chat.jid.clone()) +} + /// The newest message in `chat` that the daemon has also seen. /// /// Which excludes a send this window has drawn but not yet had named, as well @@ -3916,6 +4115,210 @@ mod tests { assert!(read_is_allowed(true, true, true, false)); } + #[test] + fn a_notification_tag_can_be_resolved_after_chat_hydration() { + let jid = "archived@example.invalid"; + let tag = notification_tag(jid); + let mut chats = Vec::new(); + + // A response can arrive before either the active or archived page. + assert_eq!(notification_chat_jid(&chats, &tag), None); + + let mut archived = Chat::with_name(jid.into(), "Archived contact".into()); + archived.archived = true; + chats.push(Arc::new(archived)); + assert_eq!(notification_chat_jid(&chats, &tag).as_deref(), Some(jid)); + } + + #[test] + fn notification_clicks_retry_in_fifo_order_with_newest_last() { + let mut queue = VecDeque::new(); + enqueue_pending_notification(&mut queue, "chat-a"); + enqueue_pending_notification(&mut queue, "chat-b"); + + assert_eq!(queue.pop_front().as_deref(), Some("chat-a")); + assert_eq!(queue.pop_front().as_deref(), Some("chat-b")); + } + + #[test] + fn notification_click_queue_is_deduplicated_without_dropping_clicks() { + let mut queue = VecDeque::new(); + enqueue_pending_notification(&mut queue, "chat-a"); + enqueue_pending_notification(&mut queue, "chat-b"); + enqueue_pending_notification(&mut queue, "chat-a"); + assert_eq!( + queue.iter().map(String::as_str).collect::>(), + ["chat-b", "chat-a"] + ); + enqueue_pending_notification(&mut queue, "chat-c"); + assert_eq!(queue.len(), 3); + assert_eq!(queue.back().map(String::as_str), Some("chat-c")); + } + + #[test] + fn notification_page_retry_backoff_doubles_and_is_capped() { + let mut delay = NOTIFICATION_PAGE_RETRY_DELAY; + assert_eq!(delay, std::time::Duration::from_millis(150)); + for expected in [300, 600, 1_200, 2_400, 4_800, 9_600, 19_200] { + delay = next_notification_retry_delay(delay); + assert_eq!(delay, std::time::Duration::from_millis(expected)); + } + assert_eq!( + next_notification_retry_delay(delay), + MAX_NOTIFICATION_PAGE_RETRY_DELAY + ); + assert_eq!( + next_notification_retry_delay(MAX_NOTIFICATION_PAGE_RETRY_DELAY), + MAX_NOTIFICATION_PAGE_RETRY_DELAY + ); + } + + #[gpui::test] + fn queued_notification_clicks_select_newest_after_both_chats_hydrate( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| { + cx.set_app_identity("org.oxidezap.test", "OxideZap Test"); + gpui_component::init(cx); + crate::theme::init(cx); + init_app_bindings(cx); + }); + let mut app_entity = None; + let window = cx.open_window(gpui::size(gpui::px(1000.), gpui::px(800.)), |window, cx| { + let app = cx.new(|cx| { + let mut app = WhatsAppApp::new(cx); + app.app_state = AppState::Connected; + app.destination = Destination::Chats; + app + }); + app_entity = Some(app.clone()); + gpui_component::Root::new(app, window, cx) + }); + let app = app_entity.unwrap(); + let mut cx = gpui::VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let first_jid = "first@example.invalid"; + let second_jid = "second@example.invalid"; + let first_tag = notification_tag(first_jid); + let second_tag = notification_tag(second_jid); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + app.update(cx, |app, cx| { + app.set_notification_window(window.window_handle()); + app.open_system_notification(&first_tag, window, cx); + app.open_system_notification(&second_tag, window, cx); + assert_eq!( + app.pending_notification_tags.iter().collect::>(), + [&first_tag, &second_tag] + ); + }); + }); + // Both responses arrived before either row. Hydrate only the newer + // row first: an unresolved older entry must not block it. + cx.run_until_parked(); + cx.update(|window, cx| { + app.update(cx, |app, cx| { + app.chats.push(Arc::new(Chat::new(second_jid.to_string()))); + app.retry_pending_notification(cx); + }); + window.draw(cx).clear(cx); + }); + cx.run_until_parked(); + cx.read(|cx| { + assert_eq!(app.read(cx).selected_chat.as_deref(), Some(second_jid)); + assert_eq!( + app.read(cx) + .pending_notification_tags + .iter() + .collect::>(), + [&first_tag] + ); + }); + + // The older tag is eventually consumed, but latest-click-wins keeps + // it from navigating away from the newer selection. + cx.update(|window, cx| { + app.update(cx, |app, cx| { + app.chats.push(Arc::new(Chat::new(first_jid.to_string()))); + app.retry_pending_notification(cx); + }); + window.draw(cx).clear(cx); + }); + cx.run_until_parked(); + cx.read(|cx| { + assert_eq!(app.read(cx).selected_chat.as_deref(), Some(second_jid)); + assert!(app.read(cx).pending_notification_tags.is_empty()); + }); + } + + #[gpui::test] + fn a_hydrated_newer_notification_opens_without_waiting_on_an_older_one( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| { + cx.set_app_identity("org.oxidezap.test", "OxideZap Test"); + gpui_component::init(cx); + crate::theme::init(cx); + init_app_bindings(cx); + }); + let mut app_entity = None; + let window = cx.open_window(gpui::size(gpui::px(1000.), gpui::px(800.)), |window, cx| { + let app = cx.new(|cx| { + let mut app = WhatsAppApp::new(cx); + app.app_state = AppState::Connected; + app.destination = Destination::Chats; + app + }); + app_entity = Some(app.clone()); + gpui_component::Root::new(app, window, cx) + }); + let app = app_entity.unwrap(); + let mut cx = gpui::VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let older_jid = "older@example.invalid"; + let newer_jid = "newer@example.invalid"; + let older_tag = notification_tag(older_jid); + let newer_tag = notification_tag(newer_jid); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + app.update(cx, |app, cx| { + app.set_notification_window(window.window_handle()); + app.open_system_notification(&older_tag, window, cx); + }); + }); + cx.run_until_parked(); + + cx.update(|window, cx| { + app.update(cx, |app, cx| { + app.chats.push(Arc::new(Chat::new(newer_jid.to_string()))); + app.open_system_notification(&newer_tag, window, cx); + assert_eq!(app.selected_chat.as_deref(), Some(newer_jid)); + assert_eq!( + app.pending_notification_tags.iter().collect::>(), + [&older_tag] + ); + }); + window.draw(cx).clear(cx); + }); + + // The older tag is still retried, but its late resolution cannot + // steal focus from the newer click. + cx.update(|window, cx| { + app.update(cx, |app, cx| { + app.chats.push(Arc::new(Chat::new(older_jid.to_string()))); + app.retry_pending_notification(cx); + }); + window.draw(cx).clear(cx); + }); + cx.run_until_parked(); + cx.read(|cx| { + assert_eq!(app.read(cx).selected_chat.as_deref(), Some(newer_jid)); + assert!(app.read(cx).pending_notification_tags.is_empty()); + }); + } + #[gpui::test] fn an_incoming_message_outside_the_active_chat_raises_one_system_notification( cx: &mut gpui::TestAppContext, diff --git a/crates/gui/src/app/paging.rs b/crates/gui/src/app/paging.rs index 7566c64d..1553af43 100644 --- a/crates/gui/src/app/paging.rs +++ b/crates/gui/src/app/paging.rs @@ -10,7 +10,7 @@ //! What a cursor *is* stays the daemon's business. This side holds the last //! one it was given and hands it back. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use gpui::{App, Context}; use log::debug; @@ -47,6 +47,38 @@ pub(super) enum Paging { Done { from: Option }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChatPageArrival { + Ignored, + Applied, + Restarted, +} + +/// Requests needed to hydrate both chat lists for an unresolved notification. +/// +/// A click can race the initial active snapshot, while the target itself may +/// live only in the include-archived list. Neither list being done proves the +/// tag is absent from the other one, so the caller keeps the response pending +/// until both have reached their terminal state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct NotificationPagePlan { + /// A request for the active list, if it was not already in flight/done. + /// The inner `None` asks from the top; `Some(cursor)` continues a page. + pub(super) active: Option>, + /// The equivalent request for the include-archived list. + pub(super) archived: Option>, + /// True once the active list's complete hydration has arrived. + pub(super) active_done: bool, + /// True once the archived list's complete hydration has arrived. + pub(super) archived_done: bool, +} + +impl NotificationPagePlan { + pub(super) fn done(&self) -> bool { + self.active_done && self.archived_done + } +} + impl Paging { /// The cursor to ask with, when asking is the right thing to do. /// @@ -109,6 +141,9 @@ pub(super) struct Pages { chats: Paging, /// The include-archived list has a different ordering window and cursor. archived_chats: Paging, + /// A history change while an archived page is in flight invalidates its + /// ordering. Let the response settle, then start a fresh scan at the top. + restart_archived_after_load: bool, } impl Pages { @@ -117,6 +152,7 @@ impl Pages { timelines: TimelinePages::new(), chats: Paging::default(), archived_chats: Paging::default(), + restart_archived_after_load: false, } } @@ -166,6 +202,20 @@ impl Pages { Some(ask) } + /// Advance both chat lists for a notification response. This is + /// deliberately separate from the rendered filter: a click is an + /// explicit request for one chat, even while the active list is shown. + fn notification_page_plan(&mut self) -> NotificationPagePlan { + let active = self.more_chats(false); + let archived = self.more_chats(true); + NotificationPagePlan { + active, + archived, + active_done: matches!(self.chats, Paging::Done { .. }), + archived_done: matches!(self.archived_chats, Paging::Done { .. }), + } + } + /// Settle a conversation's position on the page that arrived, saying /// whether anything was waiting for it. /// @@ -184,26 +234,37 @@ impl Pages { } /// The same, for the chat list. - fn chat_page_arrived(&mut self, archived: bool, next: Option) -> bool { - let list = self.chat_list(archived); - let Paging::Loading { from } = list else { - return false; + fn chat_page_arrived(&mut self, archived: bool, next: Option) -> ChatPageArrival { + let Paging::Loading { from } = self.chat_list(archived) else { + return ChatPageArrival::Ignored; }; - *list = Paging::arrived(from.clone(), next); - true + let asked_from = from.clone(); + if archived && std::mem::take(&mut self.restart_archived_after_load) { + self.archived_chats = Paging::Unasked; + return ChatPageArrival::Restarted; + } + *self.chat_list(archived) = Paging::arrived(asked_from, next); + ChatPageArrival::Applied } /// A page that was refused. Put the position back so it can be asked for /// again; a list that stayed `Loading` would never ask anything again. - fn lost(&mut self, jid: Option<&str>, archived: bool) { + /// Return whether a history-triggered archived restart is ready to ask. + fn lost(&mut self, jid: Option<&str>, archived: bool) -> bool { match jid { Some(jid) => { let paging = self.timelines.entry(jid.to_string()).or_default(); *paging = paging.lost(); + false } None => { + if archived && std::mem::take(&mut self.restart_archived_after_load) { + self.archived_chats = Paging::Unasked; + return true; + } let list = self.chat_list(archived); *list = list.lost(); + false } } } @@ -232,7 +293,9 @@ impl Pages { // in-flight answer has settled; a request already on the wire keeps // its position so an older response cannot be mistaken for a newer // one. - if !matches!(self.archived_chats, Paging::Loading { .. }) { + if matches!(self.archived_chats, Paging::Loading { .. }) { + self.restart_archived_after_load = true; + } else { self.archived_chats = Paging::Unasked; } } @@ -242,9 +305,23 @@ impl Pages { self.timelines.clear(); self.chats = Paging::Unasked; self.archived_chats = Paging::Unasked; + self.restart_archived_after_load = false; } } +/// Keep active-list removals from a complete history load, but replace the +/// archived-list debt with what this completed scan actually deferred. A JID +/// re-found by the scan no longer owes removal when its view closes. +fn reconcile_departures( + departed: &mut HashSet, + active: &HashSet<&str>, + seen: &HashSet, + archived_deferred: Vec, +) { + departed.retain(|jid| active.contains(jid.as_str()) && !seen.contains(jid)); + departed.extend(archived_deferred); +} + impl WhatsAppApp { /// Ask for a conversation's newest page, if nobody has yet. /// @@ -301,6 +378,31 @@ impl WhatsAppApp { } } + /// Ask both chat lists for the next page on behalf of a system + /// notification click. Unlike [`Self::want_more_chats`], this does not + /// depend on the current sidebar filter or whether either list is visible. + pub(super) fn request_notification_pages( + &mut self, + cx: &mut App, + ) -> Option { + let Some(client) = &self.client else { + return None; + }; + let result = self + .pages + .update(cx, |pages, _| pages.notification_page_plan()); + if let Some(ask) = &result.active { + client.load_chats(ask.clone(), false); + } + if let Some(ask) = &result.archived { + if ask.is_none() { + self.archived_scan.clear(); + } + client.load_chats(ask.clone(), true); + } + Some(result) + } + /// Fold one page of a conversation into it. pub(super) fn apply_message_page( &mut self, @@ -361,12 +463,21 @@ impl WhatsAppApp { let archived_scan_finished = archived && next.is_none(); // The same rule, and the one that matters most: this page's rows go // into the list whether or not anything else remembers them. - if !self + match self .pages .update(cx, |pages, _| pages.chat_page_arrived(archived, next)) { - debug!("a chat page arrived that nobody asked for"); - return; + ChatPageArrival::Ignored => { + debug!("a chat page arrived that nobody asked for"); + return; + } + ChatPageArrival::Restarted => { + // This answer was ordered before the history change. The + // next request restarts the scan and clears its seen set. + cx.notify(); + return; + } + ChatPageArrival::Applied => {} } if chats.is_empty() { if archived_scan_finished { @@ -375,12 +486,11 @@ impl WhatsAppApp { return; } if archived { - self.archived_scan.extend( - chats - .iter() - .filter(|chat| chat.archived) - .map(|chat| chat.jid.clone()), - ); + // Include-archived pages also carry active rows. Remember all of + // them so a re-found JID cancels old deferred removal debt even + // if its archive flag changed since the previous scan. + self.archived_scan + .extend(chats.iter().map(|chat| chat.jid.clone())); } // The same entrance a history load uses: a page that merely called // `merge_chats` left a notice for a group that arrives only by page @@ -417,7 +527,18 @@ impl WhatsAppApp { } }); } - self.departed_chats.extend(deferred); + let active = self + .chats + .iter() + .filter(|chat| !chat.archived) + .map(|chat| chat.jid.as_str()) + .collect(); + reconcile_departures( + &mut self.departed_chats, + &active, + &self.archived_scan, + deferred, + ); if dropped.is_empty() { return; } @@ -429,9 +550,18 @@ impl WhatsAppApp { /// A page that was refused. Put the position back so it can be asked for /// again; a view that stayed `Loading` would never ask anything again. - pub(super) fn page_lost(&mut self, jid: Option, archived: bool, cx: &mut App) { - self.pages - .update(cx, |pages, _| pages.lost(jid.as_deref(), archived)); + pub(super) fn page_lost( + &mut self, + jid: Option, + archived: bool, + cx: &mut Context, + ) { + if self + .pages + .update(cx, |pages, _| pages.lost(jid.as_deref(), archived)) + { + cx.notify(); + } } /// Forget where these conversations continued. @@ -608,13 +738,99 @@ mod tests { assert_eq!(pages.more_chats(false), Some(None)); assert_eq!(pages.more_chats(true), Some(None)); - assert!(pages.chat_page_arrived(false, Some(cursor("active-next")))); - assert!(pages.chat_page_arrived(true, Some(cursor("archive-next")))); + assert_eq!( + pages.chat_page_arrived(false, Some(cursor("active-next"))), + ChatPageArrival::Applied + ); + assert_eq!( + pages.chat_page_arrived(true, Some(cursor("archive-next"))), + ChatPageArrival::Applied + ); assert_eq!(pages.more_chats(false), Some(Some(cursor("active-next")))); assert_eq!(pages.more_chats(true), Some(Some(cursor("archive-next")))); } + #[test] + fn a_notification_advances_active_and_archived_pages_without_the_filter() { + let mut pages = Pages::new(); + + let first = pages.notification_page_plan(); + assert_eq!(first.active, Some(None)); + assert_eq!(first.archived, Some(None)); + assert!(!first.done()); + + let in_flight = pages.notification_page_plan(); + assert_eq!(in_flight.active, None); + assert_eq!(in_flight.archived, None); + assert!( + !in_flight.done(), + "a click must not duplicate either request" + ); + + assert_eq!( + pages.chat_page_arrived(true, Some(cursor("archive-next"))), + ChatPageArrival::Applied + ); + let archive_next = pages.notification_page_plan(); + assert_eq!(archive_next.active, None); + assert_eq!(archive_next.archived, Some(Some(cursor("archive-next")))); + assert!(!archive_next.done()); + + assert_eq!( + pages.chat_page_arrived(true, None), + ChatPageArrival::Applied + ); + let archive_done = pages.notification_page_plan(); + assert!(archive_done.archived_done); + assert!(!archive_done.active_done); + assert!( + !archive_done.done(), + "archived completion does not prove the active list was hydrated" + ); + + assert_eq!( + pages.chat_page_arrived(false, Some(cursor("active-next"))), + ChatPageArrival::Applied + ); + let active_next = pages.notification_page_plan(); + assert_eq!(active_next.active, Some(Some(cursor("active-next")))); + assert_eq!(active_next.archived, None); + assert!(!active_next.done()); + + assert_eq!( + pages.chat_page_arrived(false, None), + ChatPageArrival::Applied + ); + let both_done = pages.notification_page_plan(); + assert!(both_done.active_done); + assert!(both_done.archived_done); + assert!(both_done.done(), "only both complete scans end the retry"); + } + + #[test] + fn a_notification_page_loss_reopens_only_the_failed_chat_list() { + let mut pages = Pages::new(); + let first = pages.notification_page_plan(); + assert_eq!(first.active, Some(None)); + assert_eq!(first.archived, Some(None)); + + // The archived request failed while the active request is still in + // flight. Only the archived cursor is put back, so retrying cannot + // duplicate the active request. + pages.lost(None, true); + let archived_retry = pages.notification_page_plan(); + assert_eq!(archived_retry.active, None); + assert_eq!(archived_retry.archived, Some(None)); + + // The active request can fail independently and is then re-asked at + // its own position as well. + pages.lost(None, false); + let active_retry = pages.notification_page_plan(); + assert_eq!(active_retry.active, Some(None)); + assert_eq!(active_retry.archived, None); + } + /// A load walks the store's order itself, so what it says about the end /// of the list beats anything a window could infer from the rows. #[test] @@ -861,7 +1077,10 @@ mod tests { pages.forget(); assert!(!pages.timeline_page_arrived(CHAT, None)); - assert!(!pages.chat_page_arrived(false, None)); + assert_eq!( + pages.chat_page_arrived(false, None), + ChatPageArrival::Ignored + ); } /// A refusal puts both lists back where they were asking from, so the @@ -944,4 +1163,55 @@ mod tests { "a newly archived recent chat can sort before the old cursor" ); } + + #[test] + fn an_archived_load_in_flight_restarts_after_its_stale_answer() { + let mut pages = Pages::new(); + assert_eq!(pages.more_chats(true), Some(None)); + pages.reopen(&[]); + assert_eq!(pages.more_chats(true), None, "wait for the old answer"); + assert_eq!( + pages.chat_page_arrived(true, None), + ChatPageArrival::Restarted, + "a stale terminal page must not finish the new scan" + ); + assert_eq!(pages.more_chats(true), Some(None)); + assert_eq!( + pages.chat_page_arrived(true, None), + ChatPageArrival::Applied + ); + } + + #[test] + fn a_lost_archived_load_with_pending_restart_starts_at_top() { + let mut pages = Pages::new(); + pages.more_chats(true); + pages.chat_page_arrived(true, Some(cursor("old-next"))); + assert_eq!(pages.more_chats(true), Some(Some(cursor("old-next")))); + pages.reopen(&[]); + assert!(pages.lost(None, true), "a new scan should be scheduled"); + assert_eq!(pages.more_chats(true), Some(None)); + } + + #[test] + fn a_new_archived_scan_cancels_old_deferred_removal() { + let mut departed = HashSet::from([ + "active@s.whatsapp.net".to_string(), + "restored@s.whatsapp.net".to_string(), + "reactivated@s.whatsapp.net".to_string(), + ]); + reconcile_departures( + &mut departed, + &HashSet::from(["active@s.whatsapp.net", "reactivated@s.whatsapp.net"]), + &HashSet::from([ + "restored@s.whatsapp.net".to_string(), + "reactivated@s.whatsapp.net".to_string(), + ]), + Vec::new(), + ); + assert_eq!( + departed, + HashSet::from(["active@s.whatsapp.net".to_string()]) + ); + } } diff --git a/crates/gui/src/main.rs b/crates/gui/src/main.rs index bed334c3..a07f5454 100644 --- a/crates/gui/src/main.rs +++ b/crates/gui/src/main.rs @@ -82,6 +82,7 @@ fn open_the_window() { let view = cx.new(WhatsAppApp::new); let notification_view = view.downgrade(); let notification_window = window.window_handle(); + let app_notification_window = notification_window; cx.on_system_notification_response(move |response, cx| { let tag = response.tag.to_string(); cx.dismiss_system_notification(&tag); @@ -111,6 +112,7 @@ fn open_the_window() { // and edits to an existing `theme.json` did nothing until // the next restart. view.update(cx, |app, cx| { + app.set_notification_window(app_notification_window); app.watch_theme_file(cx); // After the window exists, so the ten seconds a cold // start can spend waiting for a daemon to come up are diff --git a/crates/gui/src/platform/notifications.rs b/crates/gui/src/platform/notifications.rs index 69e96ae6..686ca86a 100644 --- a/crates/gui/src/platform/notifications.rs +++ b/crates/gui/src/platform/notifications.rs @@ -35,11 +35,11 @@ pub fn show_notification_with_avatar( #[cfg(target_os = "macos")] mod imp { - use std::collections::hash_map::DefaultHasher; + use std::collections::{HashMap, hash_map::DefaultHasher}; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::ptr::NonNull; - use std::sync::Arc; + use std::sync::{Arc, Mutex, OnceLock}; use block2::RcBlock; use objc2::rc::autoreleasepool; @@ -48,8 +48,46 @@ mod imp { use objc2_user_notifications::{ UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, UNNotificationAttachment, UNNotificationRequest, UNNotificationSettings, - UNUserNotificationCenter, + UNNotificationSound, UNUserNotificationCenter, }; + use portable_atomic::{AtomicU64, Ordering}; + + /// One submission lane per stable notification tag. Avatar reads happen + /// on workers, so an older read can otherwise finish after a newer one + /// and replace the newer banner. The generation check also covers a + /// failed attachment's plain retry, which is delivered asynchronously. + struct TagState { + generation: u64, + lane: Arc>, + } + + static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1); + static TAG_STATES: OnceLock>> = OnceLock::new(); + + fn begin_tag_submission(tag: &str) -> (u64, Arc>) { + let states = TAG_STATES.get_or_init(|| Mutex::new(HashMap::new())); + let mut states = states + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let state = states.entry(tag.to_string()).or_insert_with(|| TagState { + generation: 0, + lane: Arc::new(Mutex::new(())), + }); + state.generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed); + (state.generation, Arc::clone(&state.lane)) + } + + fn is_current_tag_submission(tag: &str, generation: u64) -> bool { + TAG_STATES + .get() + .and_then(|states| states.lock().ok()) + .and_then(|states| states.get(tag).map(|state| state.generation == generation)) + .unwrap_or(false) + } + + fn may_retry_plain(tag: &str, generation: u64, has_attachment: bool) -> bool { + has_attachment && is_current_tag_submission(tag, generation) + } pub(super) fn request_authorization() { // The API raises an Objective-C exception outside an application @@ -98,6 +136,7 @@ mod imp { let title = title.to_string(); let body = body.to_string(); let avatar = Arc::new(avatar); + let (generation, lane) = begin_tag_submission(&tag); let settings = RcBlock::new(move |settings: NonNull| { // SAFETY: UserNotifications lends a live settings object for this callback. let status = unsafe { settings.as_ref() }.authorizationStatus(); @@ -115,10 +154,18 @@ mod imp { body.clone(), Arc::clone(&avatar), ); + let lane = Arc::clone(&lane); std::thread::spawn(move || { let bytes = avatar(); autoreleasepool(|_| { - post_authorized(&tag, &title, &body, bytes.as_deref().map(Vec::as_slice)); + post_authorized( + &tag, + &title, + &body, + bytes.as_deref().map(Vec::as_slice), + generation, + lane, + ); }); }); }); @@ -127,15 +174,30 @@ mod imp { true } - fn post_authorized(tag: &str, title: &str, body: &str, avatar: Option<&[u8]>) { + fn post_authorized( + tag: &str, + title: &str, + body: &str, + avatar: Option<&[u8]>, + generation: u64, + lane: Arc>, + ) { + let _submission = lane.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let content = UNMutableNotificationContent::new(); content.setTitle(&NSString::from_str(title)); content.setBody(&NSString::from_str(body)); let attachment = avatar.and_then(|bytes| make_avatar_attachment(tag, bytes)); + if !is_current_tag_submission(tag, generation) { + if let Some((_, path)) = attachment { + let _ = std::fs::remove_file(path); + } + return; + } if let Some((image, _)) = &attachment { content.setAttachments(&NSArray::from_retained_slice(std::slice::from_ref(image))); } + content.setSound(Some(&UNNotificationSound::defaultSound())); // A nil trigger delivers immediately. The stable tag has the same // replacement semantics as GPUI's notification backend. @@ -149,6 +211,7 @@ mod imp { let retry_tag = tag.to_string(); let retry_title = title.to_string(); let retry_body = body.to_string(); + let retry_lane = Arc::clone(&lane); let completion = RcBlock::new(move |error: *mut NSError| { // SAFETY: when non-null, UserNotifications lends an NSError for // the duration of this callback. @@ -164,7 +227,24 @@ mod imp { // alert. Re-submit once without media; the same request id // replaces any pending first attempt, without a second loop. if retry_without_avatar { - submit_plain(&retry_tag, &retry_title, &retry_body); + // Queue the retry off the callback. Although Apple's + // implementation calls this asynchronously, keeping the + // retry off-stack also makes the per-tag lane safe if a + // test double or a future implementation invokes the + // completion inline while `post_authorized` still owns + // the lane lock. + let retry_lane = Arc::clone(&retry_lane); + let retry_tag = retry_tag.clone(); + let retry_title = retry_title.clone(); + let retry_body = retry_body.clone(); + std::thread::spawn(move || { + let _submission = retry_lane + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if may_retry_plain(&retry_tag, generation, retry_without_avatar) { + submit_plain(&retry_tag, &retry_title, &retry_body); + } + }); } } }); @@ -176,6 +256,7 @@ mod imp { let content = UNMutableNotificationContent::new(); content.setTitle(&NSString::from_str(title)); content.setBody(&NSString::from_str(body)); + content.setSound(Some(&UNNotificationSound::defaultSound())); let request = UNNotificationRequest::requestWithIdentifier_content_trigger( &NSString::from_str(tag), &content, @@ -267,6 +348,53 @@ mod imp { None } } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn a_new_generation_invalidates_only_the_same_tag() { + let (old, old_lane) = begin_tag_submission("test-notification-stale"); + let (current, current_lane) = begin_tag_submission("test-notification-stale"); + + assert_ne!(old, current); + assert!(!is_current_tag_submission("test-notification-stale", old)); + assert!(is_current_tag_submission( + "test-notification-stale", + current + )); + assert!(Arc::ptr_eq(&old_lane, ¤t_lane)); + assert!(may_retry_plain("test-notification-stale", current, true)); + assert!(!may_retry_plain("test-notification-stale", old, true)); + assert!(!may_retry_plain("test-notification-stale", current, false)); + } + + #[test] + fn independent_tags_keep_independent_generations_and_lanes() { + let (first, first_lane) = begin_tag_submission("test-notification-first"); + let (second, second_lane) = begin_tag_submission("test-notification-second"); + + assert!(is_current_tag_submission("test-notification-first", first)); + assert!(is_current_tag_submission( + "test-notification-second", + second + )); + assert!(!Arc::ptr_eq(&first_lane, &second_lane)); + + let (next_first, next_first_lane) = begin_tag_submission("test-notification-first"); + assert!(!is_current_tag_submission("test-notification-first", first)); + assert!(is_current_tag_submission( + "test-notification-first", + next_first + )); + assert!(is_current_tag_submission( + "test-notification-second", + second + )); + assert!(Arc::ptr_eq(&first_lane, &next_first_lane)); + } + } } #[cfg(not(target_os = "macos"))] diff --git a/crates/gui/src/views/settings/panes.rs b/crates/gui/src/views/settings/panes.rs index 45bc5a92..92e6d780 100644 --- a/crates/gui/src/views/settings/panes.rs +++ b/crates/gui/src/views/settings/panes.rs @@ -405,9 +405,10 @@ fn notifications(metrics: Metrics, cx: &App) -> AnyElement { group( label("NOTIFICATIONS", metrics, cx), pending( - "Incoming messages raise desktop notifications while this window is running, \ - except for the conversation currently visible in the active window. Clicking a \ - notification opens that conversation.", + "When system permissions allow, eligible incoming messages may raise desktop \ + notifications while this window is running. Muted, archived, policy-blocked, \ + self-sent, and currently visible messages are suppressed; an explicit group \ + mention may still alert. Clicking a notification opens that conversation.", metrics, cx, ), diff --git a/crates/session/src/whatsapp/history.rs b/crates/session/src/whatsapp/history.rs index 335d6583..c187a5a8 100644 --- a/crates/session/src/whatsapp/history.rs +++ b/crates/session/src/whatsapp/history.rs @@ -605,10 +605,9 @@ impl WhatsAppClient { if entry.muted_until > existing.muted_until { existing.muted_until = entry.muted_until; } - // `entries` are in display order; the first alias is the - // row this logical thread is represented by. A stale second - // PN/LID row must not make an unarchived primary row archived - // again merely because aliases are being collapsed. + // An active alias keeps the logical conversation active, + // regardless of which PN/LID row leads display order. + existing.archived &= entry.archived; existing.set_name_if_better(name, name_priority); continue; } diff --git a/crates/session/src/whatsapp/mod.rs b/crates/session/src/whatsapp/mod.rs index 64717a57..ec191247 100644 --- a/crates/session/src/whatsapp/mod.rs +++ b/crates/session/src/whatsapp/mod.rs @@ -2153,7 +2153,15 @@ impl WhatsAppClient { if let Some(store) = names.chat_store() { match store.notification_metadata(&info.source.chat).await { Ok(Some(metadata)) => { - let allowed = metadata.allowed || mentions_me; + // The wire flag is the notification invariant, not a + // copy of the chat policy: self echoes are displayed + // as outgoing bubbles and must never ask a front end + // to alert, even when their chat is otherwise eligible. + let allowed = allows_desktop_notification( + info.source.is_from_me, + metadata.allowed, + mentions_me, + ); let title = if allowed { let identity = names.identity(client, &info.source.chat).await; let (title, priority) = names @@ -2861,6 +2869,12 @@ impl WhatsAppClient { } } +/// Keep the notification bit's wire invariant in one place: outgoing echoes +/// never alert, while an explicit mention may bypass the durable chat policy. +fn allows_desktop_notification(is_from_me: bool, policy_allowed: bool, mentions_me: bool) -> bool { + !is_from_me && (policy_allowed || mentions_me) +} + /// Tell the UI which real id a just-sent optimistic bubble got. fn notify_message_id( ui_sender: &UiEventSender, @@ -2959,3 +2973,17 @@ fn because(error: &dyn std::error::Error) -> String { } text } + +#[cfg(all(test, not(target_family = "wasm")))] +mod notification_policy_tests { + use super::allows_desktop_notification; + + #[test] + fn self_echoes_never_cross_the_notification_wire() { + assert!(!allows_desktop_notification(true, true, false)); + assert!(!allows_desktop_notification(true, false, true)); + assert!(allows_desktop_notification(false, true, false)); + assert!(allows_desktop_notification(false, false, true)); + assert!(!allows_desktop_notification(false, false, false)); + } +} diff --git a/crates/session/src/whatsapp/tests.rs b/crates/session/src/whatsapp/tests.rs index f5ea27cc..8c28da14 100644 --- a/crates/session/src/whatsapp/tests.rs +++ b/crates/session/src/whatsapp/tests.rs @@ -1820,6 +1820,55 @@ async fn a_scoped_load_skips_an_archived_chat() { assert!(restored.archived, "archive state survives hydration"); } +#[tokio::test] +async fn alias_hydration_is_archived_only_when_both_rows_are_archived() { + let (chat_store, client) = test_session("archive-aliases").await; + let pn = "559900000008@s.whatsapp.net"; + let lid = "111000011118888@lid"; + client + .add_lid_pn_mapping( + "111000011118888", + "559900000008", + whatsapp_rust::lid_pn_cache::LearningSource::Usync, + ) + .await + .expect("synthetic alias mapping"); + + let entry = |jid: &str, archived: bool| ChatEntry { + jid: jid.parse().expect("test JID"), + name: None, + last_message_at: None, + last_message_preview: None, + last_message_kind: None, + unread_count: 0, + pinned_at: None, + muted_until: None, + archived, + ephemeral_expiration: None, + }; + for (first, second, expected) in [ + (true, false, false), + (false, true, false), + (true, true, true), + ] { + let chats = WhatsAppClient::hydrate_entries( + &chat_store, + &client, + &book(), + vec![entry(pn, first), entry(lid, second)], + |_| 0, + ) + .await + .expect("alias rows hydrate"); + assert_eq!(chats.len(), 1, "PN/LID must merge"); + assert_eq!(chats[0].jid, lid); + assert_eq!( + chats[0].archived, expected, + "first={first}, second={second}" + ); + } +} + /// A cursor is this crate's to write and to read, and the only thing that /// makes that safe is that the two agree. #[test] diff --git a/docs/stories/1.10.story.md b/docs/stories/1.10.story.md new file mode 100644 index 00000000..e03204ff --- /dev/null +++ b/docs/stories/1.10.story.md @@ -0,0 +1,127 @@ +# Story 1.10: CodeRabbit Follow-up for Desktop Chat Reliability + +## Status + +Ready for Review + +## Executor Assignment + +```yaml +executor: "@dev" +quality_gate: "@architect" +quality_gate_tools: + - "cargo fmt --all -- --check" + - "cargo clippy --workspace --all-targets --all-features -- -D warnings" + - "cargo nextest run --workspace --all-features" + - "cargo test --workspace --all-features --doc" +``` + +## Story + +**As an** OxideZap user on macOS, +**I want** the existing archived-chat and notification flows to survive asynchronous updates, alias merges, and startup hydration, +**so that** chats remain discoverable and desktop alerts open the right conversation without regressing mute/archive or read state. + +## Context and Scope + +This is one bounded follow-up to the actionable CodeRabbit review on [OxideZap PR #182](https://github.com/oxidezap/client/pull/182). The findings concern existing behavior in the chat store, session bridge, GUI paging, and native macOS notifications; they do not authorize a new feature or architecture. The working branch already has uncommitted changes, so this story tracks the remaining implementation and validation rather than declaring any finding fixed. Draft PRs #183 and #184 have no review suggestions for this story and are out of scope. + +## Acceptance Criteria + +1. When an archived chat is removed from the active daemon snapshot, `ReadTracker` forgets that chat whether or not `hub.apply_for` accepts the `ChatRemoved` change. An inactive incoming-message path also forgets its JID before returning, so a later active read cannot inherit a stale boundary. +2. A history update arriving while the archived page is `Paging::Loading` records a pending restart. After that response settles, the archived scan restarts from the first page; the stale response cannot finalize the scan or hide a chat newly archived before the old cursor. +3. Completing an archived scan replaces previous archived `departed_chats` debt with the current scan's deferred rows while retaining active-list debt. An archived chat rediscovered in the current scan is not pruned on later navigation. +4. During PN/LID alias reconciliation, a logical chat is archived only if every contributing alias is archived, independent of alias/activity ordering. +5. `MuteUpdate` or `ArchiveUpdate` with an absent value makes no claim: neither the stored value nor its provenance changes. Explicit `Some(true)` and `Some(false)` still apply. A history conversation with `archived: None` preserves its stored archive value under the existing AppState-precedence rule; explicit true/false values still participate in that rule. +6. Clicking one or more macOS notifications before their target chats have hydrated retains every unresolved tag and retries each when chat pages arrive, including archived-page pagination and recovery after a failed page. A successfully opened tag is removed from pending work without losing later clicks; existing dismissal and window-activation behavior remains intact. +7. Concurrent avatar reads cannot cause an older notification for the same tag to replace a newer one. Both the attachment submission and its plain-text fallback reject stale work, while different tags remain independently schedulable. +8. Notification settings copy says muted chats are normally suppressed and does not promise an alert for every non-visible conversation; it remains accurate about existing policy exceptions. +9. The eager notification metadata path does not treat a self-authored message as eligible for a desktop alert. Existing non-eager/fallback processing and the wire event contract remain compatible. +10. With the user's approval for sound, both native notification submission paths use the macOS default notification sound when the system permits it; no custom sound, new setting, or mute-policy bypass is added. +11. Regression tests cover the cases above with synthetic identifiers and controlled asynchronous ordering. The applicable repository CI checks pass; runtime validation is reported as macOS-only. This story does not require reinstalling the app or cleaning build caches. + +## Tasks / Subtasks + +- [x] Correct archived/inactive `ReadTracker` cleanup and add stale-boundary regressions. (AC: 1) +- [x] Preserve pending archived-page restarts across an in-flight response and test a newly archived chat before the old cursor. (AC: 2) +- [x] Reconcile archived departed debt after each completed scan without losing active-list debt; test navigation after rediscovery. (AC: 3) +- [x] Reconcile PN/LID archive state across both alias orders. (AC: 4) +- [x] Preserve absent mute/archive event and history values without changing provenance; test `None`, `Some(false)`, and `Some(true)`. (AC: 5) +- [x] Retain and retry every notification-click target through active and archived hydration/paging, including chat-list `PageLost`; test pending-tag lifecycle. (AC: 6) +- [x] Order same-tag native notifications across avatar workers and plain fallback; test overtaking without serializing unrelated tags. (AC: 7) +- [x] Correct notification settings copy. (AC: 8) +- [x] Keep self-authored eager events ineligible for alerts and test the wire-facing result. (AC: 9) +- [x] Apply the approved default macOS notification sound to both native content constructors, subject to OS permission. (AC: 10) +- [x] Rerun targeted tests, Cargo formatting, Clippy, and the workspace test suite after the pending-tag and `PageLost` fixes; record macOS-only coverage and untested platforms. No app reinstall or cache cleanup. (AC: 11) +- [ ] Verify the changed notifications and archived-chat flow in the installed macOS app after a separately approved build/install. + +## Test Seams + +- Daemon archived removal/inactive message -> `ReadTracker` boundary for a later active read. +- In-flight archived page plus history update -> restart from page one and valid final scan authority; repeated scans -> `departed_chats` pruning. +- Stored PN/LID aliases and optional AppState/history fields -> reconciled archive/mute values and provenance. +- Early notification response plus active/archived `FromDaemon::Chats` pages -> eventual navigation and pending-tag removal. +- Two same-tag notification workers with reversed avatar completion, including attachment failure -> newest content wins; distinct tags are independent. +- Own-message eager event -> notification eligibility on the existing event wire path. + +## Dev Notes + +- The review is the source for these findings: [PR #182 conversation](https://github.com/oxidezap/client/pull/182). Recheck each against the current local code before changing it; the review describes commit `61fb458`, while the working tree may already contain partial fixes. +- Expected touchpoints are `crates/daemon/src/session_bridge/{act,translate}.rs`, `crates/gui/src/app/{mod,paging}.rs`, `crates/gui/src/{main,platform/notifications.rs,views/settings/panes.rs}`, `crates/chat-store/src/store/{event,history_sync}.rs`, and `crates/session/src/whatsapp/{history,mod}.rs`, with adjacent tests. These are guidance, not a mandate to change every file. [Source: PR #182 CodeRabbit comments; `git status` on `feat/desktop-chat-reliability`] +- Keep the daemon as the sole session owner and the GUI as a protocol client. `LoadChats.archived` retains its include-archived wire meaning; the Archived UI filter selects archived rows. Do not invent a second archive or notification authority. [Source: `AGENTS.md#shape`; `crates/gui/src/app/paging.rs`] +- Preserve the existing direct-protocol-mention exception for muted/archived groups, fail-closed behavior for unknown notification state, and delayed-history suppression. No personal JIDs, names, or message content in tests or logs. [Source: `crates/session/src/whatsapp/mod.rs`; `AGENTS.md#rules-that-are-not-obvious`] +- The authoritative CI file is `.github/workflows/ci.yml`. Its Linux Check job runs root and standalone-test-workspace formatting, `cargo machete`, workspace clippy, `cargo nextest run --workspace --all-features`, and workspace doctests; platform jobs check macOS/Windows crates and the browser/WASM path. Record the actual checks run and CI results rather than implying unrun jobs passed. [Source: `.github/workflows/ci.yml#check`; `.github/workflows/ci.yml#cross`; `.github/workflows/ci.yml#web`] +- Local runtime validation is limited to macOS. Do not reinstall or replace `/Applications/OxideZap.app` for this follow-up, and do not run `cargo clean` or otherwise remove build caches. If a behavior cannot be exercised without a new installed build, mark it unverified instead of claiming a pass. [Source: scope instruction for this follow-up] + +## CodeRabbit Integration + +> **CodeRabbit Integration**: No local AIOX CodeRabbit configuration exists in this repository. This story addresses the already-posted PR #182 review and uses the repository's own review and CI gates; it does not prescribe a new CodeRabbit CLI workflow. + +## Story Draft Checklist + +- [x] Goal, user value, prior-story dependency, and PR #182 scope are explicit. +- [x] Each verified review issue maps to a measurable acceptance criterion and test seam. +- [x] Existing architecture, notification-policy exceptions, and out-of-scope PRs are stated. +- [x] CI authority, macOS-only runtime scope, no-reinstall rule, and cache preservation are explicit. +- [x] Implementation checkboxes and validation evidence updated by `@dev`. +- [x] File List completed by `@dev` with actual changed files before handoff. + +## Change Log + +| Date | Version | Description | Author | +| --- | --- | --- | --- | +| 2026-09-22 | 0.1 | Captured the verified PR #182 CodeRabbit follow-up and bounded validation scope. | River (@sm) | +| 2026-09-22 | 0.2 | Implemented initial review fixes and approved notification sound; preliminary macOS Cargo gates passed, but QA found pending-tag and PageLost gaps before push. | @dev | +| 2026-09-22 | 0.3 | Closed the notification interleavings and retry backoff; final macOS Cargo gates and read-only QA review passed. | @dev | + +## Dev Agent Record + +### Agent Model Used + +Sol and Luna (xhigh), integrated by @dev. + +### Debug Log References + +Final checks on macOS passed after the last QA fix: `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-features -q` (including doctests); `cargo check -p oxidezap-gui --bin oxidezap`; and `git diff --check`. Targeted tests passed for chat-store history sync, daemon inactive-read boundary, session PN/LID archive merge, GUI paging (25), notification-click GPUI interleavings (including B hydrating before A), PageLost retry/backoff, and per-tag notification generations. The CLI socket test initially failed only because the default sandbox denied a local bind; the complete suite passed when rerun with local-socket permission. CI's `cargo nextest` is not installed locally. The AIOX `npm` checks do not apply to this Rust-only repository (no `package.json`). No updated app build was installed or exercised against a real WhatsApp account in this follow-up; browser/Windows runtime was not tested. + +### Completion Notes List + +- The daemon forgets read boundaries for removed/inactive chats. Archived paging restarts after stale in-flight responses and reconciles deferred removals against the latest scan. +- The store preserves absent mute/archive updates; history `archived: None` does not clear a stored archive flag. PN/LID aliases are archived only if all contributing rows are archived. +- Notification clicks retain each unresolved tag, scan active and archived pages independently, and retry failed pages with exponential backoff (150 ms to 30 s). A newer chat opens as soon as it hydrates, even if an older tag is still pending; late resolution of the older tag cannot steal focus. +- Per-tag generations and submission lanes prevent stale avatar or plain fallback work replacing a newer alert. Both macOS notification content paths use the user-approved default sound; existing mute/archive/mention policy still gates delivery. +- Settings copy and self-echo eligibility now match the actual notification behavior. Only macOS compilation and automated tests were verified; no new installed-build smoke or browser/Windows runtime test was performed. Build caches and user-untracked files were preserved. + +### File List + +- `docs/stories/1.10.story.md` — scope, checklist, and validation record. +- `crates/chat-store/src/store/{event,history_sync}.rs` and `crates/chat-store/tests/history_sync.rs` — optional app-state/history semantics and regressions. +- `crates/daemon/src/session_bridge/{act,translate,tests}.rs` — ReadTracker cleanup and regression. +- `crates/session/src/whatsapp/{history,mod,tests}.rs` — alias archive merge, self-echo notification policy, and tests. +- `crates/gui/src/app/{mod,paging}.rs` and `crates/gui/src/main.rs` — notification-click hydration, dual-list pagination, archived scan restart/debt, and tests. +- `crates/gui/src/platform/notifications.rs` — same-tag ordering, fallback guard, default sound, and tests. +- `crates/gui/src/views/settings/panes.rs` — accurate notification settings copy. + +## QA Results + +Read-only @qa follow-up: GO for the final code diff. Reviewed the B-before-A hydration race, older-tag focus protection, and bounded PageLost backoff. Final Cargo gates passed after this verdict; installed-app behavior remains unverified. From 5f775269a5eb9262750cc36fb88daefbb0f9bbee Mon Sep 17 00:00:00 2001 From: Assis Date: Tue, 22 Sep 2026 12:04:02 -0300 Subject: [PATCH 5/7] docs: remove unnecessary follow-up story --- docs/stories/1.10.story.md | 127 ------------------------------------- 1 file changed, 127 deletions(-) delete mode 100644 docs/stories/1.10.story.md diff --git a/docs/stories/1.10.story.md b/docs/stories/1.10.story.md deleted file mode 100644 index e03204ff..00000000 --- a/docs/stories/1.10.story.md +++ /dev/null @@ -1,127 +0,0 @@ -# Story 1.10: CodeRabbit Follow-up for Desktop Chat Reliability - -## Status - -Ready for Review - -## Executor Assignment - -```yaml -executor: "@dev" -quality_gate: "@architect" -quality_gate_tools: - - "cargo fmt --all -- --check" - - "cargo clippy --workspace --all-targets --all-features -- -D warnings" - - "cargo nextest run --workspace --all-features" - - "cargo test --workspace --all-features --doc" -``` - -## Story - -**As an** OxideZap user on macOS, -**I want** the existing archived-chat and notification flows to survive asynchronous updates, alias merges, and startup hydration, -**so that** chats remain discoverable and desktop alerts open the right conversation without regressing mute/archive or read state. - -## Context and Scope - -This is one bounded follow-up to the actionable CodeRabbit review on [OxideZap PR #182](https://github.com/oxidezap/client/pull/182). The findings concern existing behavior in the chat store, session bridge, GUI paging, and native macOS notifications; they do not authorize a new feature or architecture. The working branch already has uncommitted changes, so this story tracks the remaining implementation and validation rather than declaring any finding fixed. Draft PRs #183 and #184 have no review suggestions for this story and are out of scope. - -## Acceptance Criteria - -1. When an archived chat is removed from the active daemon snapshot, `ReadTracker` forgets that chat whether or not `hub.apply_for` accepts the `ChatRemoved` change. An inactive incoming-message path also forgets its JID before returning, so a later active read cannot inherit a stale boundary. -2. A history update arriving while the archived page is `Paging::Loading` records a pending restart. After that response settles, the archived scan restarts from the first page; the stale response cannot finalize the scan or hide a chat newly archived before the old cursor. -3. Completing an archived scan replaces previous archived `departed_chats` debt with the current scan's deferred rows while retaining active-list debt. An archived chat rediscovered in the current scan is not pruned on later navigation. -4. During PN/LID alias reconciliation, a logical chat is archived only if every contributing alias is archived, independent of alias/activity ordering. -5. `MuteUpdate` or `ArchiveUpdate` with an absent value makes no claim: neither the stored value nor its provenance changes. Explicit `Some(true)` and `Some(false)` still apply. A history conversation with `archived: None` preserves its stored archive value under the existing AppState-precedence rule; explicit true/false values still participate in that rule. -6. Clicking one or more macOS notifications before their target chats have hydrated retains every unresolved tag and retries each when chat pages arrive, including archived-page pagination and recovery after a failed page. A successfully opened tag is removed from pending work without losing later clicks; existing dismissal and window-activation behavior remains intact. -7. Concurrent avatar reads cannot cause an older notification for the same tag to replace a newer one. Both the attachment submission and its plain-text fallback reject stale work, while different tags remain independently schedulable. -8. Notification settings copy says muted chats are normally suppressed and does not promise an alert for every non-visible conversation; it remains accurate about existing policy exceptions. -9. The eager notification metadata path does not treat a self-authored message as eligible for a desktop alert. Existing non-eager/fallback processing and the wire event contract remain compatible. -10. With the user's approval for sound, both native notification submission paths use the macOS default notification sound when the system permits it; no custom sound, new setting, or mute-policy bypass is added. -11. Regression tests cover the cases above with synthetic identifiers and controlled asynchronous ordering. The applicable repository CI checks pass; runtime validation is reported as macOS-only. This story does not require reinstalling the app or cleaning build caches. - -## Tasks / Subtasks - -- [x] Correct archived/inactive `ReadTracker` cleanup and add stale-boundary regressions. (AC: 1) -- [x] Preserve pending archived-page restarts across an in-flight response and test a newly archived chat before the old cursor. (AC: 2) -- [x] Reconcile archived departed debt after each completed scan without losing active-list debt; test navigation after rediscovery. (AC: 3) -- [x] Reconcile PN/LID archive state across both alias orders. (AC: 4) -- [x] Preserve absent mute/archive event and history values without changing provenance; test `None`, `Some(false)`, and `Some(true)`. (AC: 5) -- [x] Retain and retry every notification-click target through active and archived hydration/paging, including chat-list `PageLost`; test pending-tag lifecycle. (AC: 6) -- [x] Order same-tag native notifications across avatar workers and plain fallback; test overtaking without serializing unrelated tags. (AC: 7) -- [x] Correct notification settings copy. (AC: 8) -- [x] Keep self-authored eager events ineligible for alerts and test the wire-facing result. (AC: 9) -- [x] Apply the approved default macOS notification sound to both native content constructors, subject to OS permission. (AC: 10) -- [x] Rerun targeted tests, Cargo formatting, Clippy, and the workspace test suite after the pending-tag and `PageLost` fixes; record macOS-only coverage and untested platforms. No app reinstall or cache cleanup. (AC: 11) -- [ ] Verify the changed notifications and archived-chat flow in the installed macOS app after a separately approved build/install. - -## Test Seams - -- Daemon archived removal/inactive message -> `ReadTracker` boundary for a later active read. -- In-flight archived page plus history update -> restart from page one and valid final scan authority; repeated scans -> `departed_chats` pruning. -- Stored PN/LID aliases and optional AppState/history fields -> reconciled archive/mute values and provenance. -- Early notification response plus active/archived `FromDaemon::Chats` pages -> eventual navigation and pending-tag removal. -- Two same-tag notification workers with reversed avatar completion, including attachment failure -> newest content wins; distinct tags are independent. -- Own-message eager event -> notification eligibility on the existing event wire path. - -## Dev Notes - -- The review is the source for these findings: [PR #182 conversation](https://github.com/oxidezap/client/pull/182). Recheck each against the current local code before changing it; the review describes commit `61fb458`, while the working tree may already contain partial fixes. -- Expected touchpoints are `crates/daemon/src/session_bridge/{act,translate}.rs`, `crates/gui/src/app/{mod,paging}.rs`, `crates/gui/src/{main,platform/notifications.rs,views/settings/panes.rs}`, `crates/chat-store/src/store/{event,history_sync}.rs`, and `crates/session/src/whatsapp/{history,mod}.rs`, with adjacent tests. These are guidance, not a mandate to change every file. [Source: PR #182 CodeRabbit comments; `git status` on `feat/desktop-chat-reliability`] -- Keep the daemon as the sole session owner and the GUI as a protocol client. `LoadChats.archived` retains its include-archived wire meaning; the Archived UI filter selects archived rows. Do not invent a second archive or notification authority. [Source: `AGENTS.md#shape`; `crates/gui/src/app/paging.rs`] -- Preserve the existing direct-protocol-mention exception for muted/archived groups, fail-closed behavior for unknown notification state, and delayed-history suppression. No personal JIDs, names, or message content in tests or logs. [Source: `crates/session/src/whatsapp/mod.rs`; `AGENTS.md#rules-that-are-not-obvious`] -- The authoritative CI file is `.github/workflows/ci.yml`. Its Linux Check job runs root and standalone-test-workspace formatting, `cargo machete`, workspace clippy, `cargo nextest run --workspace --all-features`, and workspace doctests; platform jobs check macOS/Windows crates and the browser/WASM path. Record the actual checks run and CI results rather than implying unrun jobs passed. [Source: `.github/workflows/ci.yml#check`; `.github/workflows/ci.yml#cross`; `.github/workflows/ci.yml#web`] -- Local runtime validation is limited to macOS. Do not reinstall or replace `/Applications/OxideZap.app` for this follow-up, and do not run `cargo clean` or otherwise remove build caches. If a behavior cannot be exercised without a new installed build, mark it unverified instead of claiming a pass. [Source: scope instruction for this follow-up] - -## CodeRabbit Integration - -> **CodeRabbit Integration**: No local AIOX CodeRabbit configuration exists in this repository. This story addresses the already-posted PR #182 review and uses the repository's own review and CI gates; it does not prescribe a new CodeRabbit CLI workflow. - -## Story Draft Checklist - -- [x] Goal, user value, prior-story dependency, and PR #182 scope are explicit. -- [x] Each verified review issue maps to a measurable acceptance criterion and test seam. -- [x] Existing architecture, notification-policy exceptions, and out-of-scope PRs are stated. -- [x] CI authority, macOS-only runtime scope, no-reinstall rule, and cache preservation are explicit. -- [x] Implementation checkboxes and validation evidence updated by `@dev`. -- [x] File List completed by `@dev` with actual changed files before handoff. - -## Change Log - -| Date | Version | Description | Author | -| --- | --- | --- | --- | -| 2026-09-22 | 0.1 | Captured the verified PR #182 CodeRabbit follow-up and bounded validation scope. | River (@sm) | -| 2026-09-22 | 0.2 | Implemented initial review fixes and approved notification sound; preliminary macOS Cargo gates passed, but QA found pending-tag and PageLost gaps before push. | @dev | -| 2026-09-22 | 0.3 | Closed the notification interleavings and retry backoff; final macOS Cargo gates and read-only QA review passed. | @dev | - -## Dev Agent Record - -### Agent Model Used - -Sol and Luna (xhigh), integrated by @dev. - -### Debug Log References - -Final checks on macOS passed after the last QA fix: `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-features -q` (including doctests); `cargo check -p oxidezap-gui --bin oxidezap`; and `git diff --check`. Targeted tests passed for chat-store history sync, daemon inactive-read boundary, session PN/LID archive merge, GUI paging (25), notification-click GPUI interleavings (including B hydrating before A), PageLost retry/backoff, and per-tag notification generations. The CLI socket test initially failed only because the default sandbox denied a local bind; the complete suite passed when rerun with local-socket permission. CI's `cargo nextest` is not installed locally. The AIOX `npm` checks do not apply to this Rust-only repository (no `package.json`). No updated app build was installed or exercised against a real WhatsApp account in this follow-up; browser/Windows runtime was not tested. - -### Completion Notes List - -- The daemon forgets read boundaries for removed/inactive chats. Archived paging restarts after stale in-flight responses and reconciles deferred removals against the latest scan. -- The store preserves absent mute/archive updates; history `archived: None` does not clear a stored archive flag. PN/LID aliases are archived only if all contributing rows are archived. -- Notification clicks retain each unresolved tag, scan active and archived pages independently, and retry failed pages with exponential backoff (150 ms to 30 s). A newer chat opens as soon as it hydrates, even if an older tag is still pending; late resolution of the older tag cannot steal focus. -- Per-tag generations and submission lanes prevent stale avatar or plain fallback work replacing a newer alert. Both macOS notification content paths use the user-approved default sound; existing mute/archive/mention policy still gates delivery. -- Settings copy and self-echo eligibility now match the actual notification behavior. Only macOS compilation and automated tests were verified; no new installed-build smoke or browser/Windows runtime test was performed. Build caches and user-untracked files were preserved. - -### File List - -- `docs/stories/1.10.story.md` — scope, checklist, and validation record. -- `crates/chat-store/src/store/{event,history_sync}.rs` and `crates/chat-store/tests/history_sync.rs` — optional app-state/history semantics and regressions. -- `crates/daemon/src/session_bridge/{act,translate,tests}.rs` — ReadTracker cleanup and regression. -- `crates/session/src/whatsapp/{history,mod,tests}.rs` — alias archive merge, self-echo notification policy, and tests. -- `crates/gui/src/app/{mod,paging}.rs` and `crates/gui/src/main.rs` — notification-click hydration, dual-list pagination, archived scan restart/debt, and tests. -- `crates/gui/src/platform/notifications.rs` — same-tag ordering, fallback guard, default sound, and tests. -- `crates/gui/src/views/settings/panes.rs` — accurate notification settings copy. - -## QA Results - -Read-only @qa follow-up: GO for the final code diff. Reviewed the B-before-A hydration race, older-tag focus protection, and bounded PageLost backoff. Final Cargo gates passed after this verdict; installed-app behavior remains unverified. From 5fd59dccc5635874008242121d3971cfaf09918f Mon Sep 17 00:00:00 2001 From: Assis Date: Tue, 22 Sep 2026 12:21:01 -0300 Subject: [PATCH 6/7] fix(chat): address follow-up review findings --- crates/chat-store/tests/history_sync.rs | 35 ++++++------ crates/gui/src/app/mod.rs | 72 ++++++++++++++++++++----- crates/gui/src/app/paging.rs | 62 ++++++++++++++++++++- 3 files changed, 139 insertions(+), 30 deletions(-) diff --git a/crates/chat-store/tests/history_sync.rs b/crates/chat-store/tests/history_sync.rs index e0c446c3..24e10dbc 100644 --- a/crates/chat-store/tests/history_sync.rs +++ b/crates/chat-store/tests/history_sync.rs @@ -108,20 +108,22 @@ async fn history_prefills_live_row_without_overwriting_appstate() { #[tokio::test] async fn missing_preference_options_do_not_unmute_or_unarchive() { let (_store, chat_store) = test_store().await; - let snapshot = |archived: Option| { + let snapshot = |muted: bool, archived: Option| { history_sync_event(wa::HistorySync { sync_type: wa::history_sync::HistorySyncType::RECENT, conversations: vec![wa::Conversation { id: PEER.into(), conversation_timestamp: Some(1_700_000_000), - mute_end_time: Some(1_900_000_000), + // Present zero is an explicit unmute on the wire, not a + // missing preference. + mute_end_time: Some(if muted { 1_900_000_000 } else { 0 }), archived, ..Default::default() }], ..Default::default() }) }; - feed(&chat_store, [snapshot(Some(true))]).await; + feed(&chat_store, [snapshot(true, Some(true))]).await; feed( &chat_store, [ @@ -152,7 +154,7 @@ async fn missing_preference_options_do_not_unmute_or_unarchive() { assert!(state.muted); assert!(state.archived); - feed(&chat_store, [snapshot(None)]).await; + feed(&chat_store, [snapshot(true, None)]).await; assert!( chat_store .notification_metadata(&jid(PEER)) @@ -162,17 +164,20 @@ async fn missing_preference_options_do_not_unmute_or_unarchive() { .archived ); - // An explicit false from history still applies: the missing actions - // above must not have marked either preference app-state authoritative. - feed(&chat_store, [snapshot(Some(false))]).await; - assert!( - !chat_store - .notification_metadata(&jid(PEER)) - .await - .unwrap() - .unwrap() - .archived - ); + // An explicit unmute and unarchive from history still apply: the missing + // actions above must not have marked either preference app-state authoritative. + feed(&chat_store, [snapshot(false, Some(false))]).await; + let state = chat_store + .notification_metadata(&jid(PEER)) + .await + .unwrap() + .unwrap(); + assert!(!state.muted); + assert!(!state.archived); + assert!(state.allowed); + let chats = chat_store.chats(false, 10).await.unwrap(); + let chat = chats.iter().find(|chat| chat.jid == jid(PEER)).unwrap(); + assert!(chat.muted_until.is_none()); } #[tokio::test] diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index 15a37907..94e9b196 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -1868,15 +1868,23 @@ impl WhatsAppApp { } fn resume_visible_read(&mut self, cx: &mut Context) { - if !self.window_focused || !crate::platform::application_is_active() { + if !chat_read_can_be_committed( + self.window_focused, + crate::platform::application_is_active(), + self.client.is_some(), + ) { return; } + let Some(client) = self.client.as_ref() else { + return; + }; let Some(jid) = self.visible_chat.clone() else { return; }; + let owed = self.owed_reads.contains(&jid); let Some(chat) = self .find_chat(&jid) - .filter(|chat| chat.unread_count > 0 || chat.manually_unread) + .filter(|chat| chat_read_needs_resume(chat, owed)) else { return; }; @@ -1884,9 +1892,8 @@ impl WhatsAppApp { self.owed_reads.insert(jid); return; }; - if let Some(client) = &self.client { - client.mark_chat_read(&jid, newest); - } + client.mark_chat_read(&jid, newest); + self.owed_reads.remove(&jid); if let Some(chat) = self.find_chat_mut(&jid) { chat.mark_as_read(); } @@ -2552,17 +2559,23 @@ impl WhatsAppApp { // action separately: the daemon owns both, along with the boundary // that keeps a read from swallowing anything newer. All it needs from // here is the message this side is looking at. - if self.window_focused - && crate::platform::application_is_active() - && let Some(chat) = self - .find_chat(&jid) - .filter(|c| c.unread_count > 0 || c.manually_unread) + let mut mark_locally = false; + let owed = self.owed_reads.contains(&jid); + if chat_read_can_be_committed( + self.window_focused, + crate::platform::application_is_active(), + self.client.is_some(), + ) && let Some(chat) = self + .find_chat(&jid) + .filter(|c| chat_read_needs_resume(c, owed)) { match read_bound(chat) { ReadBound::Now(newest) => { info!("Marking {} read", observe_str(&jid)); - if let Some(client) = &self.client { + if let Some(client) = self.client.as_ref() { client.mark_chat_read(&jid, newest); + self.owed_reads.remove(&jid); + mark_locally = true; } } ReadBound::WhenLoaded => { @@ -2571,12 +2584,15 @@ impl WhatsAppApp { observe_str(&jid) ); self.owed_reads.insert(jid.clone()); + mark_locally = true; } } } - // Mark as read locally - if let Some(chat) = self.find_chat_mut(&jid) { + // Mark as read locally only after a remote read was sent or retained + // in `owed_reads`. If the app is inactive, or the session is absent, + // keep the unread state so activation/reattach can retry the receipt. + if mark_locally && let Some(chat) = self.find_chat_mut(&jid) { chat.mark_as_read(); // Both caches: the badge, and the is_read snapshot the message // list renders ticks from (its count guard can't see this). @@ -2684,6 +2700,7 @@ impl WhatsAppApp { app.avatar_manager.set_session(Some(client.handle())); app.avatar_manager.flush_demands(&client); app.client = Some(client); + app.resume_visible_read(cx); if let Ok((control, control_rx)) = control { // Its frames answer through the control session's // own request table; the reader just has to keep @@ -3699,6 +3716,18 @@ fn read_bound(chat: &Chat) -> ReadBound { } } +fn chat_read_can_be_committed( + window_focused: bool, + application_active: bool, + client_available: bool, +) -> bool { + window_focused && application_active && client_available +} + +fn chat_read_needs_resume(chat: &Chat, owed: bool) -> bool { + owed || chat.unread_count > 0 || chat.manually_unread +} + fn read_is_allowed( window_focused: bool, application_active: bool, @@ -4115,6 +4144,23 @@ mod tests { assert!(read_is_allowed(true, true, true, false)); } + #[test] + fn chat_reads_stay_pending_until_window_and_client_can_commit_them() { + assert!(!chat_read_can_be_committed(false, true, true)); + assert!(!chat_read_can_be_committed(true, false, true)); + assert!(!chat_read_can_be_committed(true, true, false)); + assert!(chat_read_can_be_committed(true, true, true)); + } + + #[test] + fn an_owed_read_is_resumed_after_local_badge_was_cleared() { + let mut row = chat("a@s.whatsapp.net", Some(10)); + row.mark_as_read(); + + assert!(!chat_read_needs_resume(&row, false)); + assert!(chat_read_needs_resume(&row, true)); + } + #[test] fn a_notification_tag_can_be_resolved_after_chat_hydration() { let jid = "archived@example.invalid"; diff --git a/crates/gui/src/app/paging.rs b/crates/gui/src/app/paging.rs index 1553af43..71a6f5fa 100644 --- a/crates/gui/src/app/paging.rs +++ b/crates/gui/src/app/paging.rs @@ -13,7 +13,7 @@ use std::collections::{HashMap, HashSet}; use gpui::{App, Context}; -use log::debug; +use log::{debug, error}; use oxidezap_core::{Chat, ChatMessage}; use oxidezap_ipc::PageCursor; use wacore_binary::jid::observe_str; @@ -45,6 +45,9 @@ pub(super) enum Paging { /// still committing batches, and asking again from there is asking for /// exactly what was not there the first time. Done { from: Option }, + /// The daemon returned the cursor we just asked from. Do not ask it again + /// until a history change gives this list a reason to retry. + Stalled { from: PageCursor }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -52,6 +55,7 @@ enum ChatPageArrival { Ignored, Applied, Restarted, + Rejected, } /// Requests needed to hydrate both chat lists for an unresolved notification. @@ -88,7 +92,7 @@ impl Paging { match self { Self::Unasked => Some(None), Self::More(cursor) => Some(Some(cursor.clone())), - Self::Loading { .. } | Self::Done { .. } => None, + Self::Loading { .. } | Self::Done { .. } | Self::Stalled { .. } => None, } } @@ -111,6 +115,7 @@ impl Paging { match self { Self::Done { from: Some(cursor) } => Self::More(cursor.clone()), Self::Done { from: None } => Self::Unasked, + Self::Stalled { from } => Self::More(from.clone()), unsettled => unsettled.clone(), } } @@ -243,6 +248,12 @@ impl Pages { self.archived_chats = Paging::Unasked; return ChatPageArrival::Restarted; } + if let (Some(from), Some(returned)) = (asked_from.as_ref(), next.as_ref()) + && from == returned + { + *self.chat_list(archived) = Paging::Stalled { from: from.clone() }; + return ChatPageArrival::Rejected; + } *self.chat_list(archived) = Paging::arrived(asked_from, next); ChatPageArrival::Applied } @@ -477,6 +488,12 @@ impl WhatsAppApp { cx.notify(); return; } + ChatPageArrival::Rejected => { + // This is not a complete scan. Do not merge the page or prune + // archived chats as though the daemon had reached the end. + error!("chat page returned a non-advancing cursor"); + return; + } ChatPageArrival::Applied => {} } if chats.is_empty() { @@ -751,6 +768,47 @@ mod tests { assert_eq!(pages.more_chats(true), Some(Some(cursor("archive-next")))); } + #[test] + fn a_nonadvancing_chat_cursor_stalls_without_completing_the_scan() { + for archived in [false, true] { + let mut pages = Pages::new(); + assert_eq!(pages.more_chats(archived), Some(None)); + assert_eq!( + pages.chat_page_arrived(archived, Some(cursor("same"))), + ChatPageArrival::Applied + ); + assert_eq!(pages.more_chats(archived), Some(Some(cursor("same")))); + assert_eq!( + pages.chat_page_arrived(archived, Some(cursor("same"))), + ChatPageArrival::Rejected + ); + assert!(matches!( + pages.chat_list(archived), + Paging::Stalled { from } if *from == cursor("same") + )); + assert_eq!( + pages.more_chats(archived), + None, + "do not repeat the IPC ask" + ); + + let plan = pages.notification_page_plan(); + assert!(!plan.done(), "an incomplete scan cannot rule out a chat"); + assert!(!plan.active_done && !plan.archived_done); + + pages.reopen(&[]); + assert_eq!( + pages.more_chats(archived), + if archived { + Some(None) + } else { + Some(Some(cursor("same"))) + }, + "a history change may make the cursor usable again" + ); + } + } + #[test] fn a_notification_advances_active_and_archived_pages_without_the_filter() { let mut pages = Pages::new(); From 590ddec5dcd616fa6c544c7ba834b6608247aac4 Mon Sep 17 00:00:00 2001 From: Assis Date: Tue, 22 Sep 2026 12:21:35 -0300 Subject: [PATCH 7/7] docs: remove internal message-action stories --- docs/stories/1.6.story.md | 125 -------------------------------------- docs/stories/1.7.story.md | 119 ------------------------------------ docs/stories/1.8.story.md | 117 ----------------------------------- 3 files changed, 361 deletions(-) delete mode 100644 docs/stories/1.6.story.md delete mode 100644 docs/stories/1.7.story.md delete mode 100644 docs/stories/1.8.story.md diff --git a/docs/stories/1.6.story.md b/docs/stories/1.6.story.md deleted file mode 100644 index 54f951a9..00000000 --- a/docs/stories/1.6.story.md +++ /dev/null @@ -1,125 +0,0 @@ -# Story 1.6: Sent-Message Actions, Attachment Confirmation, and Text Selection - -## Status - -Ready for Review - -## Executor Assignment - -```yaml -executor: "@dev" -quality_gate: "@architect" -quality_gate_tools: - - "cargo fmt --all -- --check" - - "cargo clippy --workspace --all-targets --all-features -- -D warnings" - - "cargo test --workspace --all-features" -``` - -## Story - -**As an** OxideZap user, -**I want** to edit or delete messages I sent, confirm attachments before sending them regardless of how I added them, and select just the chat text I need, -**so that** I can correct mistakes, avoid accidental media sends, and copy a useful excerpt rather than an entire message. - -## Context and Decisions - -This is one user-requested batch with three independent GUI outcomes. The existing CLI already exposes `messages edit` and `messages revoke` through `oxidezap-wire::ClientRequest`; `messages revoke --for-everyone` requests deletion for everyone, while the default requests deletion for me. The GUI uses the **separate** `oxidezap-ipc::ClientRequest`, which does not yet contain edit/revoke variants. Add the smallest GUI IPC requests and daemon routing needed to reuse the existing session operations, without creating a second WhatsApp session. The pasted-image confirmation from Story 1.2 already has explicit `Cancel` and `Send` actions, whereas picker and drag/drop currently send immediately through `finish_attaching`. - -The user confirmed **both** delete choices: **Apagar para todos** and **Apagar para mim**. These are separate actions with different effects; neither may silently stand in for the other. WhatsApp documents an edit window of up to 15 minutes and a delete-for-everyone request window of up to two days after sending; delete-for-me affects only the user's copy. Server outcomes remain authoritative. [Source: [WhatsApp Help Center — How to delete messages](https://faq.whatsapp.com/1370476507114859/?cms_platform=web&helpref=faq_content)] - -## Acceptance Criteria - -1. A sent, non-revoked text message offers an `Edit` action while it is eligible under WhatsApp's 15-minute window. Editing starts with that message's current text, allows explicit save or cancel, and sends no edit on cancel or an empty replacement. A successful edit updates the conversation through the existing message/store refresh path; a failed request reports the error without presenting the replacement as accepted. -2. A sent message offers `Apagar para mim` and, while eligible within WhatsApp's two-day request window, the distinct action `Apagar para todos`. The former routes `for_everyone: false` to the existing session operation and removes only the user's copy; the latter routes `for_everyone: true` for the user's own message. No incoming-message or group-admin delete-for-everyone action is added by this story. -3. Edit/delete actions target the exact message and chat on which the action was invoked. Repeated activation cannot submit the same pending action twice. Rejected or unavailable operations produce visible feedback and do not falsely remove or edit a message. Successful own edits and delete-for-everyone actions update durable chat state and the visible conversation through the existing store/history path; delete-for-me follows its existing app-state path. -4. Every accepted attachment supplied by the file picker or drag/drop, on desktop and web paths where that source exists, reaches an explicit confirmation surface before any `send_attachment` call, outgoing media bubble, or upload. Pasted images retain their existing Story 1.2 confirmation behavior. -5. The confirmation identifies every accepted file in a multi-file choice/drop. Images use the established visual preview where supported; other file types are represented by their file identity/type rather than being sent invisibly. The surface has explicit `Cancel` and `Send` actions. No caption, media editor, or new media transport is introduced. -6. `Cancel` discards the pending accepted files with zero sends. `Send` consumes the pending choice once and passes each accepted file exactly once through the existing `send_attachment` flow, preserving the destination captured when the picker/drop began, existing reply-on-first-file behavior, size/type checks, and per-file refusal notices. Dismissing a picker or a choice with no accepted files opens no confirmation and sends nothing. -7. In a chat message's visible text, the user can select an arbitrary substring within that message and copy exactly the selected visible characters using the normal platform copy gesture. Plain, formatted, and linked message text remain readable; selection does not accidentally invoke link navigation or a message action. Existing whole-message `Copy text` and link-opening actions remain available. -8. Automated tests cover the edit/save/cancel and both delete request variants, GUI IPC serialization/daemon routing/version compatibility, durable own-edit/revoke visibility, wrong-message/stale/failed-action guards, picker/drop multi-file preview with zero-before-confirmation, cancel-zero and confirm-exactly-once, pasted-image regression, and substring selection/copy alongside whole-message copy and links. Tests use synthetic identifiers and payloads only. -9. Repository Cargo formatting, clippy, and workspace tests pass; manual desktop smoke covers all three outcomes, with web picker/drop smoke where a browser test environment is available. - -## Test Seams - -- Rendered message action -> exact chat/message ID and new `oxidezap-ipc::ClientRequest` edit/revoke variants -> daemon `Action` -> existing session edit/revoke operations -> durable state and refreshed bubble; test both delete boolean values, IPC round-trips, and failures without optimistic false success. -- Picker and native/web drop -> shared `Chosen`/`Picked` result -> visible confirmation with zero attachment attempts -> rendered `Cancel` or `Send` -> zero or exactly one attempt per accepted file. Include a multi-file choice with a refusal and a chat switch while file reading/confirmation is pending. -- Rendered plain/formatted/linked bubble text -> pointer selection and platform copy -> clipboard contains only the selected visible substring; whole-message copy and link activation still work. - -## Tasks / Subtasks - -- [x] Add rendered interaction regressions for sent-text edit, explicit save/cancel, eligible/ineligible states, both delete variants, exact message targeting, duplicate activation, and error feedback. (AC: 1-3, 8) -- [x] Add minimal GUI IPC edit/revoke requests, protocol round-trip coverage and version update, daemon dispatch/action routing, and GUI actions that reuse the existing session operations. (AC: 1-3, 8) -- [x] Ensure successful own edits and revokes materialize locally through the existing chat-store helpers/history refresh; preserve the established app-state delete-for-me behavior. (AC: 1-3, 8) -- [x] Add picker and drag/drop confirmation regressions for one file, multiple files, partial refusal, cancel, confirm once, dismissed/empty choice, captured destination/reply, and the existing paste flow. (AC: 4-6, 8) -- [x] Route accepted picker/drop files into a shared pending confirmation state and reuse `send_attachment` only after explicit `Send`; preserve the existing refusal, reply, and attachment rules. (AC: 4-6) -- [x] Add interaction coverage for selecting/copying a substring of plain, formatted, and linked chat text without regressing whole-message copy or link actions. (AC: 7-8) -- [x] Make message text selectable and copyable through the normal platform gesture while retaining its existing formatting and link behavior. (AC: 7) -- [ ] Run Cargo quality gates and manual smoke checks; update this checklist, Dev Agent Record, and File List with actual changes and results. (AC: 8-9) - -## Dev Notes - -- CLI edit/revoke entry points are in `crates/cli/src/args.rs` and `crates/cli/src/main.rs`; its `oxidezap-wire` contract is in `crates/wire/src/request.rs`, routed by `crates/daemon/src/session_bridge/act.rs` to `crates/session/src/whatsapp/mutations.rs`. The **different** GUI contract in `crates/ipc/src/protocol.rs` has no edit/revoke request yet; `crates/daemon/src/server/requests.rs` and `crates/daemon/src/session_bridge/action.rs` need corresponding routing. Follow `crates/ipc/src/transport.rs` when changing the IPC version, since an older daemon would otherwise reject the new GUI requests. The current session edit API constructs text content; this story limits editing to sent text, not media/captions. The session's sender revoke path already rejects a stored incoming message. [Source: those files] -- Existing store code materializes inbound edits and revokes, and GUI history merges treat hydrated message content as authoritative. `ChatStore::record_edit` and `record_revoke` exist but the current own-message session mutation paths do not call them; wire successful own mutations into durable state and refresh without adding a local-only representation. Delete-for-me uses the session's app-state path, not `record_revoke`. [Source: `crates/chat-store/src/store/mod.rs`; `crates/chat-store/src/store/edit.rs`; `crates/chat-store/src/store/revoke.rs`; `crates/core/src/chat/merge.rs`; `crates/session/src/whatsapp/mutations.rs`] -- `crates/gui/src/components/message_bubble/mod.rs` owns the bubble context menu; `crates/gui/src/app/mod.rs` owns its current whole-message `CopyMessage` action. `crates/gui/src/components/rich_text.rs` renders plain, styled, and linked text via different GPUI elements. A selection solution must account for all three visible forms and avoid changing copied text to markup source characters. [Source: those files] -- `crates/gui/src/app/attaching.rs::finish_attaching` is the current common immediate-send point for picker and drops. Native chat drops enter at `crates/gui/src/views/chat.rs` and browser document drops at `crates/gui/src/platform/drop.rs`; both produce `Chosen`/`Picked` via the existing picker rules. Retain the destination/reply captured before asynchronous reading. [Source: those files] -- The existing pasted-image modal and single-use confirmation live in `crates/gui/src/components/paste_preview.rs`, `crates/gui/src/app/mod.rs`, and `crates/gui/src/app/attaching.rs`. Reuse its explicit-confirmation UX and established theme/metric tokens; do not regress Story 1.2. [Source: those files; `docs/stories/1.2.story.md`] -- WhatsApp's two-day delete-for-everyone and 15-minute edit windows are product eligibility guidance, not a promise that another device has already applied a request. Surface server rejection honestly. [Source: [WhatsApp Help Center](https://faq.whatsapp.com/1370476507114859/?cms_platform=web&helpref=faq_content)] -- A successful delete-for-me is removed by exact message ID in the requesting GUI and in the durable store. A second GUI window that already holds that row may remain stale until its in-memory conversation is rebuilt: ordinary history merge cannot infer a deletion from absence in a partial page. Multi-window deletion fanout needs a separate explicit event and test; it is outside this story's minimal IPC additions. -- No new CLI command, media upload pipeline, attachment kind, caption editor, group-admin moderation, or cross-message text selection is requested. The required GUI IPC variants must stay limited to edit/revoke. Existing whole-message copy remains supported. -- This Rust repository's CI authority is `.github/workflows/ci.yml`; use its Cargo gates rather than the unrelated npm commands in the ancestor AIOX instructions. [Source: `AGENTS.md#build--verify`] - -## CodeRabbit Integration - -> **CodeRabbit Integration**: Not configured -> -> This repository has no `.aiox-core/core-config.yaml`. Use the repository's Cargo, architecture-review, and CI gates. - -## Story Draft Checklist - -- [x] All three user-requested outcomes and their value are explicit. -- [x] Both delete variants are confirmed and mapped to distinct existing requests. -- [x] Existing CLI/session, attachment, and rich-text seams are identified; the required GUI IPC bridge and version policy are explicit. -- [x] Zero-before-confirmation, cancel-zero, confirm-once, and substring-copy outcomes are testable. -- [x] Out-of-scope features and repository-native quality gates are explicit. -- [x] Implementation, automated validation, and File List recorded by `@dev`; manual WhatsApp smoke awaits the user's test. - -## Change Log - -| Date | Version | Description | Author | -| --- | --- | --- | --- | -| 2026-09-21 | 0.1 | Drafted the user-requested batch; recorded both confirmed delete variants and existing implementation/test seams. | River (@sm) | -| 2026-09-21 | 0.2 | Implemented the batch, added regressions, and recorded automated gates; kept manual smoke pending. | @dev | - -## Dev Agent Record - -### Agent Model Used - -Sol and Luna agents (xhigh), integrated by @dev. - -### Debug Log References - -`cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-features` (all passed with local socket access). GUI final: 442 passed, 7 ignored. Release GUI+daemon built and installed in `/Applications/OxideZap.app`; bundle signature verified and one GUI/daemon process pair started. Manual WhatsApp interaction and web smoke were not run. - -### Completion Notes List - -- Sent-message edits and both delete variants use addressed GUI IPC requests; local changes occur after the daemon/session operation succeeds and the store flushes. -- Picker, drop, and clipboard files share consume-once confirmation; overlapping reads queue rather than silently dropping files. -- Chat text uses a StyledText-backed GPUI selection run; formatting and inline links remain rendered. -- The second already-open GUI window limitation for delete-for-me is recorded in Dev Notes; Mac live send/delete smoke remains for the user. The previous installed executables were backed up under `/private/tmp/oxidezap-install-backup.HDAtvp`; build cache was retained. - -### File List - -- `docs/stories/1.6.story.md` — story scope, acceptance, and progress tracking. -- `Cargo.lock`, `crates/gui/Cargo.toml` — direct `gpui-base` selection dependency. -- `crates/ipc/src/{lib,protocol,transport}.rs` — addressed GUI edit/revoke requests and IPC version. -- `crates/daemon/src/server/{requests,tests}.rs`, `crates/daemon/src/session_bridge/{act,action}.rs` — daemon routing, acknowledgments, and tests. -- `crates/session/src/whatsapp/mutations.rs`, `crates/chat-store/src/store/mod.rs`, `crates/chat-store/tests/edits.rs` — server mutations and durable local materialization. -- `crates/core/src/chat/merge.rs` — exact local delete-for-me row removal and preview update. -- `crates/gui/src/session/{mod,frames}.rs` — GUI client requests and response handling. -- `crates/gui/src/app/{mod,message_actions,attaching,calls_ctl,commands}.rs` — edit modal, message actions, shared attachment confirmation, and keyboard focus. -- `crates/gui/src/components/{message_bubble/mod,message_list,mod,paste_preview,rich_text}.rs` — menu actions, attachment preview, selectable text, and tests. -- `crates/gui/src/views/chat.rs` — keyboard surface state. - -## QA Results - -_To be completed by @qa._ diff --git a/docs/stories/1.7.story.md b/docs/stories/1.7.story.md deleted file mode 100644 index b9468b6c..00000000 --- a/docs/stories/1.7.story.md +++ /dev/null @@ -1,119 +0,0 @@ -# Story 1.7: Confirm Message Deletion - -## Status - -Ready for Review - -## Executor Assignment - -```yaml -executor: "@dev" -quality_gate: "@architect" -strategy: "sol-luna-xhigh" -quality_gate_tools: - - "cargo fmt --all -- --check" - - "cargo clippy --workspace --all-targets --all-features -- -D warnings" - - "cargo test --workspace --all-features" -``` - -## Story - -**As an** OxideZap user, -**I want** to confirm a message deletion before it is applied, -**so that** I do not accidentally remove the wrong message or choose the wrong deletion scope. - -## Context and Decisions - -Story 1.6 added the two requested deletion actions for sent messages: **Apagar para mim** and **Apagar para todos**. The user now requests a confirmation surface before either action performs the real deletion. The confirmation must be a final guard around the existing revoke requests; it does not change the server operation, deletion scopes, or eligibility rules already implemented. - -Both choices remain distinct. Confirming **Apagar para mim** must continue to request `for_everyone: false`; confirming **Apagar para todos** must continue to request `for_everyone: true`. No new deletion mode or destructive operation is introduced. - -## Acceptance Criteria - -1. Activating either existing deletion action opens an explicit confirmation surface for the exact message and chat, and does not send a revoke request, change the durable store, remove the bubble, or show an optimistic deletion before confirmation. -2. The confirmation clearly identifies the pending message and which scope will be used: **Apagar para mim** or **Apagar para todos**. The two scopes remain separate and cannot silently fall back to one another. -3. Canceling or dismissing the confirmation closes it with zero revoke requests and leaves the message, chat history, and durable state unchanged. The user can invoke the deletion action again afterward. -4. Confirming **Apagar para mim** sends exactly one request for the captured chat/message with `for_everyone: false`; confirming **Apagar para todos** sends exactly one request for the captured chat/message with `for_everyone: true`. Repeated clicks or key activation while pending cannot submit duplicates. -5. A successful confirmation follows the existing deletion result path and updates the correct visible and durable message state. A rejected or failed request closes or resolves the pending operation without falsely removing the message and presents the existing error feedback path. -6. The confirmation remains bound to the message and chat that opened it. Switching chats, receiving unrelated messages, or opening another action cannot cause the pending confirmation to delete a different resource; stale or unavailable targets are rejected safely. -7. The confirmation applies only to the existing sent-message deletion actions. Editing, attachment confirmation, whole-message copy, text selection, incoming messages, and group-admin deletion behavior are unchanged by this story. -8. Automated tests cover both deletion scopes, exact message/chat targeting, confirmation rendering, cancel/dismiss with zero requests, confirm with exactly one request, duplicate activation, stale/failed request handling, and preservation of message state before confirmation. -9. Repository Cargo formatting, clippy, and workspace tests pass; manual desktop smoke verifies canceling both choices and confirming each choice against a real chat without deleting an unintended message. - -## Test Seams - -- Existing message context-menu deletion action -> pending confirmation state containing chat JID, message ID, and `for_everyone` -> rendered cancel/confirm actions -> one addressed `RevokeMessage` IPC request or zero requests. -- Confirmation cancel/dismiss and repeated keyboard/pointer activation -> request spy and visible message state; assert no mutation before confirmation and no duplicate after confirmation. -- Daemon/session success and failure -> exact message/chat durable-state update or preserved message plus existing error feedback; include stale target and chat-switch cases. - -## Tasks / Subtasks - -- [x] Add a confirmation state/surface around both existing sent-message deletion actions. (AC: 1-3) -- [x] Preserve and display the selected deletion scope, exact message, and chat while pending. (AC: 2, 6) -- [x] Gate the existing revoke requests behind one-shot confirmation, retaining the two boolean variants and current result/error paths. (AC: 4-6) -- [x] Add regressions for cancel, dismiss, both confirmation outcomes, duplicate activation, stale/failed requests, and no pre-confirmation mutation. (AC: 8) -- [x] Run Cargo formatting, workspace Clippy, workspace tests, and a release build; update this story's checklist, Dev Agent Record, and File List with actual results. (AC: 9) -- [ ] Manually test canceling and confirming both choices in a real chat before marking desktop smoke complete. (AC: 9) - -## Dev Notes - -- Reuse the existing message-action and GUI IPC/session revoke path from `docs/stories/1.6.story.md`; do not create another WhatsApp session or a second deletion API. -- The confirmation should use existing GUI theme, metrics, focus, and modal conventions. Its copy must distinguish the two deletion scopes without introducing unrelated settings or workflows. -- Do not perform a local optimistic removal when the confirmation opens. Server/session success remains authoritative, as established by Story 1.6. -- Keep test fixtures synthetic; do not add real phone numbers, JIDs, names, or message content. -- Preserve the repository's Cargo quality gates from `AGENTS.md`; the ancestor AIOX npm commands do not apply to this Rust workspace. -- Keep the build cache until the user has tested the installed build and explicitly approves cleanup. - -## CodeRabbit Integration - -> **CodeRabbit Integration**: Not configured -> -> This repository has no `.aiox-core/core-config.yaml`. Use the repository's Cargo, architecture-review, and CI gates. - -## Story Draft Checklist - -- [x] The user-requested confirmation guard is isolated from the already implemented deletion operations. -- [x] Both confirmed deletion scopes are mapped to their existing boolean requests. -- [x] Cancel, dismiss, confirm-once, exact-target, and failure behavior are testable. -- [x] Scope excludes unrelated message, attachment, and moderation changes. -- [x] Implementation, automated validation, and File List remain for `@dev` to record. - -## Change Log - -| Date | Version | Description | Author | -| --- | --- | --- | --- | -| 2026-09-21 | 0.1 | Created story from the user's request for confirmation before either sent-message deletion action. | River (@sm) | -| 2026-09-21 | 0.2 | Added addressed confirmation modal, focus/dismissal handling, and GUI regressions. | @dev | -| 2026-09-21 | 0.3 | Passed workspace gates and release build; installed and launched updated macOS app. Real-chat deletion smoke awaits user confirmation. | @dev | -| 2026-09-21 | 0.4 | Added captured message time to the confirmation and kept delete-for-me chat activity ordering aligned with durable history; workspace gates passed and updated macOS build installed/launched. | @dev | - -## Dev Agent Record - -### Agent Model Used - -Sol (xhigh), integrated by @dev. - -### Debug Log References - -`cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-features`; `cargo test -p oxidezap-gui message_actions::tests` (8 passed); `cargo build --release --bin oxidezap --bin oxidezapd`; `codesign --verify --deep --strict --verbose=2 /Applications/OxideZap.app`; app opened with GUI and daemon processes. Real-chat cancel/confirm smoke is pending. - -### Completion Notes List - -- Both existing delete actions now open a modal showing their selected scope, chat, and message preview. Opening or canceling it makes no revoke request. -- Confirm consumes the captured target once and delegates to the existing deletion path, which revalidates the message and current chat. Escape, switching chats, and leaving the connected view discard the confirmation. -- A test-only spy at the GUI's `delete_sent_message` boundary checks exact target/scope and one-shot invocation; it is not a real IPC-frame spy. Story 1.6 separately covers IPC serialization and daemon routing. The no-daemon test verifies preservation and visible failure feedback, not server behavior. -- The modal also shows the captured message time. Delete-for-me updates the preview but retains the chat's activity time, as the durable store does. These follow-up edits passed the complete workspace gates and are in the installed build. -- Independent review identified a separate pre-existing limitation: a delete-for-me performed in one GUI window may remain visible in a second simultaneously open window because history merging does not prune absent message IDs. This is not addressed by the confirmation guard; it requires a separate synchronization change and live validation. - -### File List - -- `docs/stories/1.7.story.md` — acceptance, progress, and validation record. -- `crates/gui/src/app/message_actions.rs` — pending deletion state, confirmation surface, gating, and GPUI regressions. -- `crates/gui/src/app/mod.rs` — addressed menu dispatch, modal state/rendering, focus surface, lifecycle cleanup, and test spy. -- `crates/gui/src/app/calls_ctl.rs`, `crates/gui/src/app/commands.rs`, `crates/gui/src/views/chat.rs` — modal keyboard ownership and Escape dismissal. -- `crates/gui/src/app/notices.rs` — test-only inspection of existing failure feedback. -- `crates/core/src/chat/merge.rs` — keep live delete-for-me list ordering consistent with the durable store; test deleting the newest and final row. - -## QA Results - -_To be completed by @qa._ diff --git a/docs/stories/1.8.story.md b/docs/stories/1.8.story.md deleted file mode 100644 index a0bc07b9..00000000 --- a/docs/stories/1.8.story.md +++ /dev/null @@ -1,117 +0,0 @@ -# Story 1.8: Indicate Edited Messages - -## Status - -Ready for Review - -## Executor Assignment - -```yaml -executor: "@dev" -quality_gate: "@architect" -strategy: "sol-luna-xhigh" -quality_gate_tools: - - "cargo fmt --all -- --check" - - "cargo clippy --workspace --all-targets --all-features -- -D warnings" - - "cargo test --workspace --all-features" -``` - -## Story - -**As an** OxideZap user, -**I want** edited messages to be visibly identified, -**so that** I can distinguish the current text from a message that was never edited. - -## Context and Decisions - -The existing message-edit flow already represents successful edits in the conversation. The user requests a visible indication for those messages, regardless of whether the message was sent by this account or received from another participant, and regardless of whether the conversation is direct or a group chat. - -This story adds presentation of the existing edited state. It does not add another edit API, change edit eligibility, alter message content, or introduce a new edit workflow. A message must be marked edited only when the existing edit/store path has accepted the edit. - -## Acceptance Criteria - -1. A successfully edited message sent by this account displays a clear edited indication in its message bubble. -2. A successfully edited message received from another participant displays the same clear edited indication, while preserving the existing sender and message layout. -3. The indication appears in both direct chats and group chats, without changing group sender names, avatars, mentions, replies, or other existing message metadata. -4. An unedited message does not display the indication. A revoked message, failed edit, canceled edit, or message whose edit has not been accepted by the existing store/history path is not presented as edited. -5. The indication remains associated with the exact edited message when several messages are visible, including adjacent own and received messages; an edit event must not mark another message in the same chat. -6. After the conversation is rebuilt from the existing durable/history path, the edited indication remains consistent with the stored edited state for both own and received messages. -7. The indication uses the existing GUI theme, typography, spacing, localization conventions, and message-bubble layout. No new edit controls, message actions, transport/API, or media-editing behavior are introduced by this story. -8. Automated tests cover successful own and received edits in direct and group-chat message models, unedited/revoked/failed-edit states, exact-message association with multiple messages, and rebuilt/history-rendered state. -9. Repository Cargo formatting, clippy, and workspace tests pass; manual desktop smoke verifies the indication for an own edited message and a received edited message in both a direct and a group conversation where available. - -## Test Seams - -- Existing durable/live edited-message state -> message model/history merge -> own and received bubble rendering; assert the indication appears only for the edited message. -- Direct and group chat message fixtures -> bubble layout with sender metadata, reply/mention context, and adjacent messages; assert the edited marker does not alter unrelated metadata. -- Rebuilt conversation/history hydration -> same edited state -> same visible indication; failed/canceled/revoked paths remain unmarked. - -## Tasks / Subtasks - -- [x] Identify the existing persisted/live edited-state field and expose it to the message-bubble presentation without creating a parallel state. (AC: 1-6) -- [x] Render a clear edited indication for own and received bubbles in direct and group chats using existing theme/layout/localization conventions. (AC: 1-3, 7) -- [x] Preserve unedited, failed, canceled, and revoked rendering behavior. (AC: 4-6) -- [x] Add regressions for both authorship cases, both chat types, exact-message targeting, adjacent messages, and durable/history rebuild. (AC: 8) -- [x] Run Cargo formatting, workspace Clippy, workspace tests, and a release build; update this story's checklist, Dev Agent Record, and File List with actual results. (AC: 9) -- [ ] Manually verify own and received edited messages in direct and group chats where available. (AC: 9) - -## Dev Notes - -- Reuse the edit state produced by the existing message/store flow from `docs/stories/1.6.story.md`; do not infer edited status from text differences or timestamps. -- Keep the behavior symmetric for own and received messages. Group rendering must retain existing sender identity and message metadata. -- Do not expand the scope to editing media, captions, incoming-message moderation, or a new edit transport. Those require separate requirements if needed. -- Keep test fixtures synthetic; do not add real phone numbers, JIDs, names, or message content. -- Preserve the repository's Cargo quality gates from `AGENTS.md`; the ancestor AIOX npm commands do not apply to this Rust workspace. -- Keep the build cache until the user has tested the installed build and explicitly approves cleanup. - -## CodeRabbit Integration - -> **CodeRabbit Integration**: Not configured -> -> This repository has no `.aiox-core/core-config.yaml`. Use the repository's Cargo, architecture-review, and CI gates. - -## Story Draft Checklist - -- [x] The user's request is limited to a visible edited-message indication. -- [x] Own/received and direct/group cases are explicit. -- [x] Success, persistence, unedited, failed, canceled, and revoked states are testable. -- [x] Existing edit behavior and unrelated message features remain out of scope. -- [x] Implementation, automated validation, and File List remain for `@dev` to record. - -## Change Log - -| Date | Version | Description | Author | -| --- | --- | --- | --- | -| 2026-09-21 | 0.1 | Created story from the user's request to identify edited own and received messages in direct and group chats. | River (@sm) | -| 2026-09-21 | 0.2 | Propagated durable edited state to the message model and bubble, with regression tests; workspace gates and live smoke pending. | @dev | -| 2026-09-21 | 0.3 | Passed workspace gates and release build; installed and launched updated macOS app. Real-chat observation awaits user confirmation. | @dev | - -## Dev Agent Record - -### Agent Model Used - -Sol (xhigh), integrated by @dev. - -### Debug Log References - -`cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-features -q`; targeted core/session/IPC/GUI tests for edited-state hydration, merge, wire compatibility, exact bubble rendering, and accepted own edits (all passed); `cargo build --release --bin oxidezap --bin oxidezapd`; `codesign --verify --deep --strict --verbose=2 /Applications/OxideZap.app`; app opened with GUI and daemon processes. Manual WhatsApp smoke is pending. - -### Completion Notes List - -- The store's existing `edited_at_ms` is exposed as a serialized-default `ChatMessage.edited` boolean; history hydration covers own/received messages in direct and group chats without a new transport or edit request. -- The GUI sets that fact after the existing successful edit response and renders `editada` alongside the bubble time for both authorship directions, hiding it on revoked rows. Cancel and failure keep it unset. -- A stale history page cannot unset an accepted marker on the same author's row, nor transfer it to a different group author on an ID collision; the durable row remains authoritative for content and timestamp. Test fixtures contain synthetic identities only. -- Full workspace gates and installation passed. Manual observation of actual edited messages in WhatsApp remains for the user. - -### File List - -- `docs/stories/1.8.story.md` — implementation and validation record. -- `crates/core/src/chat/{message,merge}.rs` — edited flag, serde default, and monotonic history merge tests. -- `crates/session/src/whatsapp/{convert,mod,tests}.rs` — durable edited-state hydration and own/peer direct/group tests. -- `crates/gui/src/app/{message_actions,messages}.rs` — mark accepted own edits and test exact bubble association. -- `crates/gui/src/components/message_bubble/mod.rs` — symmetric edited label beside time. -- `crates/ipc/tests/session_frames.rs` — edited field omitted by default and retained on wire when true. - -## QA Results - -_To be completed by @qa._