Skip to main content

vector_core/
state.rs

1//! Global state management — ChatState, globals, processing gate.
2//!
3//! All Tauri-specific globals (TAURI_APP) have been removed. Event emission
4//! uses the `EventEmitter` trait via `crate::traits::emit_event`.
5
6use nostr_sdk::prelude::*;
7use std::sync::RwLock;
8use std::collections::HashSet;
9use std::sync::atomic::{AtomicBool, Ordering};
10use tokio::sync::Mutex;
11
12use crate::chat::{Chat, ChatType};
13use crate::compact::{CompactMessage, CompactAttachment, NpubInterner, NO_NPUB};
14use crate::profile::{Profile, SlimProfile};
15use crate::types::{Message, Reaction};
16use crate::traits::emit_event;
17
18// ============================================================================
19// WrapperIdCache — Hybrid duplicate detection during sync
20// ============================================================================
21
22pub struct WrapperIdCache {
23    historical: Vec<[u8; 32]>,
24    pending: HashSet<[u8; 32]>,
25}
26
27impl WrapperIdCache {
28    pub fn new() -> Self { Self { historical: Vec::new(), pending: HashSet::new() } }
29
30    pub fn load(&mut self, mut ids: Vec<[u8; 32]>) {
31        ids.sort_unstable();
32        self.historical = ids;
33        self.pending.clear();
34    }
35
36    #[inline]
37    pub fn contains(&self, id: &[u8; 32]) -> bool {
38        self.historical.binary_search(id).is_ok() || self.pending.contains(id)
39    }
40
41    #[inline]
42    pub fn insert(&mut self, id: [u8; 32]) { self.pending.insert(id); }
43
44    pub fn clear(&mut self) {
45        self.historical.clear();
46        self.historical.shrink_to_fit();
47        self.pending.clear();
48        self.pending.shrink_to_fit();
49    }
50
51    pub fn len(&self) -> usize { self.historical.len() + self.pending.len() }
52}
53
54impl Default for WrapperIdCache {
55    fn default() -> Self { Self::new() }
56}
57
58// ============================================================================
59// Globals
60// ============================================================================
61
62pub static TRUSTED_RELAYS: &[&str] = &[
63    "wss://jskitty.com/nostr",
64    "wss://asia.vectorapp.io/nostr",
65    "wss://nostr.computingcache.com",
66    // Also listed in DISCOVERY_READ_ONLY_RELAYS: Ditto once ack'd stranger
67    // kind-10050 writes with OK true and silently dropped them, so it must
68    // never count toward the relay-list freshness anchor. General traffic
69    // (DMs, communities) is unrestricted per its NIP-11.
70    "wss://relay.ditto.pub",
71];
72
73/// Discovery Relays: widely-used indexers queried/written for relay-list
74/// events (kind 10050) ONLY — never DMs, profiles, or communities. They give
75/// list syncs and publishes a rendezvous point that doesn't depend on the
76/// user's own relay set overlapping anyone else's. Every entry must be
77/// verified to ACCEPT AND SERVE kind 10050 from unknown pubkeys (write +
78/// readback); accept-then-silently-drop policy relays reduce coverage
79/// invisibly.
80pub static DISCOVERY_RELAYS: &[&str] = &[
81    "wss://purplepag.es",
82    "wss://relay.primal.net",
83    "wss://nos.lol",
84    "wss://relay.snort.social",
85];
86
87/// Read-only Discovery Relays: queried when SYNCING relay lists but never
88/// published to. Ditto acks stranger kind-10050 writes with OK true and then
89/// silently drops them, so counting it as a publish landing would falsely
90/// advance the list-freshness anchor — but it serves ESTABLISHED accounts'
91/// lists honestly, and those are exactly the users whose existing list a
92/// fresh Vector login must find before it dares bootstrap-publish.
93pub static DISCOVERY_READ_ONLY_RELAYS: &[&str] = &["wss://relay.ditto.pub"];
94
95/// Every discovery relay url (writable + read-only) — the fetch/pool/identity
96/// set. Publish copies use `DISCOVERY_RELAYS` alone.
97pub fn discovery_relay_iter() -> impl Iterator<Item = &'static str> {
98    DISCOVERY_RELAYS
99        .iter()
100        .chain(DISCOVERY_READ_ONLY_RELAYS.iter())
101        .copied()
102}
103
104pub async fn active_trusted_relays() -> Vec<&'static str> {
105    let Some(client) = nostr_client() else { return Vec::new() };
106    let pool_relays = client.relays().await;
107    TRUSTED_RELAYS.iter().copied()
108        .filter(|url| {
109            let normalized = url.trim_end_matches('/');
110            pool_relays.keys().any(|r| r.as_str().trim_end_matches('/') == normalized)
111        })
112        .collect()
113}
114
115/// Blossom media servers with failover: this account's resolved list (defaults
116/// minus its disabled ones, plus its enabled customs).
117///
118/// Per-account, and consequential — it decides where THIS account's
119/// attachments are uploaded, so account A's self-hosted destination must never
120/// still be selected under account B. Starts as the seed list until the
121/// account's database is open and the resolver refreshes it.
122struct BlossomServers(std::sync::Mutex<Vec<String>>);
123
124impl Default for BlossomServers {
125    fn default() -> Self {
126        Self(std::sync::Mutex::new(init_blossom_servers()))
127    }
128}
129
130pub fn init_blossom_servers() -> Vec<String> {
131    crate::blossom_servers::DEFAULT_BLOSSOM_SERVERS
132        .iter().map(|s| s.to_string()).collect()
133}
134
135pub fn get_blossom_servers() -> Vec<String> {
136    let owner = crate::db::current_session().scoped::<BlossomServers, BlossomServers>();
137    let servers = owner.0.lock().unwrap().clone();
138    servers
139}
140
141/// Install this account's resolved server list.
142pub fn set_blossom_servers(servers: Vec<String>) {
143    let owner = crate::db::current_session().scoped::<BlossomServers, BlossomServers>();
144    *owner.0.lock().unwrap() = servers;
145}
146
147pub static MNEMONIC_SEED: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
148pub static PENDING_NSEC: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
149
150/// Staged bunker metadata between `connect_bunker` / `start_nostrconnect_session`
151/// and the subsequent encryption-flow commit. URL is wrapped in `Zeroizing`
152/// because it contains the NIP-46 single-use pairing secret; the pubkey hex
153/// is non-secret. `setup_encryption` / `skip_encryption` reads this when
154/// `signer_kind() == Bunker` to write the right settings rows via
155/// `commit_bunker_account_setup`.
156///
157/// Tuple order: (bunker_url, remote_pubkey_hex).
158pub static PENDING_BUNKER_SETUP:
159    std::sync::Mutex<Option<(zeroize::Zeroizing<String>, String)>> =
160    std::sync::Mutex::new(None);
161
162#[inline]
163pub fn set_pending_bunker_setup(url: String, remote_pk_hex: String) {
164    *PENDING_BUNKER_SETUP.lock().unwrap() =
165        Some((zeroize::Zeroizing::new(url), remote_pk_hex));
166}
167
168#[inline]
169pub fn pending_bunker_setup() -> Option<(String, String)> {
170    PENDING_BUNKER_SETUP.lock().unwrap()
171        .as_ref()
172        .map(|(z, pk)| (String::clone(&**z), pk.clone()))
173}
174
175#[inline]
176pub fn clear_pending_bunker_setup() {
177    *PENDING_BUNKER_SETUP.lock().unwrap() = None;
178}
179
180/// Staged NIP-55 pairing result between `login_with_nip55` and the subsequent
181/// encryption-flow commit. Both fields are public material (identity pubkey +
182/// signer package name), so no `Zeroizing` — unlike bunker, a NIP-55 account
183/// stages nothing secret. `setup_encryption` / `skip_encryption` reads this
184/// when `signer_kind() == Nip55` to write the right settings rows via
185/// `commit_nip55_account_setup`.
186///
187/// Tuple order: (user_pubkey_hex, signer_package).
188pub static PENDING_NIP55_SETUP: std::sync::Mutex<Option<(String, String)>> =
189    std::sync::Mutex::new(None);
190
191#[inline]
192pub fn set_pending_nip55_setup(user_pubkey_hex: String, signer_package: String) {
193    *PENDING_NIP55_SETUP.lock().unwrap() = Some((user_pubkey_hex, signer_package));
194}
195
196#[inline]
197pub fn pending_nip55_setup() -> Option<(String, String)> {
198    PENDING_NIP55_SETUP.lock().unwrap().clone()
199}
200
201#[inline]
202pub fn clear_pending_nip55_setup() {
203    *PENDING_NIP55_SETUP.lock().unwrap() = None;
204}
205
206pub static ENCRYPTION_KEY: crate::crypto::GuardedKey = crate::crypto::GuardedKey::empty();
207
208pub static ENCRYPTION_ENABLED: AtomicBool = AtomicBool::new(false);
209
210#[inline]
211pub fn is_encryption_enabled_fast() -> bool { ENCRYPTION_ENABLED.load(Ordering::Acquire) }
212
213#[inline]
214pub fn set_encryption_enabled(enabled: bool) { ENCRYPTION_ENABLED.store(enabled, Ordering::Release); }
215
216/// Resolve "is this account encrypted?" from raw DB settings. Single
217/// source of truth — every caller (crypto::is_encryption_enabled,
218/// init_encryption_enabled, Android bg-sync) delegates here, so the
219/// answer is consistent regardless of code path.
220///
221/// Rules:
222///   * `encryption_enabled = "false"` → not encrypted (explicit opt-out)
223///   * `encryption_enabled = "true"`  → encrypted (explicit opt-in)
224///   * row missing                    → encrypted iff `security_type` exists
225///
226/// The `security_type` fallback handles pre-multi-account installs that
227/// wrote `security_type` without `encryption_enabled`. A brand-new
228/// account has neither row, so the answer is "not encrypted".
229pub fn resolve_encryption_enabled(
230    encryption_enabled_row: Option<&str>,
231    security_type_row: Option<&str>,
232) -> bool {
233    match encryption_enabled_row {
234        Some("false") => false,
235        Some(_) => true,
236        None => security_type_row.is_some(),
237    }
238}
239
240/// Resolve from the current account's DB via the global settings helper.
241/// Returns `false` if the DB is not yet open.
242pub fn resolve_encryption_enabled_from_db() -> bool {
243    let enc = crate::db::get_sql_setting("encryption_enabled".to_string()).ok().flatten();
244    let sec = crate::db::get_sql_setting("security_type".to_string()).ok().flatten();
245    resolve_encryption_enabled(enc.as_deref(), sec.as_deref())
246}
247
248pub fn init_encryption_enabled() {
249    let enabled = resolve_encryption_enabled_from_db();
250    set_encryption_enabled(enabled);
251}
252
253#[cfg(test)]
254mod resolve_encryption_enabled_tests {
255    use super::*;
256
257    #[test]
258    fn explicit_false_wins_even_with_security_type() {
259        assert!(!resolve_encryption_enabled(Some("false"), Some("password")));
260    }
261
262    #[test]
263    fn explicit_true_is_encrypted() {
264        assert!(resolve_encryption_enabled(Some("true"), None));
265    }
266
267    #[test]
268    fn missing_row_defaults_to_security_type_presence() {
269        // Pre-multi-account install that wrote security_type but not the
270        // encryption_enabled flag — must be treated as encrypted.
271        assert!(resolve_encryption_enabled(None, Some("password")));
272        // Fresh account with no rows — must be treated as unencrypted.
273        assert!(!resolve_encryption_enabled(None, None));
274    }
275
276    #[test]
277    fn explicit_non_false_value_is_encrypted() {
278        // Anything other than the literal "false" string is treated as
279        // encrypted, matching the previous behaviour of `v != "false"`.
280        assert!(resolve_encryption_enabled(Some("1"), None));
281        assert!(resolve_encryption_enabled(Some(""), None));
282    }
283}
284
285// ============================================================================
286// Per-session globals — must be resettable for inline account swap
287// ============================================================================
288//
289// `NOSTR_CLIENT` and `MY_PUBLIC_KEY` are RwLock<Option<_>> rather than OnceLock
290// so `reset_session()` can swap them atomically — mobile cannot rely on
291// `app.restart()`. Callers should prefer the helpers below over locking directly.
292
293/// The relay client and identity this account signs and publishes with.
294///
295/// On the session, not global: a task that outlived a swap would otherwise
296/// reach the NEW account's client and publish the previous account's payload
297/// under the wrong identity. A bound task reaches the client it started with,
298/// which the swap has already shut down, so its send fails instead.
299///
300/// A login fills these before its database exists — creating an account
301/// installs the keys and client, and only reaches `init_database` once the
302/// user has chosen a PIN — which is why binding a session to a database keeps
303/// what it holds rather than replacing it.
304struct ActiveClient;
305struct ActiveIdentity;
306
307fn active_client() -> std::sync::Arc<RwLock<Option<Client>>> {
308    crate::db::current_session().scoped::<ActiveClient, _>()
309}
310
311fn active_identity() -> std::sync::Arc<RwLock<Option<PublicKey>>> {
312    crate::db::current_session().scoped::<ActiveIdentity, _>()
313}
314
315pub static MY_SECRET_KEY: crate::crypto::GuardedKey = crate::crypto::GuardedKey::empty();
316
317/// Get a clone of the active Nostr client. The clone is cheap — `Client`
318/// is internally `Arc`-counted, so all clones share connections, signers,
319/// and subscription state. Returns `None` when no session is active.
320#[inline]
321pub fn nostr_client() -> Option<Client> {
322    active_client().read().unwrap().as_ref().cloned()
323}
324
325/// Returns `true` when there is an active session (client + pubkey set).
326#[inline]
327pub fn has_active_session() -> bool {
328    active_client().read().unwrap().is_some()
329}
330
331/// Get the active user's public key. `PublicKey` is `Copy`, so this is by-value.
332#[inline]
333pub fn my_public_key() -> Option<PublicKey> {
334    *active_identity().read().unwrap()
335}
336
337/// Install the Nostr client for the current session. Replaces any prior client
338/// without shutting it down — `reset_session()` is responsible for orderly
339/// teardown of the outgoing client.
340#[inline]
341pub fn set_nostr_client(client: Client) {
342    *active_client().write().unwrap() = Some(client);
343}
344
345/// Install `client` only if this session has none yet, reporting whether it
346/// took. Logins race on Android, where a service-only background sync can
347/// install a client before the Activity's login reaches this point; the loser
348/// must drop its own rather than replace a live one.
349#[inline]
350pub fn set_nostr_client_if_absent(client: Client) -> bool {
351    let owner = active_client();
352    let mut slot = owner.write().unwrap();
353    if slot.is_some() {
354        return false;
355    }
356    *slot = Some(client);
357    true
358}
359
360/// Install the active user's public key for the current session.
361#[inline]
362pub fn set_my_public_key(pk: PublicKey) {
363    *active_identity().write().unwrap() = Some(pk);
364}
365
366/// Atomically take the current Nostr client out of global state.
367/// Used by `reset_session()` so the post-take shutdown call doesn't race
368/// with new readers.
369#[inline]
370pub fn take_nostr_client() -> Option<Client> {
371    active_client().write().unwrap().take()
372}
373
374/// Clear `MY_PUBLIC_KEY`. The Nostr client is taken separately via
375/// `take_nostr_client()` so the caller can shut it down before this clear.
376#[inline]
377pub fn clear_my_public_key() {
378    *active_identity().write().unwrap() = None;
379}
380
381#[derive(Clone)]
382pub struct PendingInviteAcceptance {
383    pub invite_code: String,
384    pub inviter_pubkey: PublicKey,
385}
386
387// Per-session: tracks an invite captured during account-creation that should
388// be broadcast to relays once login finishes. Must reset across accounts so a
389// pending invite captured for account A doesn't auto-execute on account B.
390/// Per-account: an invite captured while creating account A must never
391/// auto-execute once account B is live.
392struct PendingInvite;
393
394fn pending_invite_slot() -> std::sync::Arc<RwLock<Option<PendingInviteAcceptance>>> {
395    crate::db::current_session().scoped::<PendingInvite, _>()
396}
397
398#[inline]
399pub fn pending_invite() -> Option<PendingInviteAcceptance> {
400    pending_invite_slot().read().unwrap().clone()
401}
402
403#[inline]
404pub fn set_pending_invite(invite: PendingInviteAcceptance) {
405    *pending_invite_slot().write().unwrap() = Some(invite);
406}
407
408#[inline]
409pub fn clear_pending_invite() {
410    *pending_invite_slot().write().unwrap() = None;
411}
412
413/// npubs already welcomed, so the greeting fires once per contact — per
414/// account, since the contact list is.
415pub struct NotifiedWelcomesHandle;
416
417pub static NOTIFIED_WELCOMES: NotifiedWelcomesHandle = NotifiedWelcomesHandle;
418
419impl NotifiedWelcomesHandle {
420    pub async fn lock(&self) -> tokio::sync::OwnedMutexGuard<HashSet<String>> {
421        crate::db::current_session().scoped::<Self, Mutex<HashSet<String>>>().lock_owned().await
422    }
423}
424
425// ============================================================================
426// Session generation — defends background tasks against account swaps
427// ============================================================================
428//
429
430/// Session-scoped tombstones for NIP-09-deleted DM messages. The batching flush consults
431/// this POSITIVE deletion signal — never STATE absence, which also means LRU-evicted or
432/// expired (an evicted archive message must still persist). A deletion whose target sat
433/// unflushed in a batch buffer no-ops its `delete_event`; the tombstone stops the flush
434/// from resurrecting the row. Cleared on session bump — ids are meaningless across accounts.
435/// Event ids are meaningless across accounts, so this is per-account.
436struct DeletedMessageTombstones;
437
438fn deleted_message_tombstones() -> std::sync::Arc<std::sync::Mutex<HashSet<String>>> {
439    crate::db::current_session().scoped::<DeletedMessageTombstones, _>()
440}
441
442/// Record that `message_id` was deleted this session (called where the deletion is decided).
443pub fn note_message_deleted(message_id: &str) {
444    if let Ok(mut set) = deleted_message_tombstones().lock() {
445        set.insert(message_id.to_string());
446    }
447}
448
449/// Seed the tombstone set from the account's durable `deleted_messages` rows,
450/// making `was_message_deleted` survive restarts and account swaps. Called at
451/// account DB init (the set was cleared by the session bump on swap).
452pub fn seed_message_tombstones(ids: Vec<String>) {
453    if let Ok(mut set) = deleted_message_tombstones().lock() {
454        set.extend(ids);
455    }
456}
457
458/// Whether `message_id` was deleted this session.
459pub fn was_message_deleted(message_id: &str) -> bool {
460    deleted_message_tombstones().lock().map(|s| s.contains(message_id)).unwrap_or(false)
461}
462
463/// Drop the delete-tombstone set on an account switch.
464///
465/// All that survives of the old session-generation counter. Everything else it
466/// invalidated now lives on the session and goes when that does; these are
467/// keyed by event id, which means nothing across accounts either way.
468#[inline]
469pub fn clear_message_tombstones() {
470    if let Ok(mut set) = deleted_message_tombstones().lock() {
471        set.clear();
472    }
473}
474
475#[cfg(test)]
476mod session_identity_tests {
477
478    /// A session held across an account switch knows it is no longer the one on
479    /// screen. This is the whole of what the generation counter used to do, and
480    /// it now falls out of the session's own identity.
481    #[test]
482    fn a_held_session_stops_being_live_once_the_account_changes() {
483        let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
484        let held = crate::db::current_session();
485        assert!(held.is_live(), "it is the account on screen to begin with");
486
487        crate::db::close_database();
488
489        assert!(!held.is_live(), "and is not, once the account has switched");
490        assert!(crate::db::current_session().is_live(), "whoever came next is");
491        assert_ne!(held.id(), crate::db::current_session_id(), "they are different sessions");
492    }
493}
494
495#[cfg(test)]
496mod session_globals_tests {
497    use super::*;
498
499    /// Single combined test so the global statics aren't raced by parallel
500    /// runners. cargo runs each `#[test]` on its own thread, so anything
501    /// touching `MY_PUBLIC_KEY`/`PENDING_INVITE` lives here.
502    #[test]
503    fn session_helpers_round_trip_and_clear() {
504        // Defensive cleanup: a previous test panic could have left global
505        // state behind. Start every run from a known-empty baseline so the
506        // assertions below aren't fooled by leftover values.
507        clear_my_public_key();
508        clear_pending_invite();
509
510        // PublicKey: set → get → clear.
511        // Use a deterministic key from a hex seed; nostr_sdk::prelude::Keys::generate()
512        // would also work but the deterministic form makes failures easier
513        // to reproduce.
514        let keys = Keys::parse(
515            "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
516        ).expect("parse test nsec");
517        let pk = keys.public_key();
518
519        assert_eq!(my_public_key(), None, "starts as None");
520
521        set_my_public_key(pk);
522        assert_eq!(my_public_key(), Some(pk));
523
524        clear_my_public_key();
525        assert_eq!(my_public_key(), None, "cleared returns None");
526
527        // PendingInvite: set → get → clear.
528        let invite = PendingInviteAcceptance {
529            invite_code: "abc123".to_string(),
530            inviter_pubkey: pk,
531        };
532
533        assert!(pending_invite().is_none(), "starts as None");
534
535        set_pending_invite(invite.clone());
536        let got = pending_invite().expect("set then read");
537        assert_eq!(got.invite_code, invite.invite_code);
538        assert_eq!(got.inviter_pubkey, invite.inviter_pubkey);
539
540        clear_pending_invite();
541        assert!(pending_invite().is_none(), "cleared returns None");
542
543        // Take semantics for nostr_client: with no client installed,
544        // `take_nostr_client()` returns None and is_active is false.
545        assert!(!has_active_session(), "no client installed");
546        assert!(take_nostr_client().is_none(), "take from empty returns None");
547        assert!(!has_active_session(), "still none after take-of-empty");
548
549        // PendingBunkerSetup: set → peek → clear. The URL slot is wrapped in
550        // Zeroizing<String> because it carries the NIP-46 single-use pairing
551        // secret. The accessors return plain String clones so callers don't
552        // accidentally consume the protected buffer.
553        clear_pending_bunker_setup();
554        assert!(pending_bunker_setup().is_none(), "starts as None");
555
556        let url = "bunker://0123456789abcdef?relay=wss%3A%2F%2Frelay.example&secret=topsecret".to_string();
557        let pk_hex = "0123456789abcdef".to_string();
558        set_pending_bunker_setup(url.clone(), pk_hex.clone());
559
560        // Peek twice — the underlying Zeroizing<String> must NOT be consumed
561        // by either read, so successive reads return identical contents.
562        let first = pending_bunker_setup().expect("first peek");
563        let second = pending_bunker_setup().expect("second peek");
564        assert_eq!(first.0, url, "url survives clone-out from Zeroizing");
565        assert_eq!(first.0, second.0, "successive peeks return same data");
566        assert_eq!(first.1, pk_hex);
567
568        // Overwrite — replacing the slot scrubs the prior Zeroizing<String>
569        // on Drop. We can't assert heap-residue from a test, but we CAN
570        // assert the new value is what's exposed.
571        let url2 = "bunker://feedface?relay=wss%3A%2F%2Falt".to_string();
572        set_pending_bunker_setup(url2.clone(), "feedface".to_string());
573        let after = pending_bunker_setup().expect("overwritten read");
574        assert_eq!(after.0, url2);
575        assert_eq!(after.1, "feedface");
576
577        clear_pending_bunker_setup();
578        assert!(pending_bunker_setup().is_none(), "cleared returns None");
579
580        // PendingNip55Setup: set → peek → clear. Both fields are public
581        // material (identity pubkey + signer package), so no Zeroizing — a
582        // NIP-55 account stages nothing secret.
583        clear_pending_nip55_setup();
584        assert!(pending_nip55_setup().is_none(), "starts as None");
585
586        let user_pk = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string();
587        let package = "com.greenart7c3.nostrsigner".to_string();
588        set_pending_nip55_setup(user_pk.clone(), package.clone());
589        let peek1 = pending_nip55_setup().expect("first peek");
590        let peek2 = pending_nip55_setup().expect("second peek");
591        assert_eq!(peek1.0, user_pk);
592        assert_eq!(peek1.1, package);
593        assert_eq!(peek1, peek2, "successive peeks return same data");
594
595        // Overwrite replaces cleanly.
596        set_pending_nip55_setup("beef".to_string(), "org.other.signer".to_string());
597        let after55 = pending_nip55_setup().expect("overwritten read");
598        assert_eq!(after55.0, "beef");
599        assert_eq!(after55.1, "org.other.signer");
600
601        clear_pending_nip55_setup();
602        assert!(pending_nip55_setup().is_none(), "cleared returns None");
603
604        // A session held across the switch knows it is no longer current.
605        let held = crate::db::current_session();
606        assert!(held.is_live(), "the account on screen to begin with");
607        crate::db::close_database();
608        assert!(!held.is_live(), "and not, once the account has switched");
609    }
610}
611
612/// Which wrapper events this account has already ingested.
613///
614/// Per-account: the ids come from its relays and gate writes to its database.
615/// Same handle shape as [`STATE`], and for the same reason.
616pub struct WrapperIdCacheHandle;
617
618pub static WRAPPER_ID_CACHE: WrapperIdCacheHandle = WrapperIdCacheHandle;
619
620impl WrapperIdCacheHandle {
621    pub async fn lock(&self) -> tokio::sync::OwnedMutexGuard<WrapperIdCache> {
622        crate::db::current_session().scoped::<Self, Mutex<WrapperIdCache>>().lock_owned().await
623    }
624}
625
626/// The account's chats and profiles in memory.
627///
628/// Locking resolves the session THIS work belongs to, so a task bound by
629/// [`crate::db::spawn_bound`] reaches the state of the account it started
630/// under — for its whole life, whoever logs in meanwhile. A swap installs a
631/// fresh session, and with it a fresh state; the previous account's is dropped
632/// with the session rather than cleared field by field.
633///
634/// Deliberately a handle rather than the `Mutex` itself: `STATE.lock().await`
635/// reads the same at every call site, and cannot be hoisted into a `static` or
636/// a long-lived struct where it would freeze one account's state in place.
637pub struct ChatStateHandle;
638
639pub static STATE: ChatStateHandle = ChatStateHandle;
640
641impl ChatStateHandle {
642    pub async fn lock(&self) -> tokio::sync::OwnedMutexGuard<ChatState> {
643        crate::db::current_session().chat_state().lock_owned().await
644    }
645
646    /// For callers that must not block — notably Android's WebView threads,
647    /// which have no tokio runtime.
648    pub fn try_lock(&self) -> Result<tokio::sync::OwnedMutexGuard<ChatState>, tokio::sync::TryLockError> {
649        crate::db::current_session().chat_state().try_lock_owned()
650    }
651}
652
653/// Chat id currently visible to the user with auto-mark eligibility — set by
654/// the frontend when the chat is open AND pinned to bottom AND the window is
655/// active. Used by the inbound event handler to mark new messages read on
656/// arrival, so the dock badge never bumps for messages the user is actively
657/// watching. Cleared when any of those conditions flips.
658/// Per-account: the id is one of this account's chats, and a contact shared
659/// with another account would otherwise let A's open chat mark B's messages read.
660struct ActiveChat;
661
662fn active_chat() -> std::sync::Arc<RwLock<Option<String>>> {
663    crate::db::current_session().scoped::<ActiveChat, _>()
664}
665
666pub fn set_active_chat(chat_id: Option<String>) {
667    if let Ok(mut guard) = active_chat().write() {
668        *guard = chat_id;
669    }
670}
671
672pub fn get_active_chat() -> Option<String> {
673    active_chat().read().ok().and_then(|g| g.clone())
674}
675
676// ============================================================================
677// Processing Gate — Controls event processing during encryption migration
678// ============================================================================
679
680pub static PROCESSING_GATE: AtomicBool = AtomicBool::new(true);
681/// Events parked while the processing gate is closed (encryption migration).
682/// Per-account: they are this account's inbound traffic, awaiting its key.
683pub struct PendingEventsHandle;
684
685pub static PENDING_EVENTS: PendingEventsHandle = PendingEventsHandle;
686
687impl PendingEventsHandle {
688    pub async fn lock(&self) -> tokio::sync::OwnedMutexGuard<Vec<(Event, bool)>> {
689        crate::db::current_session().scoped::<Self, Mutex<Vec<(Event, bool)>>>().lock_owned().await
690    }
691}
692
693#[inline]
694pub fn is_processing_allowed() -> bool { PROCESSING_GATE.load(Ordering::Acquire) }
695pub fn close_processing_gate() { PROCESSING_GATE.store(false, Ordering::Release); }
696pub fn open_processing_gate() { PROCESSING_GATE.store(true, Ordering::Release); }
697
698// ============================================================================
699// ChatState
700// ============================================================================
701
702#[derive(Clone, Debug)]
703pub struct ChatState {
704    pub profiles: Vec<Profile>,
705    pub chats: Vec<Chat>,
706    pub interner: NpubInterner,
707    pub is_syncing: bool,
708    pub db_loaded: bool,
709    /// Authoritative per-chat unread counts (chat_identifier → count), so the badge recount is an
710    /// in-RAM fold instead of a whole-DB scan. Seeded once per account from `db::unread_counts` (see
711    /// `unread_seeded`), then kept current per-chat: cleared on read, reconciled from the DB
712    /// (`db::unread_count_for_chat`) on inbound / delete / mark-unread. SENDER-level muted/blocked
713    /// filtering is baked into the DB-sourced values (mute/block toggles reseed); CHAT-level
714    /// filtering happens at fold time, matching `sum_unread_from`. Cleared on account reset.
715    pub unread_cache: std::collections::HashMap<String, u32>,
716    /// False until `unread_cache` has been seeded from the DB for this account. Guards the one-time
717    /// seed so the full-scan query runs once per login, never per message.
718    pub unread_seeded: bool,
719    #[cfg(debug_assertions)]
720    pub cache_stats: crate::stats::CacheStats,
721}
722
723impl ChatState {
724    pub fn new() -> Self {
725        Self {
726            profiles: Vec::new(),
727            chats: Vec::new(),
728            interner: NpubInterner::new(),
729            is_syncing: false,
730            db_loaded: false,
731            unread_cache: std::collections::HashMap::new(),
732            unread_seeded: false,
733            #[cfg(debug_assertions)]
734            cache_stats: crate::stats::CacheStats::new(),
735        }
736    }
737
738    // ========================================================================
739    // Profile Management
740    // ========================================================================
741
742    pub fn merge_db_profiles(&mut self, slim_profiles: Vec<SlimProfile>, my_npub: &str) {
743        for slim in slim_profiles {
744            let mut full_profile = slim.to_profile();
745            full_profile.flags.set_mine(slim.id == my_npub);
746            self.insert_or_replace_profile(&slim.id, full_profile);
747        }
748    }
749
750    pub fn insert_or_replace_profile(&mut self, npub: &str, mut profile: Profile) {
751        let id = self.interner.intern(npub);
752        profile.id = id;
753        match self.profiles.binary_search_by(|p| p.id.cmp(&id)) {
754            Ok(idx) => self.profiles[idx] = profile,
755            Err(idx) => self.profiles.insert(idx, profile),
756        }
757    }
758
759    pub fn get_profile(&self, npub: &str) -> Option<&Profile> {
760        self.interner.lookup(npub).and_then(|id| self.get_profile_by_id(id))
761    }
762
763    pub fn get_profile_mut(&mut self, npub: &str) -> Option<&mut Profile> {
764        self.interner.lookup(npub).and_then(move |id| self.get_profile_mut_by_id(id))
765    }
766
767    #[inline]
768    pub fn get_profile_by_id(&self, id: u16) -> Option<&Profile> {
769        self.profiles.binary_search_by(|p| p.id.cmp(&id)).ok().map(|idx| &self.profiles[idx])
770    }
771
772    #[inline]
773    pub fn get_profile_mut_by_id(&mut self, id: u16) -> Option<&mut Profile> {
774        self.profiles.binary_search_by(|p| p.id.cmp(&id)).ok().map(move |idx| &mut self.profiles[idx])
775    }
776
777    pub fn serialize_profile(&self, id: u16) -> Option<SlimProfile> {
778        self.get_profile_by_id(id).map(|p| SlimProfile::from_profile(p, &self.interner))
779    }
780
781    // ========================================================================
782    // Chat Management
783    // ========================================================================
784
785    pub fn get_chat(&self, id: &str) -> Option<&Chat> { self.chats.iter().find(|c| c.id == id) }
786    pub fn get_chat_mut(&mut self, id: &str) -> Option<&mut Chat> { self.chats.iter_mut().find(|c| c.id == id) }
787
788    pub fn create_dm_chat(&mut self, their_npub: &str) -> String {
789        if self.get_chat(their_npub).is_none() {
790            let chat = Chat::new_dm(their_npub.to_string(), &mut self.interner);
791            self.chats.push(chat);
792        }
793        their_npub.to_string()
794    }
795
796    // ========================================================================
797    // Message Management
798    // ========================================================================
799
800    /// Ensure a Community channel chat exists, created as `ChatType::Community`.
801    pub fn ensure_community_chat(&mut self, channel_id: &str) {
802        if !self.chats.iter().any(|c| c.id == channel_id) {
803            let chat =
804                Chat::new_community_channel(channel_id.to_string(), Vec::new(), &mut self.interner);
805            self.chats.push(chat);
806        }
807    }
808
809    /// Create-or-update a Community channel chat with its display metadata, so the chat
810    /// row carries name/description/owning-community directly (and persists + loads like
811    /// any DM — no separate hydrate). `is_owner`/`has_icon` are stored as "true"/"1"
812    /// strings in `custom_fields`. The caller persists the row (`save_slim_chat`).
813    ///
814    /// `name` is the COMMUNITY's name (every channel row carries it, so the chat list can
815    /// label a community from any one of its rows); `channel_name` is this channel's own.
816    /// `primary_channel` is the community's primary channel id — equal to `channel_id` on
817    /// that row, and what the UI uses to render one list row per community while still
818    /// holding a chat per channel.
819    pub fn upsert_community_chat(
820        &mut self,
821        channel_id: &str,
822        name: &str,
823        description: &str,
824        community_id: &str,
825        is_owner: bool,
826        has_icon: bool,
827        owner_npub: Option<&str>,
828        created_at_ms: Option<u64>,
829        dissolved: bool,
830        protocol: crate::community::ConcordProtocol,
831        channel_name: &str,
832        primary_channel: &str,
833    ) {
834        self.ensure_community_chat(channel_id);
835        if let Some(chat) = self.chats.iter_mut().find(|c| c.id == channel_id) {
836            let cf = &mut chat.metadata.custom_fields;
837            cf.insert("name".to_string(), name.to_string());
838            cf.insert("channel_name".to_string(), channel_name.to_string());
839            cf.insert("primary_channel".to_string(), primary_channel.to_string());
840            cf.insert("description".to_string(), description.to_string());
841            cf.insert("community_id".to_string(), community_id.to_string());
842            cf.insert("is_owner".to_string(), is_owner.to_string());
843            // Protocol stack (1 = v1, 2 = v2) — the GUI gates v2-only affordances
844            // (e.g. the Self-Destruct Timer) on this so a v1 community never shows
845            // a control the v1 send path would silently ignore. Never DOWNGRADE:
846            // a dual-stack community identified as v2 by register_v2_chats stays v2
847            // even if a v1-typed sync path (sync_community_chats / finalize_member_join)
848            // re-registers its chat afterwards. Protocol only ever advances.
849            let new_proto = protocol.as_i64();
850            let cur_proto = cf.get("proto_version").and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
851            cf.insert("proto_version".to_string(), new_proto.max(cur_proto).to_string());
852            // Owner-dissolution seal — the GUI reads this to lock the composer + show the end divider.
853            cf.insert("dissolved".to_string(), dissolved.to_string());
854            // Join time — sorts a not-yet-active community by when we joined, not to the bottom.
855            if let Some(ms) = created_at_ms {
856                cf.insert("created_at".to_string(), ms.to_string());
857            }
858            // The PROVEN owner npub (verified upstream) — for the crown/hoist + in-chat tag.
859            match owner_npub {
860                Some(n) => { cf.insert("owner_npub".to_string(), n.to_string()); }
861                None => { cf.remove("owner_npub"); }
862            }
863            if has_icon {
864                cf.insert("icon".to_string(), "1".to_string());
865            } else {
866                cf.remove("icon");
867            }
868        }
869    }
870
871    pub fn add_message_to_chat(&mut self, chat_id: &str, message: &Message) -> bool {
872        let compact = CompactMessage::from_message(message, &mut self.interner);
873
874        let (is_msg_added, chat_idx) = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
875            let added = self.chats[idx].add_compact_message(compact);
876            (added, idx)
877        } else {
878            let mut chat = if chat_id.starts_with("npub1") {
879                Chat::new_dm(chat_id.to_string(), &mut self.interner)
880            } else {
881                Chat::new(chat_id.to_string(), ChatType::Community, vec![])
882            };
883            let was_added = chat.add_compact_message(compact);
884            self.chats.push(chat);
885            (was_added, self.chats.len() - 1)
886        };
887
888        if is_msg_added && chat_idx > 0 {
889            let this_time = self.chats[chat_idx].last_message_time();
890            let target = self.chats[..chat_idx].iter()
891                .position(|c| c.last_message_time() <= this_time)
892                .unwrap_or(chat_idx);
893            if target < chat_idx {
894                self.chats[target..=chat_idx].rotate_right(1);
895            }
896        }
897
898        is_msg_added
899    }
900
901    pub fn add_messages_to_chat_batch(&mut self, chat_id: &str, messages: Vec<Message>) -> usize {
902        if messages.is_empty() { return 0; }
903
904        let compact_messages: Vec<_> = messages.into_iter()
905            .map(|msg| CompactMessage::from_message_owned(msg, &mut self.interner))
906            .collect();
907
908        let chat_idx = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
909            idx
910        } else {
911            let chat = if chat_id.starts_with("npub1") {
912                Chat::new_dm(chat_id.to_string(), &mut self.interner)
913            } else {
914                Chat::new(chat_id.to_string(), ChatType::Community, vec![])
915            };
916            self.chats.push(chat);
917            self.chats.len() - 1
918        };
919
920        let old_last_time = self.chats[chat_idx].messages.last_timestamp();
921        let added = self.chats[chat_idx].messages.insert_batch(compact_messages);
922
923        if added > 0 && self.chats[chat_idx].messages.last_timestamp() != old_last_time && chat_idx > 0 {
924            let this_time = self.chats[chat_idx].last_message_time();
925            let target = self.chats[..chat_idx].iter()
926                .position(|c| c.last_message_time() <= this_time)
927                .unwrap_or(chat_idx);
928            if target < chat_idx {
929                self.chats[target..=chat_idx].rotate_right(1);
930            }
931        }
932
933        added
934    }
935
936    /// Add a message to a participant's DM chat. Creates profile if missing.
937    ///
938    /// Unlike the src-tauri version, emitting `profile_update` is the caller's responsibility.
939    pub fn add_message_to_participant(&mut self, their_npub: &str, message: &Message) -> bool {
940        let id = self.interner.intern(their_npub);
941        if self.get_profile_by_id(id).is_none() {
942            let profile = Profile::new();
943            self.insert_or_replace_profile(their_npub, profile);
944
945            // Emit profile update via EventEmitter trait (replaces TAURI_APP.emit)
946            if let Some(slim) = self.serialize_profile(id) {
947                emit_event("profile_update", &slim);
948            }
949        }
950
951        let chat_id = self.create_dm_chat(their_npub);
952        self.add_message_to_chat(&chat_id, message)
953    }
954
955    // ========================================================================
956    // Message Lookup
957    // ========================================================================
958
959    pub fn find_message(&self, message_id: &str) -> Option<(&Chat, Message)> {
960        if message_id.is_empty() { return None; }
961        for chat in &self.chats {
962            if let Some(compact) = chat.get_compact_message(message_id) {
963                return Some((chat, compact.to_message(&self.interner)));
964            }
965        }
966        None
967    }
968
969    pub fn find_chat_for_message(&self, message_id: &str) -> Option<(usize, String)> {
970        if message_id.is_empty() { return None; }
971        for (idx, chat) in self.chats.iter().enumerate() {
972            if chat.has_message(message_id) { return Some((idx, chat.id.clone())); }
973        }
974        None
975    }
976
977    pub fn update_message<F>(&mut self, message_id: &str, f: F) -> Option<(String, Message)>
978    where F: FnOnce(&mut CompactMessage)
979    {
980        if message_id.is_empty() { return None; }
981        let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
982        if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
983        let chat_id = self.chats[chat_idx].id.clone();
984        self.chats[chat_idx].get_compact_message(message_id).map(|m| (chat_id, m.to_message(&self.interner)))
985    }
986
987    pub fn update_message_in_chat<F>(&mut self, chat_id: &str, message_id: &str, f: F) -> Option<Message>
988    where F: FnOnce(&mut CompactMessage)
989    {
990        let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
991        if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
992        self.chats[chat_idx].get_compact_message(message_id).map(|m| m.to_message(&self.interner))
993    }
994
995    pub fn finalize_pending_message(&mut self, chat_id: &str, pending_id: &str, real_id: &str) -> Option<(String, Message)> {
996        let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
997        if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(pending_id) {
998            msg.id = crate::simd::hex::hex_to_bytes_32(real_id);
999            msg.set_pending(false);
1000        }
1001        self.chats[chat_idx].messages.rebuild_index();
1002        self.chats[chat_idx].get_compact_message(real_id)
1003            .map(|m| (pending_id.to_string(), m.to_message(&self.interner)))
1004    }
1005
1006    pub fn update_attachment<F>(&mut self, chat_hint: &str, msg_id: &str, attachment_id: &str, f: F) -> bool
1007    where F: FnOnce(&mut CompactAttachment)
1008    {
1009        for chat in &mut self.chats {
1010            let is_target = match &chat.chat_type {
1011                // Community channels are addressed by their id.
1012                ChatType::Community => chat.id == chat_hint,
1013                ChatType::DirectMessage => chat.has_participant(chat_hint, &self.interner),
1014            };
1015            if is_target {
1016                if let Some(msg) = chat.messages.find_by_hex_id_mut(msg_id) {
1017                    if let Some(att) = msg.attachments.iter_mut().find(|a| a.id_eq(attachment_id)) {
1018                        f(att);
1019                        return true;
1020                    }
1021                }
1022            }
1023        }
1024        false
1025    }
1026
1027    pub fn add_attachment_to_message(&mut self, chat_id: &str, msg_id: &str, attachment: CompactAttachment) -> bool {
1028        let chat_idx = match self.chats.iter().position(|c| c.id == chat_id || c.has_participant(chat_id, &self.interner)) {
1029            Some(idx) => idx,
1030            None => return false,
1031        };
1032        if let Some(msg) = self.chats[chat_idx].messages.find_by_hex_id_mut(msg_id) {
1033            msg.attachments.push(attachment);
1034            true
1035        } else { false }
1036    }
1037
1038    pub fn add_reaction_to_message(&mut self, message_id: &str, reaction: Reaction) -> Option<(String, bool)> {
1039        if message_id.is_empty() { return None; }
1040        let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
1041        let chat_id = self.chats[chat_idx].id.clone();
1042        let msg = self.chats[chat_idx].get_compact_message_mut(message_id)?;
1043        let added = msg.add_reaction(reaction, &mut self.interner);
1044        Some((chat_id, added))
1045    }
1046
1047    /// Locate a reaction by its event id across all chats.
1048    /// Returns `(chat_id, parent_message_id, author_npub, is_community)`.
1049    pub fn find_reaction(&self, reaction_id: &str) -> Option<(String, String, String, bool)> {
1050        if reaction_id.is_empty() { return None; }
1051        let target = crate::simd::hex::hex_to_bytes_32(reaction_id);
1052        for chat in &self.chats {
1053            for msg in chat.iter_compact() {
1054                if let Some(r) = msg.reactions.iter().find(|r| r.id == target) {
1055                    let author = self.interner.resolve(r.author_idx).unwrap_or("").to_string();
1056                    return Some((chat.id.clone(), msg.id_hex(), author, chat.is_community()));
1057                }
1058            }
1059        }
1060        None
1061    }
1062
1063    /// Remove a reaction from its parent message. Returns `(chat_id, updated Message)`
1064    /// for the UI refresh, or `None` if the reaction wasn't present.
1065    pub fn remove_reaction_from_message(&mut self, message_id: &str, reaction_id: &str) -> Option<(String, Message)> {
1066        if message_id.is_empty() { return None; }
1067        let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
1068        let removed = self.chats[chat_idx]
1069            .get_compact_message_mut(message_id)
1070            .map(|m| m.remove_reaction(reaction_id))
1071            .unwrap_or(false);
1072        if !removed { return None; }
1073        let chat_id = self.chats[chat_idx].id.clone();
1074        self.chats[chat_idx]
1075            .get_compact_message(message_id)
1076            .map(|m| (chat_id, m.to_message(&self.interner)))
1077    }
1078
1079    pub fn remove_message(&mut self, message_id: &str) -> Option<(String, Message)> {
1080        if message_id.is_empty() { return None; }
1081        for chat in &mut self.chats {
1082            if let Some(compact) = chat.messages.find_by_hex_id(message_id) {
1083                let msg = compact.to_message(&self.interner);
1084                let removed_id = compact.id;
1085                let removed_at = compact.at;
1086                let chat_id = chat.id.clone();
1087                let was_marker = chat.last_read == removed_id;
1088                chat.messages.remove_by_hex_id(message_id);
1089                // A deleted read marker leaves `last_read` dangling and collapses the unread anchor
1090                // (badge stuck at 99+); retreat it to the newest surviving contact message before the
1091                // deleted one, or clear it. Mirrors the DB retreat in `db::events::delete_event`.
1092                if was_marker {
1093                    chat.last_read = chat.messages.iter().rev()
1094                        .find(|m| m.at <= removed_at && !m.flags.is_mine())
1095                        .map(|m| m.id)
1096                        .unwrap_or([0u8; 32]);
1097                }
1098                return Some((chat_id, msg));
1099            }
1100        }
1101        None
1102    }
1103
1104    pub fn message_exists(&self, message_id: &str) -> bool {
1105        !message_id.is_empty() && self.chats.iter().any(|chat| chat.has_message(message_id))
1106    }
1107
1108    // ========================================================================
1109    // Unread Count
1110    // ========================================================================
1111
1112    /// Sum DB-computed per-chat unread counts, applying the same muted/blocked filters as
1113    /// [`count_unread_messages`] but sourcing each COUNT from `counts` (chat_identifier → unread)
1114    /// rather than walking in-memory messages — so it's correct even when only the last message per
1115    /// chat is in RAM (the boot state). Muted chats and blocked-DM contacts contribute 0.
1116    pub fn sum_unread_from(&self, counts: &std::collections::HashMap<String, u32>) -> u32 {
1117        let mut total = 0u32;
1118        for chat in &self.chats {
1119            if chat.muted {
1120                continue;
1121            }
1122            if !chat.is_community() {
1123                if let Some(id) = self.interner.lookup(&chat.id) {
1124                    if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) {
1125                        continue;
1126                    }
1127                }
1128            } else if !chat.is_surfaced_community_channel() {
1129                // Only a community's PRIMARY channel gets a row; a bare persistence anchor
1130                // or a sibling channel is invisible, so its unreads can't be seen or
1131                // cleared and must not badge.
1132                continue;
1133            }
1134            total += counts.get(&chat.id).copied().unwrap_or(0);
1135        }
1136        total
1137    }
1138
1139    // ------------------------------------------------------------------------
1140    // Unread cache — in-RAM per-chat counts, so the per-message badge recount
1141    // never re-scans the DB. See the `unread_cache` field doc.
1142    // ------------------------------------------------------------------------
1143
1144    /// Seed the whole cache from a DB `unread_counts()` result and mark it seeded. Idempotent per
1145    /// login; a later call replaces the map wholesale (used only if a reseed is ever forced).
1146    pub fn unread_seed(&mut self, counts: std::collections::HashMap<String, u32>) {
1147        self.unread_cache = counts;
1148        self.unread_seeded = true;
1149    }
1150
1151    /// The chat was read (opened / marked): zero its unread.
1152    pub fn unread_clear(&mut self, chat_id: &str) {
1153        self.unread_cache.remove(chat_id);
1154    }
1155
1156    /// Reconcile a chat to an exact DB-computed count (delete / retreat / backfill). A zero drops the
1157    /// entry so the map stays small and `unwrap_or(0)` reads it as caught-up.
1158    pub fn unread_set(&mut self, chat_id: &str, count: u32) {
1159        if count == 0 {
1160            self.unread_cache.remove(chat_id);
1161        } else {
1162            self.unread_cache.insert(chat_id.to_string(), count);
1163        }
1164    }
1165
1166    /// Total unread for the badge, from the cache, applying the same muted/blocked filters as
1167    /// [`sum_unread_from`].
1168    pub fn sum_unread(&self) -> u32 {
1169        self.sum_unread_from(&self.unread_cache)
1170    }
1171
1172    /// A snapshot of the raw per-chat counts, for the frontend's boot badges.
1173    pub fn unread_snapshot(&self) -> std::collections::HashMap<String, u32> {
1174        self.unread_cache.clone()
1175    }
1176
1177    pub fn count_unread_messages(&self) -> u32 {
1178        // Sender-level mutes: a muted DM silences that person's community messages too.
1179        let muted_senders: std::collections::HashSet<u16> = self
1180            .chats
1181            .iter()
1182            .filter(|c| c.muted && !c.is_community())
1183            .filter_map(|c| self.interner.lookup(&c.id))
1184            .collect();
1185        let mut total_unread = 0;
1186        for chat in &self.chats {
1187            if chat.muted { continue; }
1188            let is_group = chat.is_community();
1189            if !is_group {
1190                if let Some(id) = self.interner.lookup(&chat.id) {
1191                    if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) { continue; }
1192                }
1193            } else if !chat.is_surfaced_community_channel() {
1194                // Unsurfaced channel row — see `sum_unread_from`.
1195                continue;
1196            }
1197            let mut unread_count = 0u32;
1198            for msg in chat.iter_compact().rev() {
1199                if msg.flags.is_mine() { break; }
1200                if chat.last_read != [0u8; 32] && msg.id == chat.last_read { break; }
1201                if is_group && msg.npub_idx != NO_NPUB {
1202                    if muted_senders.contains(&msg.npub_idx) { continue; }
1203                    if self.get_profile_by_id(msg.npub_idx).map_or(false, |p| p.flags.is_blocked()) { continue; }
1204                }
1205                unread_count += 1;
1206            }
1207            // Debug: log which chat has unread messages
1208            #[cfg(debug_assertions)]
1209            if unread_count > 0 {
1210                let last_read_hex = crate::compact::decode_message_id(&chat.last_read);
1211                let last_msg_hex = chat.messages.last().map(|m| crate::compact::decode_message_id(&m.id)).unwrap_or_default();
1212                let msg_count = chat.message_count();
1213                eprintln!("[Unread] chat={} unread={} msgs_in_memory={} last_read={} last_msg={}",
1214                    &chat.id[..20.min(chat.id.len())], unread_count, msg_count,
1215                    &last_read_hex[..16.min(last_read_hex.len())], &last_msg_hex[..16.min(last_msg_hex.len())]);
1216            }
1217            total_unread += unread_count;
1218        }
1219        total_unread
1220    }
1221
1222    // ========================================================================
1223    // Typing Indicators
1224    // ========================================================================
1225
1226    pub fn update_typing_and_get_active(&mut self, chat_id: &str, npub: &str, expires_at: u64) -> Vec<String> {
1227        let handle = self.interner.intern(npub);
1228        if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
1229            chat.update_typing_participant(handle, expires_at);
1230            chat.get_active_typers(&self.interner)
1231        } else {
1232            Vec::new()
1233        }
1234    }
1235}
1236
1237impl Default for ChatState {
1238    fn default() -> Self { Self::new() }
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243    use super::*;
1244    use crate::types::Message;
1245    use crate::profile::{Profile, SlimProfile, Status};
1246    use crate::simd::hex::bytes_to_hex_32;
1247
1248    // ========================================================================
1249    // Helpers
1250    // ========================================================================
1251
1252    /// Create a deterministic 64-char hex ID from a u8 seed.
1253    /// First byte is always >= 0x10 to avoid the pending ID marker (0x01).
1254    fn make_hex_id(seed: u8) -> String {
1255        let mut bytes = [seed; 32];
1256        bytes[0] = seed.wrapping_add(0x10) | 0x10; // never 0x00 or 0x01
1257        bytes[1] = seed.wrapping_mul(37);
1258        bytes_to_hex_32(&bytes)
1259    }
1260
1261    /// Build a test Message with the given parameters.
1262    fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
1263        Message {
1264            id: make_hex_id(id_seed),
1265            content: content.to_string(),
1266            at: timestamp_ms,
1267            mine,
1268            ..Default::default()
1269        }
1270    }
1271
1272    /// Build a message with an npub sender.
1273    fn make_message_from(id_seed: u8, content: &str, timestamp_ms: u64, npub: &str) -> Message {
1274        Message {
1275            id: make_hex_id(id_seed),
1276            content: content.to_string(),
1277            at: timestamp_ms,
1278            mine: false,
1279            npub: Some(npub.to_string()),
1280            ..Default::default()
1281        }
1282    }
1283
1284    /// Build a SlimProfile for testing.
1285    fn make_slim_profile(id: &str, name: &str) -> SlimProfile {
1286        SlimProfile {
1287            id: id.to_string(),
1288            name: name.to_string(),
1289            display_name: String::new(),
1290            nickname: String::new(),
1291            lud06: String::new(),
1292            lud16: String::new(),
1293            banner: String::new(),
1294            avatar: String::new(),
1295            about: String::new(),
1296            website: String::new(),
1297            nip05: String::new(),
1298            status: Status::new(),
1299            last_updated: 0,
1300            mine: false,
1301            bot: false,
1302            is_blocked: false,
1303            avatar_cached: String::new(),
1304            banner_cached: String::new(),
1305        }
1306    }
1307
1308    // ========================================================================
1309    // Profile Management
1310    // ========================================================================
1311
1312    #[test]
1313    fn insert_or_replace_profile_creates_new() {
1314        let mut state = ChatState::new();
1315        let profile = Profile::new();
1316        state.insert_or_replace_profile("npub1alice", profile);
1317
1318        assert!(
1319            state.get_profile("npub1alice").is_some(),
1320            "newly inserted profile should be retrievable"
1321        );
1322        assert_eq!(state.profiles.len(), 1, "should have exactly one profile");
1323    }
1324
1325    #[test]
1326    fn insert_or_replace_profile_updates_existing() {
1327        let mut state = ChatState::new();
1328        let mut p1 = Profile::new();
1329        p1.name = "Alice".to_string().into_boxed_str();
1330        state.insert_or_replace_profile("npub1alice", p1);
1331
1332        let mut p2 = Profile::new();
1333        p2.name = "Alice Updated".to_string().into_boxed_str();
1334        state.insert_or_replace_profile("npub1alice", p2);
1335
1336        let fetched = state.get_profile("npub1alice").expect("profile should exist");
1337        assert_eq!(
1338            &*fetched.name, "Alice Updated",
1339            "profile name should be updated after replace"
1340        );
1341        assert_eq!(state.profiles.len(), 1, "should still be one profile, not duplicated");
1342    }
1343
1344    #[test]
1345    fn get_profile_by_npub() {
1346        let mut state = ChatState::new();
1347        let mut profile = Profile::new();
1348        profile.name = "Bob".to_string().into_boxed_str();
1349        state.insert_or_replace_profile("npub1bob", profile);
1350
1351        let fetched = state.get_profile("npub1bob").expect("profile should be found");
1352        assert_eq!(&*fetched.name, "Bob", "fetched profile name should match");
1353    }
1354
1355    #[test]
1356    fn get_profile_returns_none_for_unknown() {
1357        let state = ChatState::new();
1358        assert!(
1359            state.get_profile("npub1unknown").is_none(),
1360            "unknown npub should return None"
1361        );
1362    }
1363
1364    #[test]
1365    fn get_profile_by_id_works() {
1366        let mut state = ChatState::new();
1367        let mut profile = Profile::new();
1368        profile.name = "Charlie".to_string().into_boxed_str();
1369        state.insert_or_replace_profile("npub1charlie", profile);
1370
1371        let id = state.interner.lookup("npub1charlie").expect("npub should be interned");
1372        let fetched = state.get_profile_by_id(id).expect("profile should be found by id");
1373        assert_eq!(&*fetched.name, "Charlie", "profile looked up by id should match");
1374    }
1375
1376    #[test]
1377    fn get_profile_by_id_returns_none_for_invalid() {
1378        let state = ChatState::new();
1379        assert!(
1380            state.get_profile_by_id(9999).is_none(),
1381            "invalid interner id should return None"
1382        );
1383    }
1384
1385    #[test]
1386    fn merge_db_profiles_sets_mine_flag() {
1387        let mut state = ChatState::new();
1388        let slim_mine = make_slim_profile("npub1me", "Me");
1389        let slim_other = make_slim_profile("npub1other", "Other");
1390
1391        state.merge_db_profiles(vec![slim_mine, slim_other], "npub1me");
1392
1393        let me = state.get_profile("npub1me").expect("my profile should exist");
1394        assert!(me.flags.is_mine(), "my profile should have mine flag set");
1395
1396        let other = state.get_profile("npub1other").expect("other profile should exist");
1397        assert!(!other.flags.is_mine(), "other profile should not have mine flag");
1398    }
1399
1400    #[test]
1401    fn serialize_profile_roundtrip() {
1402        let mut state = ChatState::new();
1403        let mut profile = Profile::new();
1404        profile.name = "Roundtrip".to_string().into_boxed_str();
1405        profile.about = "Test about".to_string().into_boxed_str();
1406        profile.flags.set_blocked(true);
1407        state.insert_or_replace_profile("npub1round", profile);
1408
1409        let id = state.interner.lookup("npub1round").unwrap();
1410        let slim = state.serialize_profile(id).expect("serialization should succeed");
1411
1412        assert_eq!(slim.id, "npub1round", "serialized id should match");
1413        assert_eq!(slim.name, "Roundtrip", "serialized name should match");
1414        assert_eq!(slim.about, "Test about", "serialized about should match");
1415        assert!(slim.is_blocked, "serialized blocked flag should be true");
1416
1417        // Convert back to profile and re-insert
1418        let restored = slim.to_profile();
1419        assert_eq!(&*restored.name, "Roundtrip", "restored name should match");
1420        assert!(restored.flags.is_blocked(), "restored blocked flag should be true");
1421    }
1422
1423    #[test]
1424    fn binary_search_maintains_sorted_order_with_100_profiles() {
1425        let mut state = ChatState::new();
1426
1427        // Insert 100 profiles in random-ish order
1428        let npubs: Vec<String> = (0..100).map(|i| format!("npub1user{:04}", i)).collect();
1429        let mut shuffled = npubs.clone();
1430        // Simple deterministic shuffle
1431        for i in (1..shuffled.len()).rev() {
1432            let j = (i * 37 + 13) % (i + 1);
1433            shuffled.swap(i, j);
1434        }
1435
1436        for npub in &shuffled {
1437            let mut profile = Profile::new();
1438            profile.name = npub.clone().into_boxed_str();
1439            state.insert_or_replace_profile(npub, profile);
1440        }
1441
1442        // All should be findable
1443        for npub in &npubs {
1444            assert!(
1445                state.get_profile(npub).is_some(),
1446                "profile {} should be retrievable after bulk insert",
1447                npub
1448            );
1449        }
1450
1451        // Internal profiles vec should be sorted by id
1452        for window in state.profiles.windows(2) {
1453            assert!(
1454                window[0].id < window[1].id,
1455                "profiles should be sorted by interner id"
1456            );
1457        }
1458
1459        assert_eq!(state.profiles.len(), 100, "should have exactly 100 profiles");
1460    }
1461
1462    #[test]
1463    fn insert_same_npub_twice_updates_not_duplicates() {
1464        let mut state = ChatState::new();
1465
1466        for i in 0..5 {
1467            let mut profile = Profile::new();
1468            profile.name = format!("version_{}", i).into_boxed_str();
1469            state.insert_or_replace_profile("npub1repeated", profile);
1470        }
1471
1472        assert_eq!(state.profiles.len(), 1, "repeated inserts should not create duplicates");
1473        let p = state.get_profile("npub1repeated").unwrap();
1474        assert_eq!(&*p.name, "version_4", "should retain the last update");
1475    }
1476
1477    #[test]
1478    fn get_profile_mut_modifies_in_place() {
1479        let mut state = ChatState::new();
1480        let profile = Profile::new();
1481        state.insert_or_replace_profile("npub1mutable", profile);
1482
1483        let p = state.get_profile_mut("npub1mutable").expect("profile should exist");
1484        p.name = "Mutated".to_string().into_boxed_str();
1485
1486        let fetched = state.get_profile("npub1mutable").unwrap();
1487        assert_eq!(&*fetched.name, "Mutated", "mutation should persist");
1488    }
1489
1490    // ========================================================================
1491    // Chat Management
1492    // ========================================================================
1493
1494    #[test]
1495    fn create_dm_chat_creates_new() {
1496        let mut state = ChatState::new();
1497        let id = state.create_dm_chat("npub1peer");
1498
1499        assert_eq!(id, "npub1peer", "returned id should match the npub");
1500        assert!(state.get_chat("npub1peer").is_some(), "chat should be created");
1501        assert_eq!(state.chats.len(), 1, "should have exactly one chat");
1502    }
1503
1504    #[test]
1505    fn create_dm_chat_is_idempotent() {
1506        let mut state = ChatState::new();
1507        state.create_dm_chat("npub1peer");
1508        state.create_dm_chat("npub1peer");
1509        state.create_dm_chat("npub1peer");
1510
1511        assert_eq!(state.chats.len(), 1, "repeated creates should not duplicate");
1512    }
1513
1514    #[test]
1515    fn ensure_community_chat_idempotent() {
1516        let mut state = ChatState::new();
1517        state.ensure_community_chat("grp1");
1518        state.ensure_community_chat("grp1");
1519
1520        assert_eq!(state.chats.len(), 1, "second call should not create a duplicate");
1521        let chat = state.get_chat("grp1").expect("community chat should exist");
1522        assert!(chat.is_community(), "should be a Community chat");
1523    }
1524
1525    #[test]
1526    fn get_chat_by_id() {
1527        let mut state = ChatState::new();
1528        state.create_dm_chat("npub1x");
1529
1530        let chat = state.get_chat("npub1x").expect("chat should exist");
1531        assert_eq!(chat.id, "npub1x", "chat id should match");
1532    }
1533
1534    #[test]
1535    fn get_chat_returns_none_for_missing() {
1536        let state = ChatState::new();
1537        assert!(state.get_chat("nonexistent").is_none(), "missing chat should return None");
1538    }
1539
1540    #[test]
1541    fn get_chat_mut_modifies_in_place() {
1542        let mut state = ChatState::new();
1543        state.create_dm_chat("npub1editable");
1544
1545        let chat = state.get_chat_mut("npub1editable").expect("chat should exist");
1546        chat.muted = true;
1547
1548        let refetched = state.get_chat("npub1editable").unwrap();
1549        assert!(refetched.muted, "muted flag should persist after mutation");
1550    }
1551
1552    #[test]
1553    fn multiple_different_chats() {
1554        let mut state = ChatState::new();
1555        state.create_dm_chat("npub1alice");
1556        state.create_dm_chat("npub1bob");
1557        state.ensure_community_chat("grp1");
1558
1559        assert_eq!(state.chats.len(), 3, "should have three distinct chats");
1560    }
1561
1562    // ========================================================================
1563    // Message Management
1564    // ========================================================================
1565
1566    #[test]
1567    fn add_message_to_chat_single() {
1568        let mut state = ChatState::new();
1569        state.create_dm_chat("npub1peer");
1570
1571        let msg = make_message(1, "hello", 1700000000000, false);
1572        let added = state.add_message_to_chat("npub1peer", &msg);
1573
1574        assert!(added, "first message should be added successfully");
1575        let chat = state.get_chat("npub1peer").unwrap();
1576        assert_eq!(chat.message_count(), 1, "chat should have one message");
1577    }
1578
1579    #[test]
1580    fn add_message_to_chat_dedup_rejects_same_id() {
1581        let mut state = ChatState::new();
1582        state.create_dm_chat("npub1peer");
1583
1584        let msg1 = make_message(1, "hello", 1700000000000, false);
1585        let msg2 = make_message(1, "duplicate", 1700000001000, false);
1586
1587        let added1 = state.add_message_to_chat("npub1peer", &msg1);
1588        let added2 = state.add_message_to_chat("npub1peer", &msg2);
1589
1590        assert!(added1, "first insert should succeed");
1591        assert!(!added2, "duplicate ID should be rejected");
1592        assert_eq!(
1593            state.get_chat("npub1peer").unwrap().message_count(), 1,
1594            "should still have only one message"
1595        );
1596    }
1597
1598    #[test]
1599    fn add_messages_to_chat_batch_works() {
1600        let mut state = ChatState::new();
1601        state.create_dm_chat("npub1peer");
1602
1603        let msgs: Vec<Message> = (0..10).map(|i| {
1604            make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1605        }).collect();
1606
1607        let added = state.add_messages_to_chat_batch("npub1peer", msgs);
1608        assert_eq!(added, 10, "all 10 messages should be added");
1609        assert_eq!(
1610            state.get_chat("npub1peer").unwrap().message_count(), 10,
1611            "chat should have 10 messages"
1612        );
1613    }
1614
1615    #[test]
1616    fn add_messages_to_chat_batch_dedup() {
1617        let mut state = ChatState::new();
1618        state.create_dm_chat("npub1peer");
1619
1620        // Add first batch
1621        let msgs1: Vec<Message> = (0..5).map(|i| {
1622            make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1623        }).collect();
1624        state.add_messages_to_chat_batch("npub1peer", msgs1);
1625
1626        // Add overlapping batch (IDs 3,4 overlap, 5,6,7 are new)
1627        let msgs2: Vec<Message> = (3..8).map(|i| {
1628            make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1629        }).collect();
1630        let added = state.add_messages_to_chat_batch("npub1peer", msgs2);
1631
1632        assert_eq!(added, 3, "only 3 new messages should be added (5, 6, 7)");
1633        assert_eq!(
1634            state.get_chat("npub1peer").unwrap().message_count(), 8,
1635            "total should be 8 unique messages"
1636        );
1637    }
1638
1639    #[test]
1640    fn add_message_to_participant_creates_profile_and_chat() {
1641        let mut state = ChatState::new();
1642
1643        let msg = make_message(1, "hi there", 1700000000000, false);
1644        let added = state.add_message_to_participant("npub1stranger", &msg);
1645
1646        assert!(added, "message should be added");
1647        assert!(
1648            state.get_profile("npub1stranger").is_some(),
1649            "profile should be auto-created for unknown participant"
1650        );
1651        assert!(
1652            state.get_chat("npub1stranger").is_some(),
1653            "DM chat should be auto-created"
1654        );
1655    }
1656
1657    #[test]
1658    fn add_message_to_participant_uses_existing_profile() {
1659        let mut state = ChatState::new();
1660
1661        // Pre-create profile
1662        let mut profile = Profile::new();
1663        profile.name = "Known User".to_string().into_boxed_str();
1664        state.insert_or_replace_profile("npub1known", profile);
1665
1666        let msg = make_message(1, "hello", 1700000000000, false);
1667        state.add_message_to_participant("npub1known", &msg);
1668
1669        // Profile should not be replaced
1670        let p = state.get_profile("npub1known").unwrap();
1671        assert_eq!(&*p.name, "Known User", "existing profile should not be overwritten");
1672    }
1673
1674    #[test]
1675    fn find_message_across_chats() {
1676        let mut state = ChatState::new();
1677        state.create_dm_chat("npub1a");
1678        state.create_dm_chat("npub1b");
1679
1680        let msg_a = make_message(1, "in chat a", 1700000000000, false);
1681        let msg_b = make_message(2, "in chat b", 1700000001000, false);
1682        let msg_id_b = msg_b.id.clone();
1683
1684        state.add_message_to_chat("npub1a", &msg_a);
1685        state.add_message_to_chat("npub1b", &msg_b);
1686
1687        let (chat, found_msg) = state.find_message(&msg_id_b).expect("message should be found");
1688        assert_eq!(chat.id, "npub1b", "should find in correct chat");
1689        assert_eq!(found_msg.content, "in chat b", "content should match");
1690    }
1691
1692    #[test]
1693    fn find_message_returns_none_for_unknown() {
1694        let state = ChatState::new();
1695        assert!(
1696            state.find_message(&make_hex_id(99)).is_none(),
1697            "unknown message id should return None"
1698        );
1699    }
1700
1701    #[test]
1702    fn find_message_empty_id_returns_none() {
1703        let state = ChatState::new();
1704        assert!(state.find_message("").is_none(), "empty id should return None");
1705    }
1706
1707    #[test]
1708    fn update_message_mutates_and_returns() {
1709        let mut state = ChatState::new();
1710        state.create_dm_chat("npub1peer");
1711
1712        let msg = make_message(1, "original", 1700000000000, false);
1713        let msg_id = msg.id.clone();
1714        state.add_message_to_chat("npub1peer", &msg);
1715
1716        let result = state.update_message(&msg_id, |cm| {
1717            cm.content = "updated content".to_string().into_boxed_str();
1718        });
1719
1720        let (chat_id, updated) = result.expect("update should return Some");
1721        assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1722        assert_eq!(updated.content, "updated content", "content should be updated");
1723    }
1724
1725    #[test]
1726    fn update_message_returns_none_for_missing() {
1727        let mut state = ChatState::new();
1728        let result = state.update_message(&make_hex_id(99), |_cm| {});
1729        assert!(result.is_none(), "updating nonexistent message should return None");
1730    }
1731
1732    #[test]
1733    fn finalize_pending_message_changes_id() {
1734        let mut state = ChatState::new();
1735        state.create_dm_chat("npub1peer");
1736
1737        let mut msg = make_message(1, "pending msg", 1700000000000, true);
1738        msg.pending = true;
1739        let pending_id = msg.id.clone();
1740        state.add_message_to_chat("npub1peer", &msg);
1741
1742        let real_id = make_hex_id(2);
1743        let result = state.finalize_pending_message("npub1peer", &pending_id, &real_id);
1744
1745        let (old_id, finalized) = result.expect("finalize should succeed");
1746        assert_eq!(old_id, pending_id, "should return old pending id");
1747        assert_eq!(finalized.id, real_id, "message id should now be the real id");
1748        assert!(!finalized.pending, "message should no longer be pending");
1749
1750        // Old ID should no longer be findable
1751        assert!(
1752            state.find_message(&pending_id).is_none(),
1753            "pending id should no longer resolve"
1754        );
1755        // New ID should be findable
1756        assert!(
1757            state.find_message(&real_id).is_some(),
1758            "real id should now resolve"
1759        );
1760    }
1761
1762    #[test]
1763    fn remove_message_works() {
1764        let mut state = ChatState::new();
1765        state.create_dm_chat("npub1peer");
1766
1767        let msg = make_message(1, "deleteme", 1700000000000, false);
1768        let msg_id = msg.id.clone();
1769        state.add_message_to_chat("npub1peer", &msg);
1770
1771        let result = state.remove_message(&msg_id);
1772        assert!(result.is_some(), "remove should return the removed message");
1773
1774        let (chat_id, removed) = result.unwrap();
1775        assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1776        assert_eq!(removed.content, "deleteme", "content should match");
1777
1778        assert!(
1779            state.find_message(&msg_id).is_none(),
1780            "removed message should no longer be findable"
1781        );
1782    }
1783
1784    #[test]
1785    fn remove_message_retreats_last_read_marker() {
1786        let mut state = ChatState::new();
1787        state.create_dm_chat("npub1peer");
1788        let m1 = make_message(1, "one", 1_700_000_000_000, false);
1789        let m2 = make_message(2, "two", 1_700_000_001_000, false);
1790        let (m1_id, m2_id) = (m1.id.clone(), m2.id.clone());
1791        state.add_message_to_chat("npub1peer", &m1);
1792        state.add_message_to_chat("npub1peer", &m2);
1793
1794        // Read up to the newest — m2 is the marker.
1795        state.chats.iter_mut().find(|c| c.id == "npub1peer").unwrap().last_read =
1796            crate::compact::encode_message_id(&m2_id);
1797
1798        // Deleting the marker retreats it to the prior survivor, never leaves it dangling.
1799        state.remove_message(&m2_id);
1800        assert_eq!(state.get_chat("npub1peer").unwrap().last_read,
1801            crate::compact::encode_message_id(&m1_id), "marker retreats to m1");
1802
1803        // Deleting the last survivor clears the marker (no predecessor).
1804        state.remove_message(&m1_id);
1805        assert_eq!(state.get_chat("npub1peer").unwrap().last_read, [0u8; 32],
1806            "no predecessor → marker clears");
1807    }
1808
1809    #[test]
1810    fn remove_message_returns_none_for_missing() {
1811        let mut state = ChatState::new();
1812        assert!(
1813            state.remove_message(&make_hex_id(99)).is_none(),
1814            "removing nonexistent message should return None"
1815        );
1816    }
1817
1818    #[test]
1819    fn message_exists_check() {
1820        let mut state = ChatState::new();
1821        state.create_dm_chat("npub1peer");
1822
1823        let msg = make_message(1, "exists", 1700000000000, false);
1824        let msg_id = msg.id.clone();
1825        state.add_message_to_chat("npub1peer", &msg);
1826
1827        assert!(state.message_exists(&msg_id), "added message should exist");
1828        assert!(!state.message_exists(&make_hex_id(99)), "unknown id should not exist");
1829        assert!(!state.message_exists(""), "empty id should not exist");
1830    }
1831
1832    #[test]
1833    fn chat_reordering_newest_first_after_message_add() {
1834        let mut state = ChatState::new();
1835        state.create_dm_chat("npub1old");
1836        state.create_dm_chat("npub1new");
1837
1838        // Add an old message to the first chat
1839        let old_msg = make_message(1, "old", 1700000000000, false);
1840        state.add_message_to_chat("npub1old", &old_msg);
1841
1842        // Add a newer message to the second chat
1843        let new_msg = make_message(2, "new", 1700000002000, false);
1844        state.add_message_to_chat("npub1new", &new_msg);
1845
1846        assert_eq!(
1847            state.chats[0].id, "npub1new",
1848            "chat with newest message should be first"
1849        );
1850        assert_eq!(
1851            state.chats[1].id, "npub1old",
1852            "chat with older message should be second"
1853        );
1854    }
1855
1856    #[test]
1857    fn batch_add_does_not_reorder_for_old_messages() {
1858        let mut state = ChatState::new();
1859        state.create_dm_chat("npub1active");
1860        state.create_dm_chat("npub1history");
1861
1862        // Give active chat a recent message
1863        let recent = make_message(1, "recent", 1700000010000, false);
1864        state.add_message_to_chat("npub1active", &recent);
1865
1866        // Batch-add old messages to history chat (pagination loading)
1867        let old_msgs: Vec<Message> = (10..15).map(|i| {
1868            make_message(i, &format!("old {}", i), 1700000000000 + i as u64 * 100, false)
1869        }).collect();
1870        state.add_messages_to_chat_batch("npub1history", old_msgs);
1871
1872        assert_eq!(
1873            state.chats[0].id, "npub1active",
1874            "active chat should remain first when batch has only old messages"
1875        );
1876    }
1877
1878    #[test]
1879    fn stress_test_50_messages_in_5_chats() {
1880        let mut state = ChatState::new();
1881
1882        for i in 0..5 {
1883            state.create_dm_chat(&format!("npub1chat{}", i));
1884        }
1885
1886        let mut total_added = 0;
1887        for i in 0..50u8 {
1888            let chat_idx = i as usize % 5;
1889            let chat_id = format!("npub1chat{}", chat_idx);
1890            let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, i % 3 == 0);
1891            if state.add_message_to_chat(&chat_id, &msg) {
1892                total_added += 1;
1893            }
1894        }
1895
1896        assert_eq!(total_added, 50, "all 50 unique messages should be added");
1897
1898        let total_in_chats: usize = state.chats.iter().map(|c| c.message_count()).sum();
1899        assert_eq!(total_in_chats, 50, "total messages across all chats should be 50");
1900
1901        // Each chat should have 10 messages
1902        for i in 0..5 {
1903            let chat = state.get_chat(&format!("npub1chat{}", i)).unwrap();
1904            assert_eq!(
1905                chat.message_count(), 10,
1906                "chat {} should have 10 messages",
1907                i
1908            );
1909        }
1910
1911        // All messages should be findable
1912        for i in 0..50u8 {
1913            assert!(
1914                state.message_exists(&make_hex_id(i)),
1915                "message {} should exist",
1916                i
1917            );
1918        }
1919    }
1920
1921    #[test]
1922    fn add_message_auto_creates_dm_chat() {
1923        let mut state = ChatState::new();
1924
1925        // Add message to a chat that doesn't exist yet (npub-style ID)
1926        let msg = make_message(1, "auto create", 1700000000000, false);
1927        let added = state.add_message_to_chat("npub1auto", &msg);
1928
1929        assert!(added, "message should be added");
1930        assert!(state.get_chat("npub1auto").is_some(), "DM chat should be auto-created");
1931    }
1932
1933    #[test]
1934    fn add_message_auto_creates_community_chat() {
1935        let mut state = ChatState::new();
1936
1937        // Add message to a non-npub ID (should create a Community chat)
1938        let msg = make_message(1, "group msg", 1700000000000, false);
1939        let added = state.add_message_to_chat("group_abc123", &msg);
1940
1941        assert!(added, "message should be added");
1942        let chat = state.get_chat("group_abc123").expect("community chat should be auto-created");
1943        assert!(chat.is_community(), "auto-created non-npub chat should be a Community chat");
1944    }
1945
1946    // ========================================================================
1947    // Unread Count
1948    // ========================================================================
1949
1950    #[test]
1951    fn count_unread_messages_basic() {
1952        let mut state = ChatState::new();
1953        state.create_dm_chat("npub1peer");
1954
1955        for i in 0..5u8 {
1956            let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false);
1957            state.add_message_to_chat("npub1peer", &msg);
1958        }
1959
1960        assert_eq!(state.count_unread_messages(), 5, "all 5 non-mine messages should be unread");
1961    }
1962
1963    #[test]
1964    fn count_unread_muted_chat_skipped() {
1965        let mut state = ChatState::new();
1966        state.create_dm_chat("npub1muted");
1967
1968        let msg = make_message(1, "muted msg", 1700000000000, false);
1969        state.add_message_to_chat("npub1muted", &msg);
1970
1971        state.get_chat_mut("npub1muted").unwrap().muted = true;
1972
1973        assert_eq!(state.count_unread_messages(), 0, "muted chat should not count toward unread");
1974    }
1975
1976    #[test]
1977    fn count_unread_blocked_user_skipped() {
1978        let mut state = ChatState::new();
1979
1980        let mut profile = Profile::new();
1981        profile.flags.set_blocked(true);
1982        state.insert_or_replace_profile("npub1blocked", profile);
1983        state.create_dm_chat("npub1blocked");
1984
1985        let msg = make_message(1, "blocked msg", 1700000000000, false);
1986        state.add_message_to_chat("npub1blocked", &msg);
1987
1988        assert_eq!(state.count_unread_messages(), 0, "blocked user DM should not count");
1989    }
1990
1991    #[test]
1992    fn unread_cache_seed_clear_set_and_sum() {
1993        let mut state = ChatState::new();
1994        state.create_dm_chat("npub1a");
1995        state.create_dm_chat("npub1b");
1996
1997        let mut seed = std::collections::HashMap::new();
1998        seed.insert("npub1a".to_string(), 3u32);
1999        seed.insert("npub1b".to_string(), 2u32);
2000        state.unread_seed(seed);
2001        assert!(state.unread_seeded);
2002        assert_eq!(state.sum_unread(), 5);
2003
2004        state.unread_clear("npub1a");
2005        assert_eq!(state.sum_unread(), 2, "clear drops a's 3");
2006
2007        state.unread_set("npub1b", 4);
2008        assert_eq!(state.sum_unread(), 4, "reconcile b to exact 4");
2009        state.unread_set("npub1b", 0);
2010        assert_eq!(state.sum_unread(), 0);
2011        assert!(!state.unread_cache.contains_key("npub1b"), "a zero count drops the entry");
2012    }
2013
2014    #[test]
2015    fn unread_cache_sum_honours_muted_and_blocked() {
2016        let mut state = ChatState::new();
2017        let mut blocked = Profile::new();
2018        blocked.flags.set_blocked(true);
2019        state.insert_or_replace_profile("npub1blk", blocked);
2020        state.create_dm_chat("npub1blk");
2021        state.create_dm_chat("npub1mut");
2022        state.get_chat_mut("npub1mut").unwrap().muted = true;
2023        state.create_dm_chat("npub1ok");
2024
2025        let mut seed = std::collections::HashMap::new();
2026        seed.insert("npub1blk".to_string(), 5u32);
2027        seed.insert("npub1mut".to_string(), 7u32);
2028        seed.insert("npub1ok".to_string(), 2u32);
2029        state.unread_seed(seed);
2030
2031        // Blocked + muted chats are filtered exactly like sum_unread_from; only the normal chat counts.
2032        assert_eq!(state.sum_unread(), 2);
2033    }
2034
2035    #[test]
2036    fn count_unread_own_messages_break_count() {
2037        let mut state = ChatState::new();
2038        state.create_dm_chat("npub1peer");
2039
2040        // 3 from them, then 1 from me, then 2 from them
2041        let msg1 = make_message(1, "them 1", 1700000001000, false);
2042        let msg2 = make_message(2, "them 2", 1700000002000, false);
2043        let msg3 = make_message(3, "them 3", 1700000003000, false);
2044        let msg_mine = make_message(4, "me", 1700000004000, true);
2045        let msg5 = make_message(5, "them 4", 1700000005000, false);
2046        let msg6 = make_message(6, "them 5", 1700000006000, false);
2047
2048        for m in [msg1, msg2, msg3, msg_mine, msg5, msg6] {
2049            state.add_message_to_chat("npub1peer", &m);
2050        }
2051
2052        // Counting from the end: msg6 (unread), msg5 (unread), then msg_mine breaks
2053        assert_eq!(
2054            state.count_unread_messages(), 2,
2055            "only messages after last 'mine' should count as unread"
2056        );
2057    }
2058
2059    #[test]
2060    fn count_unread_last_read_marker_breaks_count() {
2061        let mut state = ChatState::new();
2062        state.create_dm_chat("npub1peer");
2063
2064        let msg1 = make_message(1, "old", 1700000001000, false);
2065        let msg2 = make_message(2, "read up to here", 1700000002000, false);
2066        let msg3 = make_message(3, "new 1", 1700000003000, false);
2067        let msg4 = make_message(4, "new 2", 1700000004000, false);
2068        let read_marker_id = msg2.id.clone();
2069
2070        for m in [msg1, msg2, msg3, msg4] {
2071            state.add_message_to_chat("npub1peer", &m);
2072        }
2073
2074        // Set last_read to msg2's ID
2075        let chat = state.get_chat_mut("npub1peer").unwrap();
2076        chat.last_read = crate::simd::hex::hex_to_bytes_32(&read_marker_id);
2077
2078        assert_eq!(
2079            state.count_unread_messages(), 2,
2080            "only messages after last_read marker should count"
2081        );
2082    }
2083
2084    #[test]
2085    fn count_unread_empty_chats_is_zero() {
2086        let mut state = ChatState::new();
2087        state.create_dm_chat("npub1empty1");
2088        state.create_dm_chat("npub1empty2");
2089
2090        assert_eq!(state.count_unread_messages(), 0, "empty chats should have zero unread");
2091    }
2092
2093    #[test]
2094    fn count_unread_blocked_group_member_messages_skipped() {
2095        let mut state = ChatState::new();
2096
2097        // Create a blocked profile
2098        let mut blocked_profile = Profile::new();
2099        blocked_profile.flags.set_blocked(true);
2100        state.insert_or_replace_profile("npub1blockedmember", blocked_profile);
2101
2102        // Create a normal profile
2103        let normal_profile = Profile::new();
2104        state.insert_or_replace_profile("npub1normal", normal_profile);
2105
2106        // A SURFACED community row (carries its owning community_id) — a bare
2107        // anchor row is excluded from unread totals by design.
2108        state.ensure_community_chat("grp1");
2109        if let Some(chat) = state.chats.iter_mut().find(|c| c.id == "grp1") {
2110            chat.metadata.custom_fields.insert("community_id".to_string(), "c".repeat(64));
2111        }
2112
2113        // Message from blocked member
2114        let msg_blocked = make_message_from(1, "blocked says hi", 1700000001000, "npub1blockedmember");
2115        state.add_message_to_chat("grp1", &msg_blocked);
2116
2117        // Message from normal member
2118        let msg_normal = make_message_from(2, "normal says hi", 1700000002000, "npub1normal");
2119        state.add_message_to_chat("grp1", &msg_normal);
2120
2121        assert_eq!(
2122            state.count_unread_messages(), 1,
2123            "only the non-blocked member's message should count"
2124        );
2125    }
2126
2127    #[test]
2128    fn count_unread_muted_sender_group_messages_skipped() {
2129        let mut state = ChatState::new();
2130        state.insert_or_replace_profile("npub1mutedmember", Profile::new());
2131        state.insert_or_replace_profile("npub1normal", Profile::new());
2132
2133        // Muting a person = muting their (possibly message-less) DM row.
2134        state.create_dm_chat("npub1mutedmember");
2135        state.get_chat_mut("npub1mutedmember").unwrap().muted = true;
2136
2137        state.ensure_community_chat("grp1");
2138        if let Some(chat) = state.chats.iter_mut().find(|c| c.id == "grp1") {
2139            chat.metadata.custom_fields.insert("community_id".to_string(), "c".repeat(64));
2140        }
2141
2142        let msg_muted = make_message_from(1, "muted says hi", 1700000001000, "npub1mutedmember");
2143        state.add_message_to_chat("grp1", &msg_muted);
2144        let msg_normal = make_message_from(2, "normal says hi", 1700000002000, "npub1normal");
2145        state.add_message_to_chat("grp1", &msg_normal);
2146
2147        assert_eq!(
2148            state.count_unread_messages(), 1,
2149            "a muted sender's community messages should not count"
2150        );
2151    }
2152
2153    #[test]
2154    fn count_unread_multiple_chats_summed() {
2155        let mut state = ChatState::new();
2156
2157        for i in 0..3 {
2158            let npub = format!("npub1chat{}", i);
2159            state.create_dm_chat(&npub);
2160            for j in 0..3u8 {
2161                let msg = make_message(
2162                    i * 10 + j,
2163                    &format!("msg {}-{}", i, j),
2164                    1700000000000 + j as u64 * 1000,
2165                    false,
2166                );
2167                state.add_message_to_chat(&npub, &msg);
2168            }
2169        }
2170
2171        assert_eq!(
2172            state.count_unread_messages(), 9,
2173            "3 chats x 3 unread each = 9 total"
2174        );
2175    }
2176
2177    // ========================================================================
2178    // Typing Indicators
2179    // ========================================================================
2180
2181    #[test]
2182    fn update_typing_and_get_active_basic() {
2183        let mut state = ChatState::new();
2184        state.create_dm_chat("npub1peer");
2185
2186        // Set a far-future expiry so it's definitely active
2187        let far_future = std::time::SystemTime::now()
2188            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
2189
2190        let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
2191        assert_eq!(active.len(), 1, "should have one active typer");
2192        assert_eq!(active[0], "npub1typer", "typer npub should match");
2193    }
2194
2195    #[test]
2196    fn update_typing_expired_typers_filtered() {
2197        let mut state = ChatState::new();
2198        state.create_dm_chat("npub1peer");
2199
2200        // Expired timestamp (in the past)
2201        let expired = 1000;
2202        let active = state.update_typing_and_get_active("npub1peer", "npub1expired", expired);
2203
2204        assert!(active.is_empty(), "expired typer should be filtered out");
2205    }
2206
2207    #[test]
2208    fn update_typing_multiple_typers() {
2209        let mut state = ChatState::new();
2210        state.create_dm_chat("npub1peer");
2211
2212        let far_future = std::time::SystemTime::now()
2213            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
2214
2215        state.update_typing_and_get_active("npub1peer", "npub1typer1", far_future);
2216        let active = state.update_typing_and_get_active("npub1peer", "npub1typer2", far_future);
2217
2218        assert_eq!(active.len(), 2, "should have two active typers");
2219    }
2220
2221    #[test]
2222    fn update_typing_unknown_chat_returns_empty() {
2223        let mut state = ChatState::new();
2224        let far_future = std::time::SystemTime::now()
2225            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
2226
2227        let active = state.update_typing_and_get_active("npub1nonexistent", "npub1typer", far_future);
2228        assert!(active.is_empty(), "unknown chat should return empty typers");
2229    }
2230
2231    #[test]
2232    fn update_typing_refreshes_existing_typer() {
2233        let mut state = ChatState::new();
2234        state.create_dm_chat("npub1peer");
2235
2236        let far_future = std::time::SystemTime::now()
2237            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
2238
2239        state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
2240        // Update the same typer with a new expiry
2241        let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future + 100);
2242
2243        assert_eq!(active.len(), 1, "should still have only one typer entry after refresh");
2244    }
2245
2246    // ========================================================================
2247    // WrapperIdCache
2248    // ========================================================================
2249
2250    #[test]
2251    fn wrapper_id_cache_historical_and_pending() {
2252        let mut cache = WrapperIdCache::new();
2253
2254        let id1 = [1u8; 32];
2255        let id2 = [2u8; 32];
2256        let id3 = [3u8; 32];
2257
2258        cache.load(vec![id1, id2]);
2259        cache.insert(id3);
2260
2261        assert!(cache.contains(&id1), "historical id should be found");
2262        assert!(cache.contains(&id2), "historical id should be found");
2263        assert!(cache.contains(&id3), "pending id should be found");
2264        assert!(!cache.contains(&[4u8; 32]), "unknown id should not be found");
2265        assert_eq!(cache.len(), 3, "total count should be 3");
2266    }
2267
2268    #[test]
2269    fn wrapper_id_cache_clear() {
2270        let mut cache = WrapperIdCache::new();
2271        cache.load(vec![[1u8; 32]]);
2272        cache.insert([2u8; 32]);
2273
2274        cache.clear();
2275
2276        assert_eq!(cache.len(), 0, "cache should be empty after clear");
2277        assert!(!cache.contains(&[1u8; 32]), "cleared historical should not be found");
2278        assert!(!cache.contains(&[2u8; 32]), "cleared pending should not be found");
2279    }
2280}