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        protocol: crate::community::ConcordProtocol,
667    ) {
668        self.ensure_community_chat(channel_id);
669        if let Some(chat) = self.chats.iter_mut().find(|c| c.id == channel_id) {
670            let cf = &mut chat.metadata.custom_fields;
671            cf.insert("name".to_string(), name.to_string());
672            cf.insert("description".to_string(), description.to_string());
673            cf.insert("community_id".to_string(), community_id.to_string());
674            cf.insert("is_owner".to_string(), is_owner.to_string());
675            // Protocol stack (1 = v1, 2 = v2) — the GUI gates v2-only affordances
676            // (e.g. the Self-Destruct Timer) on this so a v1 community never shows
677            // a control the v1 send path would silently ignore. Never DOWNGRADE:
678            // a dual-stack community identified as v2 by register_v2_chats stays v2
679            // even if a v1-typed sync path (sync_community_chats / finalize_member_join)
680            // re-registers its chat afterwards. Protocol only ever advances.
681            let new_proto = protocol.as_i64();
682            let cur_proto = cf.get("proto_version").and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
683            cf.insert("proto_version".to_string(), new_proto.max(cur_proto).to_string());
684            // Owner-dissolution seal — the GUI reads this to lock the composer + show the end divider.
685            cf.insert("dissolved".to_string(), dissolved.to_string());
686            // Join time — sorts a not-yet-active community by when we joined, not to the bottom.
687            if let Some(ms) = created_at_ms {
688                cf.insert("created_at".to_string(), ms.to_string());
689            }
690            // The PROVEN owner npub (verified upstream) — for the crown/hoist + in-chat tag.
691            match owner_npub {
692                Some(n) => { cf.insert("owner_npub".to_string(), n.to_string()); }
693                None => { cf.remove("owner_npub"); }
694            }
695            if has_icon {
696                cf.insert("icon".to_string(), "1".to_string());
697            } else {
698                cf.remove("icon");
699            }
700        }
701    }
702
703    pub fn add_message_to_chat(&mut self, chat_id: &str, message: Message) -> bool {
704        let compact = CompactMessage::from_message(&message, &mut self.interner);
705
706        let (is_msg_added, chat_idx) = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
707            let added = self.chats[idx].add_compact_message(compact);
708            (added, idx)
709        } else {
710            let mut chat = if chat_id.starts_with("npub1") {
711                Chat::new_dm(chat_id.to_string(), &mut self.interner)
712            } else {
713                Chat::new(chat_id.to_string(), ChatType::Community, vec![])
714            };
715            let was_added = chat.add_compact_message(compact);
716            self.chats.push(chat);
717            (was_added, self.chats.len() - 1)
718        };
719
720        if is_msg_added && chat_idx > 0 {
721            let this_time = self.chats[chat_idx].last_message_time();
722            let target = self.chats[..chat_idx].iter()
723                .position(|c| c.last_message_time() <= this_time)
724                .unwrap_or(chat_idx);
725            if target < chat_idx {
726                self.chats[target..=chat_idx].rotate_right(1);
727            }
728        }
729
730        is_msg_added
731    }
732
733    pub fn add_messages_to_chat_batch(&mut self, chat_id: &str, messages: Vec<Message>) -> usize {
734        if messages.is_empty() { return 0; }
735
736        let compact_messages: Vec<_> = messages.into_iter()
737            .map(|msg| CompactMessage::from_message_owned(msg, &mut self.interner))
738            .collect();
739
740        let chat_idx = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
741            idx
742        } else {
743            let chat = if chat_id.starts_with("npub1") {
744                Chat::new_dm(chat_id.to_string(), &mut self.interner)
745            } else {
746                Chat::new(chat_id.to_string(), ChatType::Community, vec![])
747            };
748            self.chats.push(chat);
749            self.chats.len() - 1
750        };
751
752        let old_last_time = self.chats[chat_idx].messages.last_timestamp();
753        let added = self.chats[chat_idx].messages.insert_batch(compact_messages);
754
755        if added > 0 && self.chats[chat_idx].messages.last_timestamp() != old_last_time && chat_idx > 0 {
756            let this_time = self.chats[chat_idx].last_message_time();
757            let target = self.chats[..chat_idx].iter()
758                .position(|c| c.last_message_time() <= this_time)
759                .unwrap_or(chat_idx);
760            if target < chat_idx {
761                self.chats[target..=chat_idx].rotate_right(1);
762            }
763        }
764
765        added
766    }
767
768    /// Add a message to a participant's DM chat. Creates profile if missing.
769    ///
770    /// Unlike the src-tauri version, emitting `profile_update` is the caller's responsibility.
771    pub fn add_message_to_participant(&mut self, their_npub: &str, message: Message) -> bool {
772        let id = self.interner.intern(their_npub);
773        if self.get_profile_by_id(id).is_none() {
774            let profile = Profile::new();
775            self.insert_or_replace_profile(their_npub, profile);
776
777            // Emit profile update via EventEmitter trait (replaces TAURI_APP.emit)
778            if let Some(slim) = self.serialize_profile(id) {
779                emit_event("profile_update", &slim);
780            }
781        }
782
783        let chat_id = self.create_dm_chat(their_npub);
784        self.add_message_to_chat(&chat_id, message)
785    }
786
787    // ========================================================================
788    // Message Lookup
789    // ========================================================================
790
791    pub fn find_message(&self, message_id: &str) -> Option<(&Chat, Message)> {
792        if message_id.is_empty() { return None; }
793        for chat in &self.chats {
794            if let Some(compact) = chat.get_compact_message(message_id) {
795                return Some((chat, compact.to_message(&self.interner)));
796            }
797        }
798        None
799    }
800
801    pub fn find_chat_for_message(&self, message_id: &str) -> Option<(usize, String)> {
802        if message_id.is_empty() { return None; }
803        for (idx, chat) in self.chats.iter().enumerate() {
804            if chat.has_message(message_id) { return Some((idx, chat.id.clone())); }
805        }
806        None
807    }
808
809    pub fn update_message<F>(&mut self, message_id: &str, f: F) -> Option<(String, Message)>
810    where F: FnOnce(&mut CompactMessage)
811    {
812        if message_id.is_empty() { return None; }
813        let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
814        if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
815        let chat_id = self.chats[chat_idx].id.clone();
816        self.chats[chat_idx].get_compact_message(message_id).map(|m| (chat_id, m.to_message(&self.interner)))
817    }
818
819    pub fn update_message_in_chat<F>(&mut self, chat_id: &str, message_id: &str, f: F) -> Option<Message>
820    where F: FnOnce(&mut CompactMessage)
821    {
822        let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
823        if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
824        self.chats[chat_idx].get_compact_message(message_id).map(|m| m.to_message(&self.interner))
825    }
826
827    pub fn finalize_pending_message(&mut self, chat_id: &str, pending_id: &str, real_id: &str) -> Option<(String, Message)> {
828        let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
829        if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(pending_id) {
830            msg.id = crate::simd::hex::hex_to_bytes_32(real_id);
831            msg.set_pending(false);
832        }
833        self.chats[chat_idx].messages.rebuild_index();
834        self.chats[chat_idx].get_compact_message(real_id)
835            .map(|m| (pending_id.to_string(), m.to_message(&self.interner)))
836    }
837
838    pub fn update_attachment<F>(&mut self, chat_hint: &str, msg_id: &str, attachment_id: &str, f: F) -> bool
839    where F: FnOnce(&mut CompactAttachment)
840    {
841        for chat in &mut self.chats {
842            let is_target = match &chat.chat_type {
843                // Community channels are addressed by their id.
844                ChatType::Community => chat.id == chat_hint,
845                ChatType::DirectMessage => chat.has_participant(chat_hint, &self.interner),
846            };
847            if is_target {
848                if let Some(msg) = chat.messages.find_by_hex_id_mut(msg_id) {
849                    if let Some(att) = msg.attachments.iter_mut().find(|a| a.id_eq(attachment_id)) {
850                        f(att);
851                        return true;
852                    }
853                }
854            }
855        }
856        false
857    }
858
859    pub fn add_attachment_to_message(&mut self, chat_id: &str, msg_id: &str, attachment: CompactAttachment) -> bool {
860        let chat_idx = match self.chats.iter().position(|c| c.id == chat_id || c.has_participant(chat_id, &self.interner)) {
861            Some(idx) => idx,
862            None => return false,
863        };
864        if let Some(msg) = self.chats[chat_idx].messages.find_by_hex_id_mut(msg_id) {
865            msg.attachments.push(attachment);
866            true
867        } else { false }
868    }
869
870    pub fn add_reaction_to_message(&mut self, message_id: &str, reaction: Reaction) -> Option<(String, bool)> {
871        if message_id.is_empty() { return None; }
872        let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
873        let chat_id = self.chats[chat_idx].id.clone();
874        let msg = self.chats[chat_idx].get_compact_message_mut(message_id)?;
875        let added = msg.add_reaction(reaction, &mut self.interner);
876        Some((chat_id, added))
877    }
878
879    /// Locate a reaction by its event id across all chats.
880    /// Returns `(chat_id, parent_message_id, author_npub, is_community)`.
881    pub fn find_reaction(&self, reaction_id: &str) -> Option<(String, String, String, bool)> {
882        if reaction_id.is_empty() { return None; }
883        let target = crate::simd::hex::hex_to_bytes_32(reaction_id);
884        for chat in &self.chats {
885            for msg in chat.iter_compact() {
886                if let Some(r) = msg.reactions.iter().find(|r| r.id == target) {
887                    let author = self.interner.resolve(r.author_idx).unwrap_or("").to_string();
888                    return Some((chat.id.clone(), msg.id_hex(), author, chat.is_community()));
889                }
890            }
891        }
892        None
893    }
894
895    /// Remove a reaction from its parent message. Returns `(chat_id, updated Message)`
896    /// for the UI refresh, or `None` if the reaction wasn't present.
897    pub fn remove_reaction_from_message(&mut self, message_id: &str, reaction_id: &str) -> Option<(String, Message)> {
898        if message_id.is_empty() { return None; }
899        let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
900        let removed = self.chats[chat_idx]
901            .get_compact_message_mut(message_id)
902            .map(|m| m.remove_reaction(reaction_id))
903            .unwrap_or(false);
904        if !removed { return None; }
905        let chat_id = self.chats[chat_idx].id.clone();
906        self.chats[chat_idx]
907            .get_compact_message(message_id)
908            .map(|m| (chat_id, m.to_message(&self.interner)))
909    }
910
911    pub fn remove_message(&mut self, message_id: &str) -> Option<(String, Message)> {
912        if message_id.is_empty() { return None; }
913        for chat in &mut self.chats {
914            if let Some(compact) = chat.messages.find_by_hex_id(message_id) {
915                let msg = compact.to_message(&self.interner);
916                let removed_id = compact.id;
917                let removed_at = compact.at;
918                let chat_id = chat.id.clone();
919                let was_marker = chat.last_read == removed_id;
920                chat.messages.remove_by_hex_id(message_id);
921                // A deleted read marker leaves `last_read` dangling and collapses the unread anchor
922                // (badge stuck at 99+); retreat it to the newest surviving contact message before the
923                // deleted one, or clear it. Mirrors the DB retreat in `db::events::delete_event`.
924                if was_marker {
925                    chat.last_read = chat.messages.iter().rev()
926                        .find(|m| m.at <= removed_at && !m.flags.is_mine())
927                        .map(|m| m.id)
928                        .unwrap_or([0u8; 32]);
929                }
930                return Some((chat_id, msg));
931            }
932        }
933        None
934    }
935
936    pub fn message_exists(&self, message_id: &str) -> bool {
937        !message_id.is_empty() && self.chats.iter().any(|chat| chat.has_message(message_id))
938    }
939
940    // ========================================================================
941    // Unread Count
942    // ========================================================================
943
944    /// Sum DB-computed per-chat unread counts, applying the same muted/blocked filters as
945    /// [`count_unread_messages`] but sourcing each COUNT from `counts` (chat_identifier → unread)
946    /// rather than walking in-memory messages — so it's correct even when only the last message per
947    /// chat is in RAM (the boot state). Muted chats and blocked-DM contacts contribute 0.
948    pub fn sum_unread_from(&self, counts: &std::collections::HashMap<String, u32>) -> u32 {
949        let mut total = 0u32;
950        for chat in &self.chats {
951            if chat.muted {
952                continue;
953            }
954            if !chat.is_community() {
955                if let Some(id) = self.interner.lookup(&chat.id) {
956                    if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) {
957                        continue;
958                    }
959                }
960            } else if !chat.metadata.custom_fields.contains_key("community_id") {
961                // A Community row without its owning community_id is a bare
962                // persistence anchor (a sibling channel the UI doesn't surface) —
963                // its unreads can't be seen or cleared, so they must not badge.
964                continue;
965            }
966            total += counts.get(&chat.id).copied().unwrap_or(0);
967        }
968        total
969    }
970
971    pub fn count_unread_messages(&self) -> u32 {
972        let mut total_unread = 0;
973        for chat in &self.chats {
974            if chat.muted { continue; }
975            let is_group = chat.is_community();
976            if !is_group {
977                if let Some(id) = self.interner.lookup(&chat.id) {
978                    if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) { continue; }
979                }
980            } else if !chat.metadata.custom_fields.contains_key("community_id") {
981                // Unsurfaced sibling-channel anchor — see `sum_unread_from`.
982                continue;
983            }
984            let mut unread_count = 0u32;
985            for msg in chat.iter_compact().rev() {
986                if msg.flags.is_mine() { break; }
987                if chat.last_read != [0u8; 32] && msg.id == chat.last_read { break; }
988                if is_group && msg.npub_idx != NO_NPUB {
989                    if self.get_profile_by_id(msg.npub_idx).map_or(false, |p| p.flags.is_blocked()) { continue; }
990                }
991                unread_count += 1;
992            }
993            // Debug: log which chat has unread messages
994            #[cfg(debug_assertions)]
995            if unread_count > 0 {
996                let last_read_hex = crate::compact::decode_message_id(&chat.last_read);
997                let last_msg_hex = chat.messages.last().map(|m| crate::compact::decode_message_id(&m.id)).unwrap_or_default();
998                let msg_count = chat.message_count();
999                eprintln!("[Unread] chat={} unread={} msgs_in_memory={} last_read={} last_msg={}",
1000                    &chat.id[..20.min(chat.id.len())], unread_count, msg_count,
1001                    &last_read_hex[..16.min(last_read_hex.len())], &last_msg_hex[..16.min(last_msg_hex.len())]);
1002            }
1003            total_unread += unread_count;
1004        }
1005        total_unread
1006    }
1007
1008    // ========================================================================
1009    // Typing Indicators
1010    // ========================================================================
1011
1012    pub fn update_typing_and_get_active(&mut self, chat_id: &str, npub: &str, expires_at: u64) -> Vec<String> {
1013        let handle = self.interner.intern(npub);
1014        if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
1015            chat.update_typing_participant(handle, expires_at);
1016            chat.get_active_typers(&self.interner)
1017        } else {
1018            Vec::new()
1019        }
1020    }
1021}
1022
1023impl Default for ChatState {
1024    fn default() -> Self { Self::new() }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030    use crate::types::Message;
1031    use crate::profile::{Profile, SlimProfile, Status};
1032    use crate::simd::hex::bytes_to_hex_32;
1033
1034    // ========================================================================
1035    // Helpers
1036    // ========================================================================
1037
1038    /// Create a deterministic 64-char hex ID from a u8 seed.
1039    /// First byte is always >= 0x10 to avoid the pending ID marker (0x01).
1040    fn make_hex_id(seed: u8) -> String {
1041        let mut bytes = [seed; 32];
1042        bytes[0] = seed.wrapping_add(0x10) | 0x10; // never 0x00 or 0x01
1043        bytes[1] = seed.wrapping_mul(37);
1044        bytes_to_hex_32(&bytes)
1045    }
1046
1047    /// Build a test Message with the given parameters.
1048    fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
1049        Message {
1050            id: make_hex_id(id_seed),
1051            content: content.to_string(),
1052            at: timestamp_ms,
1053            mine,
1054            ..Default::default()
1055        }
1056    }
1057
1058    /// Build a message with an npub sender.
1059    fn make_message_from(id_seed: u8, content: &str, timestamp_ms: u64, npub: &str) -> Message {
1060        Message {
1061            id: make_hex_id(id_seed),
1062            content: content.to_string(),
1063            at: timestamp_ms,
1064            mine: false,
1065            npub: Some(npub.to_string()),
1066            ..Default::default()
1067        }
1068    }
1069
1070    /// Build a SlimProfile for testing.
1071    fn make_slim_profile(id: &str, name: &str) -> SlimProfile {
1072        SlimProfile {
1073            id: id.to_string(),
1074            name: name.to_string(),
1075            display_name: String::new(),
1076            nickname: String::new(),
1077            lud06: String::new(),
1078            lud16: String::new(),
1079            banner: String::new(),
1080            avatar: String::new(),
1081            about: String::new(),
1082            website: String::new(),
1083            nip05: String::new(),
1084            status: Status::new(),
1085            last_updated: 0,
1086            mine: false,
1087            bot: false,
1088            is_blocked: false,
1089            avatar_cached: String::new(),
1090            banner_cached: String::new(),
1091        }
1092    }
1093
1094    // ========================================================================
1095    // Profile Management
1096    // ========================================================================
1097
1098    #[test]
1099    fn insert_or_replace_profile_creates_new() {
1100        let mut state = ChatState::new();
1101        let profile = Profile::new();
1102        state.insert_or_replace_profile("npub1alice", profile);
1103
1104        assert!(
1105            state.get_profile("npub1alice").is_some(),
1106            "newly inserted profile should be retrievable"
1107        );
1108        assert_eq!(state.profiles.len(), 1, "should have exactly one profile");
1109    }
1110
1111    #[test]
1112    fn insert_or_replace_profile_updates_existing() {
1113        let mut state = ChatState::new();
1114        let mut p1 = Profile::new();
1115        p1.name = "Alice".to_string().into_boxed_str();
1116        state.insert_or_replace_profile("npub1alice", p1);
1117
1118        let mut p2 = Profile::new();
1119        p2.name = "Alice Updated".to_string().into_boxed_str();
1120        state.insert_or_replace_profile("npub1alice", p2);
1121
1122        let fetched = state.get_profile("npub1alice").expect("profile should exist");
1123        assert_eq!(
1124            &*fetched.name, "Alice Updated",
1125            "profile name should be updated after replace"
1126        );
1127        assert_eq!(state.profiles.len(), 1, "should still be one profile, not duplicated");
1128    }
1129
1130    #[test]
1131    fn get_profile_by_npub() {
1132        let mut state = ChatState::new();
1133        let mut profile = Profile::new();
1134        profile.name = "Bob".to_string().into_boxed_str();
1135        state.insert_or_replace_profile("npub1bob", profile);
1136
1137        let fetched = state.get_profile("npub1bob").expect("profile should be found");
1138        assert_eq!(&*fetched.name, "Bob", "fetched profile name should match");
1139    }
1140
1141    #[test]
1142    fn get_profile_returns_none_for_unknown() {
1143        let state = ChatState::new();
1144        assert!(
1145            state.get_profile("npub1unknown").is_none(),
1146            "unknown npub should return None"
1147        );
1148    }
1149
1150    #[test]
1151    fn get_profile_by_id_works() {
1152        let mut state = ChatState::new();
1153        let mut profile = Profile::new();
1154        profile.name = "Charlie".to_string().into_boxed_str();
1155        state.insert_or_replace_profile("npub1charlie", profile);
1156
1157        let id = state.interner.lookup("npub1charlie").expect("npub should be interned");
1158        let fetched = state.get_profile_by_id(id).expect("profile should be found by id");
1159        assert_eq!(&*fetched.name, "Charlie", "profile looked up by id should match");
1160    }
1161
1162    #[test]
1163    fn get_profile_by_id_returns_none_for_invalid() {
1164        let state = ChatState::new();
1165        assert!(
1166            state.get_profile_by_id(9999).is_none(),
1167            "invalid interner id should return None"
1168        );
1169    }
1170
1171    #[test]
1172    fn merge_db_profiles_sets_mine_flag() {
1173        let mut state = ChatState::new();
1174        let slim_mine = make_slim_profile("npub1me", "Me");
1175        let slim_other = make_slim_profile("npub1other", "Other");
1176
1177        state.merge_db_profiles(vec![slim_mine, slim_other], "npub1me");
1178
1179        let me = state.get_profile("npub1me").expect("my profile should exist");
1180        assert!(me.flags.is_mine(), "my profile should have mine flag set");
1181
1182        let other = state.get_profile("npub1other").expect("other profile should exist");
1183        assert!(!other.flags.is_mine(), "other profile should not have mine flag");
1184    }
1185
1186    #[test]
1187    fn serialize_profile_roundtrip() {
1188        let mut state = ChatState::new();
1189        let mut profile = Profile::new();
1190        profile.name = "Roundtrip".to_string().into_boxed_str();
1191        profile.about = "Test about".to_string().into_boxed_str();
1192        profile.flags.set_blocked(true);
1193        state.insert_or_replace_profile("npub1round", profile);
1194
1195        let id = state.interner.lookup("npub1round").unwrap();
1196        let slim = state.serialize_profile(id).expect("serialization should succeed");
1197
1198        assert_eq!(slim.id, "npub1round", "serialized id should match");
1199        assert_eq!(slim.name, "Roundtrip", "serialized name should match");
1200        assert_eq!(slim.about, "Test about", "serialized about should match");
1201        assert!(slim.is_blocked, "serialized blocked flag should be true");
1202
1203        // Convert back to profile and re-insert
1204        let restored = slim.to_profile();
1205        assert_eq!(&*restored.name, "Roundtrip", "restored name should match");
1206        assert!(restored.flags.is_blocked(), "restored blocked flag should be true");
1207    }
1208
1209    #[test]
1210    fn binary_search_maintains_sorted_order_with_100_profiles() {
1211        let mut state = ChatState::new();
1212
1213        // Insert 100 profiles in random-ish order
1214        let npubs: Vec<String> = (0..100).map(|i| format!("npub1user{:04}", i)).collect();
1215        let mut shuffled = npubs.clone();
1216        // Simple deterministic shuffle
1217        for i in (1..shuffled.len()).rev() {
1218            let j = (i * 37 + 13) % (i + 1);
1219            shuffled.swap(i, j);
1220        }
1221
1222        for npub in &shuffled {
1223            let mut profile = Profile::new();
1224            profile.name = npub.clone().into_boxed_str();
1225            state.insert_or_replace_profile(npub, profile);
1226        }
1227
1228        // All should be findable
1229        for npub in &npubs {
1230            assert!(
1231                state.get_profile(npub).is_some(),
1232                "profile {} should be retrievable after bulk insert",
1233                npub
1234            );
1235        }
1236
1237        // Internal profiles vec should be sorted by id
1238        for window in state.profiles.windows(2) {
1239            assert!(
1240                window[0].id < window[1].id,
1241                "profiles should be sorted by interner id"
1242            );
1243        }
1244
1245        assert_eq!(state.profiles.len(), 100, "should have exactly 100 profiles");
1246    }
1247
1248    #[test]
1249    fn insert_same_npub_twice_updates_not_duplicates() {
1250        let mut state = ChatState::new();
1251
1252        for i in 0..5 {
1253            let mut profile = Profile::new();
1254            profile.name = format!("version_{}", i).into_boxed_str();
1255            state.insert_or_replace_profile("npub1repeated", profile);
1256        }
1257
1258        assert_eq!(state.profiles.len(), 1, "repeated inserts should not create duplicates");
1259        let p = state.get_profile("npub1repeated").unwrap();
1260        assert_eq!(&*p.name, "version_4", "should retain the last update");
1261    }
1262
1263    #[test]
1264    fn get_profile_mut_modifies_in_place() {
1265        let mut state = ChatState::new();
1266        let profile = Profile::new();
1267        state.insert_or_replace_profile("npub1mutable", profile);
1268
1269        let p = state.get_profile_mut("npub1mutable").expect("profile should exist");
1270        p.name = "Mutated".to_string().into_boxed_str();
1271
1272        let fetched = state.get_profile("npub1mutable").unwrap();
1273        assert_eq!(&*fetched.name, "Mutated", "mutation should persist");
1274    }
1275
1276    // ========================================================================
1277    // Chat Management
1278    // ========================================================================
1279
1280    #[test]
1281    fn create_dm_chat_creates_new() {
1282        let mut state = ChatState::new();
1283        let id = state.create_dm_chat("npub1peer");
1284
1285        assert_eq!(id, "npub1peer", "returned id should match the npub");
1286        assert!(state.get_chat("npub1peer").is_some(), "chat should be created");
1287        assert_eq!(state.chats.len(), 1, "should have exactly one chat");
1288    }
1289
1290    #[test]
1291    fn create_dm_chat_is_idempotent() {
1292        let mut state = ChatState::new();
1293        state.create_dm_chat("npub1peer");
1294        state.create_dm_chat("npub1peer");
1295        state.create_dm_chat("npub1peer");
1296
1297        assert_eq!(state.chats.len(), 1, "repeated creates should not duplicate");
1298    }
1299
1300    #[test]
1301    fn ensure_community_chat_idempotent() {
1302        let mut state = ChatState::new();
1303        state.ensure_community_chat("grp1");
1304        state.ensure_community_chat("grp1");
1305
1306        assert_eq!(state.chats.len(), 1, "second call should not create a duplicate");
1307        let chat = state.get_chat("grp1").expect("community chat should exist");
1308        assert!(chat.is_community(), "should be a Community chat");
1309    }
1310
1311    #[test]
1312    fn get_chat_by_id() {
1313        let mut state = ChatState::new();
1314        state.create_dm_chat("npub1x");
1315
1316        let chat = state.get_chat("npub1x").expect("chat should exist");
1317        assert_eq!(chat.id, "npub1x", "chat id should match");
1318    }
1319
1320    #[test]
1321    fn get_chat_returns_none_for_missing() {
1322        let state = ChatState::new();
1323        assert!(state.get_chat("nonexistent").is_none(), "missing chat should return None");
1324    }
1325
1326    #[test]
1327    fn get_chat_mut_modifies_in_place() {
1328        let mut state = ChatState::new();
1329        state.create_dm_chat("npub1editable");
1330
1331        let chat = state.get_chat_mut("npub1editable").expect("chat should exist");
1332        chat.muted = true;
1333
1334        let refetched = state.get_chat("npub1editable").unwrap();
1335        assert!(refetched.muted, "muted flag should persist after mutation");
1336    }
1337
1338    #[test]
1339    fn multiple_different_chats() {
1340        let mut state = ChatState::new();
1341        state.create_dm_chat("npub1alice");
1342        state.create_dm_chat("npub1bob");
1343        state.ensure_community_chat("grp1");
1344
1345        assert_eq!(state.chats.len(), 3, "should have three distinct chats");
1346    }
1347
1348    // ========================================================================
1349    // Message Management
1350    // ========================================================================
1351
1352    #[test]
1353    fn add_message_to_chat_single() {
1354        let mut state = ChatState::new();
1355        state.create_dm_chat("npub1peer");
1356
1357        let msg = make_message(1, "hello", 1700000000000, false);
1358        let added = state.add_message_to_chat("npub1peer", msg);
1359
1360        assert!(added, "first message should be added successfully");
1361        let chat = state.get_chat("npub1peer").unwrap();
1362        assert_eq!(chat.message_count(), 1, "chat should have one message");
1363    }
1364
1365    #[test]
1366    fn add_message_to_chat_dedup_rejects_same_id() {
1367        let mut state = ChatState::new();
1368        state.create_dm_chat("npub1peer");
1369
1370        let msg1 = make_message(1, "hello", 1700000000000, false);
1371        let msg2 = make_message(1, "duplicate", 1700000001000, false);
1372
1373        let added1 = state.add_message_to_chat("npub1peer", msg1);
1374        let added2 = state.add_message_to_chat("npub1peer", msg2);
1375
1376        assert!(added1, "first insert should succeed");
1377        assert!(!added2, "duplicate ID should be rejected");
1378        assert_eq!(
1379            state.get_chat("npub1peer").unwrap().message_count(), 1,
1380            "should still have only one message"
1381        );
1382    }
1383
1384    #[test]
1385    fn add_messages_to_chat_batch_works() {
1386        let mut state = ChatState::new();
1387        state.create_dm_chat("npub1peer");
1388
1389        let msgs: Vec<Message> = (0..10).map(|i| {
1390            make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1391        }).collect();
1392
1393        let added = state.add_messages_to_chat_batch("npub1peer", msgs);
1394        assert_eq!(added, 10, "all 10 messages should be added");
1395        assert_eq!(
1396            state.get_chat("npub1peer").unwrap().message_count(), 10,
1397            "chat should have 10 messages"
1398        );
1399    }
1400
1401    #[test]
1402    fn add_messages_to_chat_batch_dedup() {
1403        let mut state = ChatState::new();
1404        state.create_dm_chat("npub1peer");
1405
1406        // Add first batch
1407        let msgs1: Vec<Message> = (0..5).map(|i| {
1408            make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1409        }).collect();
1410        state.add_messages_to_chat_batch("npub1peer", msgs1);
1411
1412        // Add overlapping batch (IDs 3,4 overlap, 5,6,7 are new)
1413        let msgs2: Vec<Message> = (3..8).map(|i| {
1414            make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1415        }).collect();
1416        let added = state.add_messages_to_chat_batch("npub1peer", msgs2);
1417
1418        assert_eq!(added, 3, "only 3 new messages should be added (5, 6, 7)");
1419        assert_eq!(
1420            state.get_chat("npub1peer").unwrap().message_count(), 8,
1421            "total should be 8 unique messages"
1422        );
1423    }
1424
1425    #[test]
1426    fn add_message_to_participant_creates_profile_and_chat() {
1427        let mut state = ChatState::new();
1428
1429        let msg = make_message(1, "hi there", 1700000000000, false);
1430        let added = state.add_message_to_participant("npub1stranger", msg);
1431
1432        assert!(added, "message should be added");
1433        assert!(
1434            state.get_profile("npub1stranger").is_some(),
1435            "profile should be auto-created for unknown participant"
1436        );
1437        assert!(
1438            state.get_chat("npub1stranger").is_some(),
1439            "DM chat should be auto-created"
1440        );
1441    }
1442
1443    #[test]
1444    fn add_message_to_participant_uses_existing_profile() {
1445        let mut state = ChatState::new();
1446
1447        // Pre-create profile
1448        let mut profile = Profile::new();
1449        profile.name = "Known User".to_string().into_boxed_str();
1450        state.insert_or_replace_profile("npub1known", profile);
1451
1452        let msg = make_message(1, "hello", 1700000000000, false);
1453        state.add_message_to_participant("npub1known", msg);
1454
1455        // Profile should not be replaced
1456        let p = state.get_profile("npub1known").unwrap();
1457        assert_eq!(&*p.name, "Known User", "existing profile should not be overwritten");
1458    }
1459
1460    #[test]
1461    fn find_message_across_chats() {
1462        let mut state = ChatState::new();
1463        state.create_dm_chat("npub1a");
1464        state.create_dm_chat("npub1b");
1465
1466        let msg_a = make_message(1, "in chat a", 1700000000000, false);
1467        let msg_b = make_message(2, "in chat b", 1700000001000, false);
1468        let msg_id_b = msg_b.id.clone();
1469
1470        state.add_message_to_chat("npub1a", msg_a);
1471        state.add_message_to_chat("npub1b", msg_b);
1472
1473        let (chat, found_msg) = state.find_message(&msg_id_b).expect("message should be found");
1474        assert_eq!(chat.id, "npub1b", "should find in correct chat");
1475        assert_eq!(found_msg.content, "in chat b", "content should match");
1476    }
1477
1478    #[test]
1479    fn find_message_returns_none_for_unknown() {
1480        let state = ChatState::new();
1481        assert!(
1482            state.find_message(&make_hex_id(99)).is_none(),
1483            "unknown message id should return None"
1484        );
1485    }
1486
1487    #[test]
1488    fn find_message_empty_id_returns_none() {
1489        let state = ChatState::new();
1490        assert!(state.find_message("").is_none(), "empty id should return None");
1491    }
1492
1493    #[test]
1494    fn update_message_mutates_and_returns() {
1495        let mut state = ChatState::new();
1496        state.create_dm_chat("npub1peer");
1497
1498        let msg = make_message(1, "original", 1700000000000, false);
1499        let msg_id = msg.id.clone();
1500        state.add_message_to_chat("npub1peer", msg);
1501
1502        let result = state.update_message(&msg_id, |cm| {
1503            cm.content = "updated content".to_string().into_boxed_str();
1504        });
1505
1506        let (chat_id, updated) = result.expect("update should return Some");
1507        assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1508        assert_eq!(updated.content, "updated content", "content should be updated");
1509    }
1510
1511    #[test]
1512    fn update_message_returns_none_for_missing() {
1513        let mut state = ChatState::new();
1514        let result = state.update_message(&make_hex_id(99), |_cm| {});
1515        assert!(result.is_none(), "updating nonexistent message should return None");
1516    }
1517
1518    #[test]
1519    fn finalize_pending_message_changes_id() {
1520        let mut state = ChatState::new();
1521        state.create_dm_chat("npub1peer");
1522
1523        let mut msg = make_message(1, "pending msg", 1700000000000, true);
1524        msg.pending = true;
1525        let pending_id = msg.id.clone();
1526        state.add_message_to_chat("npub1peer", msg);
1527
1528        let real_id = make_hex_id(2);
1529        let result = state.finalize_pending_message("npub1peer", &pending_id, &real_id);
1530
1531        let (old_id, finalized) = result.expect("finalize should succeed");
1532        assert_eq!(old_id, pending_id, "should return old pending id");
1533        assert_eq!(finalized.id, real_id, "message id should now be the real id");
1534        assert!(!finalized.pending, "message should no longer be pending");
1535
1536        // Old ID should no longer be findable
1537        assert!(
1538            state.find_message(&pending_id).is_none(),
1539            "pending id should no longer resolve"
1540        );
1541        // New ID should be findable
1542        assert!(
1543            state.find_message(&real_id).is_some(),
1544            "real id should now resolve"
1545        );
1546    }
1547
1548    #[test]
1549    fn remove_message_works() {
1550        let mut state = ChatState::new();
1551        state.create_dm_chat("npub1peer");
1552
1553        let msg = make_message(1, "deleteme", 1700000000000, false);
1554        let msg_id = msg.id.clone();
1555        state.add_message_to_chat("npub1peer", msg);
1556
1557        let result = state.remove_message(&msg_id);
1558        assert!(result.is_some(), "remove should return the removed message");
1559
1560        let (chat_id, removed) = result.unwrap();
1561        assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1562        assert_eq!(removed.content, "deleteme", "content should match");
1563
1564        assert!(
1565            state.find_message(&msg_id).is_none(),
1566            "removed message should no longer be findable"
1567        );
1568    }
1569
1570    #[test]
1571    fn remove_message_retreats_last_read_marker() {
1572        let mut state = ChatState::new();
1573        state.create_dm_chat("npub1peer");
1574        let m1 = make_message(1, "one", 1_700_000_000_000, false);
1575        let m2 = make_message(2, "two", 1_700_000_001_000, false);
1576        let (m1_id, m2_id) = (m1.id.clone(), m2.id.clone());
1577        state.add_message_to_chat("npub1peer", m1);
1578        state.add_message_to_chat("npub1peer", m2);
1579
1580        // Read up to the newest — m2 is the marker.
1581        state.chats.iter_mut().find(|c| c.id == "npub1peer").unwrap().last_read =
1582            crate::compact::encode_message_id(&m2_id);
1583
1584        // Deleting the marker retreats it to the prior survivor, never leaves it dangling.
1585        state.remove_message(&m2_id);
1586        assert_eq!(state.get_chat("npub1peer").unwrap().last_read,
1587            crate::compact::encode_message_id(&m1_id), "marker retreats to m1");
1588
1589        // Deleting the last survivor clears the marker (no predecessor).
1590        state.remove_message(&m1_id);
1591        assert_eq!(state.get_chat("npub1peer").unwrap().last_read, [0u8; 32],
1592            "no predecessor → marker clears");
1593    }
1594
1595    #[test]
1596    fn remove_message_returns_none_for_missing() {
1597        let mut state = ChatState::new();
1598        assert!(
1599            state.remove_message(&make_hex_id(99)).is_none(),
1600            "removing nonexistent message should return None"
1601        );
1602    }
1603
1604    #[test]
1605    fn message_exists_check() {
1606        let mut state = ChatState::new();
1607        state.create_dm_chat("npub1peer");
1608
1609        let msg = make_message(1, "exists", 1700000000000, false);
1610        let msg_id = msg.id.clone();
1611        state.add_message_to_chat("npub1peer", msg);
1612
1613        assert!(state.message_exists(&msg_id), "added message should exist");
1614        assert!(!state.message_exists(&make_hex_id(99)), "unknown id should not exist");
1615        assert!(!state.message_exists(""), "empty id should not exist");
1616    }
1617
1618    #[test]
1619    fn chat_reordering_newest_first_after_message_add() {
1620        let mut state = ChatState::new();
1621        state.create_dm_chat("npub1old");
1622        state.create_dm_chat("npub1new");
1623
1624        // Add an old message to the first chat
1625        let old_msg = make_message(1, "old", 1700000000000, false);
1626        state.add_message_to_chat("npub1old", old_msg);
1627
1628        // Add a newer message to the second chat
1629        let new_msg = make_message(2, "new", 1700000002000, false);
1630        state.add_message_to_chat("npub1new", new_msg);
1631
1632        assert_eq!(
1633            state.chats[0].id, "npub1new",
1634            "chat with newest message should be first"
1635        );
1636        assert_eq!(
1637            state.chats[1].id, "npub1old",
1638            "chat with older message should be second"
1639        );
1640    }
1641
1642    #[test]
1643    fn batch_add_does_not_reorder_for_old_messages() {
1644        let mut state = ChatState::new();
1645        state.create_dm_chat("npub1active");
1646        state.create_dm_chat("npub1history");
1647
1648        // Give active chat a recent message
1649        let recent = make_message(1, "recent", 1700000010000, false);
1650        state.add_message_to_chat("npub1active", recent);
1651
1652        // Batch-add old messages to history chat (pagination loading)
1653        let old_msgs: Vec<Message> = (10..15).map(|i| {
1654            make_message(i, &format!("old {}", i), 1700000000000 + i as u64 * 100, false)
1655        }).collect();
1656        state.add_messages_to_chat_batch("npub1history", old_msgs);
1657
1658        assert_eq!(
1659            state.chats[0].id, "npub1active",
1660            "active chat should remain first when batch has only old messages"
1661        );
1662    }
1663
1664    #[test]
1665    fn stress_test_50_messages_in_5_chats() {
1666        let mut state = ChatState::new();
1667
1668        for i in 0..5 {
1669            state.create_dm_chat(&format!("npub1chat{}", i));
1670        }
1671
1672        let mut total_added = 0;
1673        for i in 0..50u8 {
1674            let chat_idx = i as usize % 5;
1675            let chat_id = format!("npub1chat{}", chat_idx);
1676            let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, i % 3 == 0);
1677            if state.add_message_to_chat(&chat_id, msg) {
1678                total_added += 1;
1679            }
1680        }
1681
1682        assert_eq!(total_added, 50, "all 50 unique messages should be added");
1683
1684        let total_in_chats: usize = state.chats.iter().map(|c| c.message_count()).sum();
1685        assert_eq!(total_in_chats, 50, "total messages across all chats should be 50");
1686
1687        // Each chat should have 10 messages
1688        for i in 0..5 {
1689            let chat = state.get_chat(&format!("npub1chat{}", i)).unwrap();
1690            assert_eq!(
1691                chat.message_count(), 10,
1692                "chat {} should have 10 messages",
1693                i
1694            );
1695        }
1696
1697        // All messages should be findable
1698        for i in 0..50u8 {
1699            assert!(
1700                state.message_exists(&make_hex_id(i)),
1701                "message {} should exist",
1702                i
1703            );
1704        }
1705    }
1706
1707    #[test]
1708    fn add_message_auto_creates_dm_chat() {
1709        let mut state = ChatState::new();
1710
1711        // Add message to a chat that doesn't exist yet (npub-style ID)
1712        let msg = make_message(1, "auto create", 1700000000000, false);
1713        let added = state.add_message_to_chat("npub1auto", msg);
1714
1715        assert!(added, "message should be added");
1716        assert!(state.get_chat("npub1auto").is_some(), "DM chat should be auto-created");
1717    }
1718
1719    #[test]
1720    fn add_message_auto_creates_community_chat() {
1721        let mut state = ChatState::new();
1722
1723        // Add message to a non-npub ID (should create a Community chat)
1724        let msg = make_message(1, "group msg", 1700000000000, false);
1725        let added = state.add_message_to_chat("group_abc123", msg);
1726
1727        assert!(added, "message should be added");
1728        let chat = state.get_chat("group_abc123").expect("community chat should be auto-created");
1729        assert!(chat.is_community(), "auto-created non-npub chat should be a Community chat");
1730    }
1731
1732    // ========================================================================
1733    // Unread Count
1734    // ========================================================================
1735
1736    #[test]
1737    fn count_unread_messages_basic() {
1738        let mut state = ChatState::new();
1739        state.create_dm_chat("npub1peer");
1740
1741        for i in 0..5u8 {
1742            let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false);
1743            state.add_message_to_chat("npub1peer", msg);
1744        }
1745
1746        assert_eq!(state.count_unread_messages(), 5, "all 5 non-mine messages should be unread");
1747    }
1748
1749    #[test]
1750    fn count_unread_muted_chat_skipped() {
1751        let mut state = ChatState::new();
1752        state.create_dm_chat("npub1muted");
1753
1754        let msg = make_message(1, "muted msg", 1700000000000, false);
1755        state.add_message_to_chat("npub1muted", msg);
1756
1757        state.get_chat_mut("npub1muted").unwrap().muted = true;
1758
1759        assert_eq!(state.count_unread_messages(), 0, "muted chat should not count toward unread");
1760    }
1761
1762    #[test]
1763    fn count_unread_blocked_user_skipped() {
1764        let mut state = ChatState::new();
1765
1766        let mut profile = Profile::new();
1767        profile.flags.set_blocked(true);
1768        state.insert_or_replace_profile("npub1blocked", profile);
1769        state.create_dm_chat("npub1blocked");
1770
1771        let msg = make_message(1, "blocked msg", 1700000000000, false);
1772        state.add_message_to_chat("npub1blocked", msg);
1773
1774        assert_eq!(state.count_unread_messages(), 0, "blocked user DM should not count");
1775    }
1776
1777    #[test]
1778    fn count_unread_own_messages_break_count() {
1779        let mut state = ChatState::new();
1780        state.create_dm_chat("npub1peer");
1781
1782        // 3 from them, then 1 from me, then 2 from them
1783        let msg1 = make_message(1, "them 1", 1700000001000, false);
1784        let msg2 = make_message(2, "them 2", 1700000002000, false);
1785        let msg3 = make_message(3, "them 3", 1700000003000, false);
1786        let msg_mine = make_message(4, "me", 1700000004000, true);
1787        let msg5 = make_message(5, "them 4", 1700000005000, false);
1788        let msg6 = make_message(6, "them 5", 1700000006000, false);
1789
1790        for m in [msg1, msg2, msg3, msg_mine, msg5, msg6] {
1791            state.add_message_to_chat("npub1peer", m);
1792        }
1793
1794        // Counting from the end: msg6 (unread), msg5 (unread), then msg_mine breaks
1795        assert_eq!(
1796            state.count_unread_messages(), 2,
1797            "only messages after last 'mine' should count as unread"
1798        );
1799    }
1800
1801    #[test]
1802    fn count_unread_last_read_marker_breaks_count() {
1803        let mut state = ChatState::new();
1804        state.create_dm_chat("npub1peer");
1805
1806        let msg1 = make_message(1, "old", 1700000001000, false);
1807        let msg2 = make_message(2, "read up to here", 1700000002000, false);
1808        let msg3 = make_message(3, "new 1", 1700000003000, false);
1809        let msg4 = make_message(4, "new 2", 1700000004000, false);
1810        let read_marker_id = msg2.id.clone();
1811
1812        for m in [msg1, msg2, msg3, msg4] {
1813            state.add_message_to_chat("npub1peer", m);
1814        }
1815
1816        // Set last_read to msg2's ID
1817        let chat = state.get_chat_mut("npub1peer").unwrap();
1818        chat.last_read = crate::simd::hex::hex_to_bytes_32(&read_marker_id);
1819
1820        assert_eq!(
1821            state.count_unread_messages(), 2,
1822            "only messages after last_read marker should count"
1823        );
1824    }
1825
1826    #[test]
1827    fn count_unread_empty_chats_is_zero() {
1828        let mut state = ChatState::new();
1829        state.create_dm_chat("npub1empty1");
1830        state.create_dm_chat("npub1empty2");
1831
1832        assert_eq!(state.count_unread_messages(), 0, "empty chats should have zero unread");
1833    }
1834
1835    #[test]
1836    fn count_unread_blocked_group_member_messages_skipped() {
1837        let mut state = ChatState::new();
1838
1839        // Create a blocked profile
1840        let mut blocked_profile = Profile::new();
1841        blocked_profile.flags.set_blocked(true);
1842        state.insert_or_replace_profile("npub1blockedmember", blocked_profile);
1843
1844        // Create a normal profile
1845        let normal_profile = Profile::new();
1846        state.insert_or_replace_profile("npub1normal", normal_profile);
1847
1848        // A SURFACED community row (carries its owning community_id) — a bare
1849        // anchor row is excluded from unread totals by design.
1850        state.ensure_community_chat("grp1");
1851        if let Some(chat) = state.chats.iter_mut().find(|c| c.id == "grp1") {
1852            chat.metadata.custom_fields.insert("community_id".to_string(), "c".repeat(64));
1853        }
1854
1855        // Message from blocked member
1856        let msg_blocked = make_message_from(1, "blocked says hi", 1700000001000, "npub1blockedmember");
1857        state.add_message_to_chat("grp1", msg_blocked);
1858
1859        // Message from normal member
1860        let msg_normal = make_message_from(2, "normal says hi", 1700000002000, "npub1normal");
1861        state.add_message_to_chat("grp1", msg_normal);
1862
1863        assert_eq!(
1864            state.count_unread_messages(), 1,
1865            "only the non-blocked member's message should count"
1866        );
1867    }
1868
1869    #[test]
1870    fn count_unread_multiple_chats_summed() {
1871        let mut state = ChatState::new();
1872
1873        for i in 0..3 {
1874            let npub = format!("npub1chat{}", i);
1875            state.create_dm_chat(&npub);
1876            for j in 0..3u8 {
1877                let msg = make_message(
1878                    i * 10 + j,
1879                    &format!("msg {}-{}", i, j),
1880                    1700000000000 + j as u64 * 1000,
1881                    false,
1882                );
1883                state.add_message_to_chat(&npub, msg);
1884            }
1885        }
1886
1887        assert_eq!(
1888            state.count_unread_messages(), 9,
1889            "3 chats x 3 unread each = 9 total"
1890        );
1891    }
1892
1893    // ========================================================================
1894    // Typing Indicators
1895    // ========================================================================
1896
1897    #[test]
1898    fn update_typing_and_get_active_basic() {
1899        let mut state = ChatState::new();
1900        state.create_dm_chat("npub1peer");
1901
1902        // Set a far-future expiry so it's definitely active
1903        let far_future = std::time::SystemTime::now()
1904            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1905
1906        let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
1907        assert_eq!(active.len(), 1, "should have one active typer");
1908        assert_eq!(active[0], "npub1typer", "typer npub should match");
1909    }
1910
1911    #[test]
1912    fn update_typing_expired_typers_filtered() {
1913        let mut state = ChatState::new();
1914        state.create_dm_chat("npub1peer");
1915
1916        // Expired timestamp (in the past)
1917        let expired = 1000;
1918        let active = state.update_typing_and_get_active("npub1peer", "npub1expired", expired);
1919
1920        assert!(active.is_empty(), "expired typer should be filtered out");
1921    }
1922
1923    #[test]
1924    fn update_typing_multiple_typers() {
1925        let mut state = ChatState::new();
1926        state.create_dm_chat("npub1peer");
1927
1928        let far_future = std::time::SystemTime::now()
1929            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1930
1931        state.update_typing_and_get_active("npub1peer", "npub1typer1", far_future);
1932        let active = state.update_typing_and_get_active("npub1peer", "npub1typer2", far_future);
1933
1934        assert_eq!(active.len(), 2, "should have two active typers");
1935    }
1936
1937    #[test]
1938    fn update_typing_unknown_chat_returns_empty() {
1939        let mut state = ChatState::new();
1940        let far_future = std::time::SystemTime::now()
1941            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1942
1943        let active = state.update_typing_and_get_active("npub1nonexistent", "npub1typer", far_future);
1944        assert!(active.is_empty(), "unknown chat should return empty typers");
1945    }
1946
1947    #[test]
1948    fn update_typing_refreshes_existing_typer() {
1949        let mut state = ChatState::new();
1950        state.create_dm_chat("npub1peer");
1951
1952        let far_future = std::time::SystemTime::now()
1953            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1954
1955        state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
1956        // Update the same typer with a new expiry
1957        let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future + 100);
1958
1959        assert_eq!(active.len(), 1, "should still have only one typer entry after refresh");
1960    }
1961
1962    // ========================================================================
1963    // WrapperIdCache
1964    // ========================================================================
1965
1966    #[test]
1967    fn wrapper_id_cache_historical_and_pending() {
1968        let mut cache = WrapperIdCache::new();
1969
1970        let id1 = [1u8; 32];
1971        let id2 = [2u8; 32];
1972        let id3 = [3u8; 32];
1973
1974        cache.load(vec![id1, id2]);
1975        cache.insert(id3);
1976
1977        assert!(cache.contains(&id1), "historical id should be found");
1978        assert!(cache.contains(&id2), "historical id should be found");
1979        assert!(cache.contains(&id3), "pending id should be found");
1980        assert!(!cache.contains(&[4u8; 32]), "unknown id should not be found");
1981        assert_eq!(cache.len(), 3, "total count should be 3");
1982    }
1983
1984    #[test]
1985    fn wrapper_id_cache_clear() {
1986        let mut cache = WrapperIdCache::new();
1987        cache.load(vec![[1u8; 32]]);
1988        cache.insert([2u8; 32]);
1989
1990        cache.clear();
1991
1992        assert_eq!(cache.len(), 0, "cache should be empty after clear");
1993        assert!(!cache.contains(&[1u8; 32]), "cleared historical should not be found");
1994        assert!(!cache.contains(&[2u8; 32]), "cleared pending should not be found");
1995    }
1996}