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