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