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