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