1use nostr_sdk::prelude::*;
7use std::sync::RwLock;
8use std::collections::HashSet;
9use std::sync::atomic::{AtomicBool, Ordering};
10use tokio::sync::Mutex;
11
12use crate::chat::{Chat, ChatType};
13use crate::compact::{CompactMessage, CompactAttachment, NpubInterner, NO_NPUB};
14use crate::profile::{Profile, SlimProfile};
15use crate::types::{Message, Reaction};
16use crate::traits::emit_event;
17
18pub struct WrapperIdCache {
23 historical: Vec<[u8; 32]>,
24 pending: HashSet<[u8; 32]>,
25}
26
27impl WrapperIdCache {
28 pub fn new() -> Self { Self { historical: Vec::new(), pending: HashSet::new() } }
29
30 pub fn load(&mut self, mut ids: Vec<[u8; 32]>) {
31 ids.sort_unstable();
32 self.historical = ids;
33 self.pending.clear();
34 }
35
36 #[inline]
37 pub fn contains(&self, id: &[u8; 32]) -> bool {
38 self.historical.binary_search(id).is_ok() || self.pending.contains(id)
39 }
40
41 #[inline]
42 pub fn insert(&mut self, id: [u8; 32]) { self.pending.insert(id); }
43
44 pub fn clear(&mut self) {
45 self.historical.clear();
46 self.historical.shrink_to_fit();
47 self.pending.clear();
48 self.pending.shrink_to_fit();
49 }
50
51 pub fn len(&self) -> usize { self.historical.len() + self.pending.len() }
52
53 pub fn is_empty(&self) -> bool { self.historical.is_empty() && self.pending.is_empty() }
54}
55
56impl Default for WrapperIdCache {
57 fn default() -> Self { Self::new() }
58}
59
60pub static TRUSTED_RELAYS: &[&str] = &[
65 "wss://jskitty.com/nostr",
66 "wss://asia.vectorapp.io/nostr",
67 "wss://nostr.computingcache.com",
68 "wss://relay.ditto.pub",
73];
74
75pub static DISCOVERY_RELAYS: &[&str] = &[
83 "wss://purplepag.es",
84 "wss://relay.primal.net",
85 "wss://nos.lol",
86 "wss://relay.snort.social",
87];
88
89pub static DISCOVERY_READ_ONLY_RELAYS: &[&str] = &["wss://relay.ditto.pub"];
96
97pub fn discovery_relay_iter() -> impl Iterator<Item = &'static str> {
100 DISCOVERY_RELAYS
101 .iter()
102 .chain(DISCOVERY_READ_ONLY_RELAYS.iter())
103 .copied()
104}
105
106pub async fn active_trusted_relays() -> Vec<&'static str> {
107 let Some(client) = nostr_client() else { return Vec::new() };
108 let pool_relays = client.relays().await;
109 TRUSTED_RELAYS.iter().copied()
110 .filter(|url| {
111 let normalized = url.trim_end_matches('/');
112 pool_relays.keys().any(|r| r.as_str().trim_end_matches('/') == normalized)
113 })
114 .collect()
115}
116
117#[derive(Default)]
125struct BlossomServers(std::sync::Mutex<Option<Vec<String>>>);
126
127pub fn init_blossom_servers() -> Vec<String> {
128 crate::blossom_servers::DEFAULT_BLOSSOM_SERVERS
129 .iter().map(|s| s.to_string()).collect()
130}
131
132pub fn get_blossom_servers() -> Vec<String> {
133 let owner = crate::db::current_session().scoped::<BlossomServers, BlossomServers>();
134 if let Some(servers) = owner.0.lock().unwrap().clone() {
135 return servers;
136 }
137 match crate::blossom_servers::resolve_if_open() {
140 Some(servers) => owner.0.lock().unwrap().get_or_insert(servers).clone(),
141 None => init_blossom_servers(),
142 }
143}
144
145pub fn set_blossom_servers(servers: Vec<String>) {
147 let owner = crate::db::current_session().scoped::<BlossomServers, BlossomServers>();
148 *owner.0.lock().unwrap() = Some(servers);
149}
150
151pub static MNEMONIC_SEED: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
152pub static PENDING_NSEC: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
153
154pub static PENDING_BUNKER_SETUP:
163 std::sync::Mutex<Option<(zeroize::Zeroizing<String>, String)>> =
164 std::sync::Mutex::new(None);
165
166#[inline]
167pub fn set_pending_bunker_setup(url: String, remote_pk_hex: String) {
168 *PENDING_BUNKER_SETUP.lock().unwrap() =
169 Some((zeroize::Zeroizing::new(url), remote_pk_hex));
170}
171
172#[inline]
173pub fn pending_bunker_setup() -> Option<(String, String)> {
174 PENDING_BUNKER_SETUP.lock().unwrap()
175 .as_ref()
176 .map(|(z, pk)| (String::clone(&**z), pk.clone()))
177}
178
179#[inline]
180pub fn clear_pending_bunker_setup() {
181 *PENDING_BUNKER_SETUP.lock().unwrap() = None;
182}
183
184pub static PENDING_NIP55_SETUP: std::sync::Mutex<Option<(String, String)>> =
193 std::sync::Mutex::new(None);
194
195#[inline]
196pub fn set_pending_nip55_setup(user_pubkey_hex: String, signer_package: String) {
197 *PENDING_NIP55_SETUP.lock().unwrap() = Some((user_pubkey_hex, signer_package));
198}
199
200#[inline]
201pub fn pending_nip55_setup() -> Option<(String, String)> {
202 PENDING_NIP55_SETUP.lock().unwrap().clone()
203}
204
205#[inline]
206pub fn clear_pending_nip55_setup() {
207 *PENDING_NIP55_SETUP.lock().unwrap() = None;
208}
209
210pub static ENCRYPTION_KEY: crate::crypto::GuardedKey = crate::crypto::GuardedKey::empty();
211
212pub static ENCRYPTION_ENABLED: AtomicBool = AtomicBool::new(false);
213
214#[inline]
215pub fn is_encryption_enabled_fast() -> bool { ENCRYPTION_ENABLED.load(Ordering::Acquire) }
216
217#[inline]
218pub fn set_encryption_enabled(enabled: bool) { ENCRYPTION_ENABLED.store(enabled, Ordering::Release); }
219
220pub fn resolve_encryption_enabled(
234 encryption_enabled_row: Option<&str>,
235 security_type_row: Option<&str>,
236) -> bool {
237 match encryption_enabled_row {
238 Some("false") => false,
239 Some(_) => true,
240 None => security_type_row.is_some(),
241 }
242}
243
244pub fn resolve_encryption_enabled_from_db() -> bool {
247 let enc = crate::db::get_sql_setting("encryption_enabled".to_string()).ok().flatten();
248 let sec = crate::db::get_sql_setting("security_type".to_string()).ok().flatten();
249 resolve_encryption_enabled(enc.as_deref(), sec.as_deref())
250}
251
252pub fn init_encryption_enabled() {
253 let enabled = resolve_encryption_enabled_from_db();
254 set_encryption_enabled(enabled);
255}
256
257#[cfg(test)]
258mod resolve_encryption_enabled_tests {
259 use super::*;
260
261 #[test]
262 fn explicit_false_wins_even_with_security_type() {
263 assert!(!resolve_encryption_enabled(Some("false"), Some("password")));
264 }
265
266 #[test]
267 fn explicit_true_is_encrypted() {
268 assert!(resolve_encryption_enabled(Some("true"), None));
269 }
270
271 #[test]
272 fn missing_row_defaults_to_security_type_presence() {
273 assert!(resolve_encryption_enabled(None, Some("password")));
276 assert!(!resolve_encryption_enabled(None, None));
278 }
279
280 #[test]
281 fn explicit_non_false_value_is_encrypted() {
282 assert!(resolve_encryption_enabled(Some("1"), None));
285 assert!(resolve_encryption_enabled(Some(""), None));
286 }
287}
288
289struct ActiveClient;
309struct ActiveIdentity;
310
311fn active_client() -> std::sync::Arc<RwLock<Option<Client>>> {
312 crate::db::current_session().scoped::<ActiveClient, _>()
313}
314
315fn active_identity() -> std::sync::Arc<RwLock<Option<PublicKey>>> {
316 crate::db::current_session().scoped::<ActiveIdentity, _>()
317}
318
319pub static MY_SECRET_KEY: crate::crypto::GuardedKey = crate::crypto::GuardedKey::empty();
320
321#[inline]
325pub fn nostr_client() -> Option<Client> {
326 active_client().read().unwrap().as_ref().cloned()
327}
328
329#[inline]
331pub fn has_active_session() -> bool {
332 active_client().read().unwrap().is_some()
333}
334
335#[inline]
337pub fn my_public_key() -> Option<PublicKey> {
338 *active_identity().read().unwrap()
339}
340
341#[inline]
345pub fn set_nostr_client(client: Client) {
346 *active_client().write().unwrap() = Some(client);
347}
348
349#[inline]
354pub fn set_nostr_client_if_absent(client: Client) -> bool {
355 let owner = active_client();
356 let mut slot = owner.write().unwrap();
357 if slot.is_some() {
358 return false;
359 }
360 *slot = Some(client);
361 true
362}
363
364#[inline]
366pub fn set_my_public_key(pk: PublicKey) {
367 *active_identity().write().unwrap() = Some(pk);
368}
369
370#[inline]
374pub fn take_nostr_client() -> Option<Client> {
375 active_client().write().unwrap().take()
376}
377
378#[inline]
381pub fn clear_my_public_key() {
382 *active_identity().write().unwrap() = None;
383}
384
385#[derive(Clone)]
386pub struct PendingInviteAcceptance {
387 pub invite_code: String,
388 pub inviter_pubkey: PublicKey,
389}
390
391struct PendingInvite;
397
398fn pending_invite_slot() -> std::sync::Arc<RwLock<Option<PendingInviteAcceptance>>> {
399 crate::db::current_session().scoped::<PendingInvite, _>()
400}
401
402#[inline]
403pub fn pending_invite() -> Option<PendingInviteAcceptance> {
404 pending_invite_slot().read().unwrap().clone()
405}
406
407#[inline]
408pub fn set_pending_invite(invite: PendingInviteAcceptance) {
409 *pending_invite_slot().write().unwrap() = Some(invite);
410}
411
412#[inline]
413pub fn clear_pending_invite() {
414 *pending_invite_slot().write().unwrap() = None;
415}
416
417pub struct NotifiedWelcomesHandle;
420
421pub static NOTIFIED_WELCOMES: NotifiedWelcomesHandle = NotifiedWelcomesHandle;
422
423impl NotifiedWelcomesHandle {
424 pub async fn lock(&self) -> tokio::sync::OwnedMutexGuard<HashSet<String>> {
425 crate::db::current_session().scoped::<Self, Mutex<HashSet<String>>>().lock_owned().await
426 }
427}
428
429struct DeletedMessageTombstones;
441
442fn deleted_message_tombstones() -> std::sync::Arc<std::sync::Mutex<HashSet<String>>> {
443 crate::db::current_session().scoped::<DeletedMessageTombstones, _>()
444}
445
446pub fn note_message_deleted(message_id: &str) {
448 if let Ok(mut set) = deleted_message_tombstones().lock() {
449 set.insert(message_id.to_string());
450 }
451}
452
453pub fn seed_message_tombstones(ids: Vec<String>) {
457 if let Ok(mut set) = deleted_message_tombstones().lock() {
458 set.extend(ids);
459 }
460}
461
462pub fn was_message_deleted(message_id: &str) -> bool {
464 deleted_message_tombstones().lock().map(|s| s.contains(message_id)).unwrap_or(false)
465}
466
467#[inline]
473pub fn clear_message_tombstones() {
474 if let Ok(mut set) = deleted_message_tombstones().lock() {
475 set.clear();
476 }
477}
478
479#[cfg(test)]
480mod session_identity_tests {
481
482 #[test]
486 fn a_held_session_stops_being_live_once_the_account_changes() {
487 let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
488 let held = crate::db::current_session();
489 assert!(held.is_live(), "it is the account on screen to begin with");
490
491 crate::db::close_database();
492
493 assert!(!held.is_live(), "and is not, once the account has switched");
494 assert!(crate::db::current_session().is_live(), "whoever came next is");
495 assert_ne!(held.id(), crate::db::current_session_id(), "they are different sessions");
496 }
497}
498
499#[cfg(test)]
500mod session_globals_tests {
501 use super::*;
502
503 #[test]
507 fn session_helpers_round_trip_and_clear() {
508 clear_my_public_key();
512 clear_pending_invite();
513
514 let keys = Keys::parse(
519 "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
520 ).expect("parse test nsec");
521 let pk = keys.public_key();
522
523 assert_eq!(my_public_key(), None, "starts as None");
524
525 set_my_public_key(pk);
526 assert_eq!(my_public_key(), Some(pk));
527
528 clear_my_public_key();
529 assert_eq!(my_public_key(), None, "cleared returns None");
530
531 let invite = PendingInviteAcceptance {
533 invite_code: "abc123".to_string(),
534 inviter_pubkey: pk,
535 };
536
537 assert!(pending_invite().is_none(), "starts as None");
538
539 set_pending_invite(invite.clone());
540 let got = pending_invite().expect("set then read");
541 assert_eq!(got.invite_code, invite.invite_code);
542 assert_eq!(got.inviter_pubkey, invite.inviter_pubkey);
543
544 clear_pending_invite();
545 assert!(pending_invite().is_none(), "cleared returns None");
546
547 assert!(!has_active_session(), "no client installed");
550 assert!(take_nostr_client().is_none(), "take from empty returns None");
551 assert!(!has_active_session(), "still none after take-of-empty");
552
553 clear_pending_bunker_setup();
558 assert!(pending_bunker_setup().is_none(), "starts as None");
559
560 let url = "bunker://0123456789abcdef?relay=wss%3A%2F%2Frelay.example&secret=topsecret".to_string();
561 let pk_hex = "0123456789abcdef".to_string();
562 set_pending_bunker_setup(url.clone(), pk_hex.clone());
563
564 let first = pending_bunker_setup().expect("first peek");
567 let second = pending_bunker_setup().expect("second peek");
568 assert_eq!(first.0, url, "url survives clone-out from Zeroizing");
569 assert_eq!(first.0, second.0, "successive peeks return same data");
570 assert_eq!(first.1, pk_hex);
571
572 let url2 = "bunker://feedface?relay=wss%3A%2F%2Falt".to_string();
576 set_pending_bunker_setup(url2.clone(), "feedface".to_string());
577 let after = pending_bunker_setup().expect("overwritten read");
578 assert_eq!(after.0, url2);
579 assert_eq!(after.1, "feedface");
580
581 clear_pending_bunker_setup();
582 assert!(pending_bunker_setup().is_none(), "cleared returns None");
583
584 clear_pending_nip55_setup();
588 assert!(pending_nip55_setup().is_none(), "starts as None");
589
590 let user_pk = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string();
591 let package = "com.greenart7c3.nostrsigner".to_string();
592 set_pending_nip55_setup(user_pk.clone(), package.clone());
593 let peek1 = pending_nip55_setup().expect("first peek");
594 let peek2 = pending_nip55_setup().expect("second peek");
595 assert_eq!(peek1.0, user_pk);
596 assert_eq!(peek1.1, package);
597 assert_eq!(peek1, peek2, "successive peeks return same data");
598
599 set_pending_nip55_setup("beef".to_string(), "org.other.signer".to_string());
601 let after55 = pending_nip55_setup().expect("overwritten read");
602 assert_eq!(after55.0, "beef");
603 assert_eq!(after55.1, "org.other.signer");
604
605 clear_pending_nip55_setup();
606 assert!(pending_nip55_setup().is_none(), "cleared returns None");
607
608 let held = crate::db::current_session();
610 assert!(held.is_live(), "the account on screen to begin with");
611 crate::db::close_database();
612 assert!(!held.is_live(), "and not, once the account has switched");
613 }
614}
615
616pub struct WrapperIdCacheHandle;
621
622pub static WRAPPER_ID_CACHE: WrapperIdCacheHandle = WrapperIdCacheHandle;
623
624impl WrapperIdCacheHandle {
625 pub async fn lock(&self) -> tokio::sync::OwnedMutexGuard<WrapperIdCache> {
626 crate::db::current_session().scoped::<Self, Mutex<WrapperIdCache>>().lock_owned().await
627 }
628}
629
630pub struct ChatStateHandle;
642
643pub static STATE: ChatStateHandle = ChatStateHandle;
644
645impl ChatStateHandle {
646 pub async fn lock(&self) -> tokio::sync::OwnedMutexGuard<ChatState> {
647 crate::db::current_session().chat_state().lock_owned().await
648 }
649
650 pub fn try_lock(&self) -> Result<tokio::sync::OwnedMutexGuard<ChatState>, tokio::sync::TryLockError> {
653 crate::db::current_session().chat_state().try_lock_owned()
654 }
655}
656
657struct ActiveChat;
665
666fn active_chat() -> std::sync::Arc<RwLock<Option<String>>> {
667 crate::db::current_session().scoped::<ActiveChat, _>()
668}
669
670pub fn set_active_chat(chat_id: Option<String>) {
671 if let Ok(mut guard) = active_chat().write() {
672 *guard = chat_id;
673 }
674}
675
676pub fn get_active_chat() -> Option<String> {
677 active_chat().read().ok().and_then(|g| g.clone())
678}
679
680pub static PROCESSING_GATE: AtomicBool = AtomicBool::new(true);
685pub struct PendingEventsHandle;
688
689pub static PENDING_EVENTS: PendingEventsHandle = PendingEventsHandle;
690
691impl PendingEventsHandle {
692 pub async fn lock(&self) -> tokio::sync::OwnedMutexGuard<Vec<(Event, bool)>> {
693 crate::db::current_session().scoped::<Self, Mutex<Vec<(Event, bool)>>>().lock_owned().await
694 }
695}
696
697#[inline]
698pub fn is_processing_allowed() -> bool { PROCESSING_GATE.load(Ordering::Acquire) }
699pub fn close_processing_gate() { PROCESSING_GATE.store(false, Ordering::Release); }
700pub fn open_processing_gate() { PROCESSING_GATE.store(true, Ordering::Release); }
701
702#[derive(Clone, Debug)]
707pub struct ChatState {
708 pub profiles: Vec<Profile>,
709 pub chats: Vec<Chat>,
710 pub interner: NpubInterner,
711 pub is_syncing: bool,
712 pub db_loaded: bool,
713 pub unread_cache: std::collections::HashMap<String, u32>,
720 pub unread_seeded: bool,
723 #[cfg(debug_assertions)]
724 pub cache_stats: crate::stats::CacheStats,
725}
726
727fn chat_has_a_row(chat: &crate::chat::Chat) -> bool {
735 if !chat.metadata.custom_fields.contains_key("community_id") {
736 return false;
737 }
738 chat.is_surfaced_community_channel() || crate::notify::channel_is_listed(&chat.id)
739}
740
741impl ChatState {
742 pub fn new() -> Self {
743 Self {
744 profiles: Vec::new(),
745 chats: Vec::new(),
746 interner: NpubInterner::new(),
747 is_syncing: false,
748 db_loaded: false,
749 unread_cache: std::collections::HashMap::new(),
750 unread_seeded: false,
751 #[cfg(debug_assertions)]
752 cache_stats: crate::stats::CacheStats::new(),
753 }
754 }
755
756 pub fn merge_db_profiles(&mut self, slim_profiles: Vec<SlimProfile>, my_npub: &str) {
761 for slim in slim_profiles {
762 let mut full_profile = slim.to_profile();
763 full_profile.flags.set_mine(slim.id == my_npub);
764 self.insert_or_replace_profile(&slim.id, full_profile);
765 }
766 }
767
768 pub fn insert_or_replace_profile(&mut self, npub: &str, mut profile: Profile) {
769 let id = self.interner.intern(npub);
770 profile.id = id;
771 match self.profiles.binary_search_by(|p| p.id.cmp(&id)) {
772 Ok(idx) => self.profiles[idx] = profile,
773 Err(idx) => self.profiles.insert(idx, profile),
774 }
775 }
776
777 pub fn get_profile(&self, npub: &str) -> Option<&Profile> {
778 self.interner.lookup(npub).and_then(|id| self.get_profile_by_id(id))
779 }
780
781 pub fn get_profile_mut(&mut self, npub: &str) -> Option<&mut Profile> {
782 self.interner.lookup(npub).and_then(move |id| self.get_profile_mut_by_id(id))
783 }
784
785 #[inline]
786 pub fn get_profile_by_id(&self, id: u16) -> Option<&Profile> {
787 self.profiles.binary_search_by(|p| p.id.cmp(&id)).ok().map(|idx| &self.profiles[idx])
788 }
789
790 #[inline]
791 pub fn get_profile_mut_by_id(&mut self, id: u16) -> Option<&mut Profile> {
792 self.profiles.binary_search_by(|p| p.id.cmp(&id)).ok().map(move |idx| &mut self.profiles[idx])
793 }
794
795 pub fn serialize_profile(&self, id: u16) -> Option<SlimProfile> {
796 self.get_profile_by_id(id).map(|p| SlimProfile::from_profile(p, &self.interner))
797 }
798
799 pub fn get_chat(&self, id: &str) -> Option<&Chat> { self.chats.iter().find(|c| c.id == id) }
804 pub fn get_chat_mut(&mut self, id: &str) -> Option<&mut Chat> { self.chats.iter_mut().find(|c| c.id == id) }
805
806 pub fn create_dm_chat(&mut self, their_npub: &str) -> String {
807 if self.get_chat(their_npub).is_none() {
808 let chat = Chat::new_dm(their_npub.to_string(), &mut self.interner);
809 self.chats.push(chat);
810 }
811 their_npub.to_string()
812 }
813
814 pub fn ensure_community_chat(&mut self, channel_id: &str) {
820 if channel_id.is_empty() {
822 return;
823 }
824 if !self.chats.iter().any(|c| c.id == channel_id) {
825 let chat =
826 Chat::new_community_channel(channel_id.to_string(), Vec::new(), &mut self.interner);
827 self.chats.push(chat);
828 }
829 }
830
831 #[allow(clippy::too_many_arguments)]
842 pub fn upsert_community_chat(
843 &mut self,
844 channel_id: &str,
845 name: &str,
846 description: &str,
847 community_id: &str,
848 is_owner: bool,
849 has_icon: bool,
850 owner_npub: Option<&str>,
851 created_at_ms: Option<u64>,
852 dissolved: bool,
853 protocol: crate::community::ConcordProtocol,
854 channel_name: &str,
855 primary_channel: &str,
856 ) {
857 self.ensure_community_chat(channel_id);
858 if let Some(chat) = self.chats.iter_mut().find(|c| c.id == channel_id) {
859 let cf = &mut chat.metadata.custom_fields;
860 cf.insert("name".to_string(), name.to_string());
861 cf.insert("channel_name".to_string(), channel_name.to_string());
862 cf.insert("primary_channel".to_string(), primary_channel.to_string());
863 cf.insert("description".to_string(), description.to_string());
864 cf.insert("community_id".to_string(), community_id.to_string());
865 cf.insert("is_owner".to_string(), is_owner.to_string());
866 let new_proto = protocol.as_i64();
873 let cur_proto = cf.get("proto_version").and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
874 cf.insert("proto_version".to_string(), new_proto.max(cur_proto).to_string());
875 cf.insert("dissolved".to_string(), dissolved.to_string());
877 if let Some(ms) = created_at_ms {
879 cf.insert("created_at".to_string(), ms.to_string());
880 }
881 match owner_npub {
883 Some(n) => { cf.insert("owner_npub".to_string(), n.to_string()); }
884 None => { cf.remove("owner_npub"); }
885 }
886 if has_icon {
887 cf.insert("icon".to_string(), "1".to_string());
888 } else {
889 cf.remove("icon");
890 }
891 }
892 }
893
894 pub fn add_message_to_chat(&mut self, chat_id: &str, message: &Message) -> bool {
895 let compact = CompactMessage::from_message(message, &mut self.interner);
896
897 let (is_msg_added, chat_idx) = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
898 let added = self.chats[idx].add_compact_message(compact);
899 (added, idx)
900 } else {
901 let mut chat = if chat_id.starts_with("npub1") {
902 Chat::new_dm(chat_id.to_string(), &mut self.interner)
903 } else {
904 Chat::new(chat_id.to_string(), ChatType::Community, vec![])
905 };
906 let was_added = chat.add_compact_message(compact);
907 self.chats.push(chat);
908 (was_added, self.chats.len() - 1)
909 };
910
911 if is_msg_added && chat_idx > 0 {
912 let this_time = self.chats[chat_idx].last_message_time();
913 let target = self.chats[..chat_idx].iter()
914 .position(|c| c.last_message_time() <= this_time)
915 .unwrap_or(chat_idx);
916 if target < chat_idx {
917 self.chats[target..=chat_idx].rotate_right(1);
918 }
919 }
920
921 is_msg_added
922 }
923
924 pub fn add_messages_to_chat_batch(&mut self, chat_id: &str, messages: Vec<Message>) -> usize {
925 if messages.is_empty() { return 0; }
926
927 let compact_messages: Vec<_> = messages.into_iter()
928 .map(|msg| CompactMessage::from_message_owned(msg, &mut self.interner))
929 .collect();
930
931 let chat_idx = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
932 idx
933 } else {
934 let chat = if chat_id.starts_with("npub1") {
935 Chat::new_dm(chat_id.to_string(), &mut self.interner)
936 } else {
937 Chat::new(chat_id.to_string(), ChatType::Community, vec![])
938 };
939 self.chats.push(chat);
940 self.chats.len() - 1
941 };
942
943 let old_last_time = self.chats[chat_idx].messages.last_timestamp();
944 let added = self.chats[chat_idx].messages.insert_batch(compact_messages);
945
946 if added > 0 && self.chats[chat_idx].messages.last_timestamp() != old_last_time && chat_idx > 0 {
947 let this_time = self.chats[chat_idx].last_message_time();
948 let target = self.chats[..chat_idx].iter()
949 .position(|c| c.last_message_time() <= this_time)
950 .unwrap_or(chat_idx);
951 if target < chat_idx {
952 self.chats[target..=chat_idx].rotate_right(1);
953 }
954 }
955
956 added
957 }
958
959 pub fn add_message_to_participant(&mut self, their_npub: &str, message: &Message) -> bool {
963 let id = self.interner.intern(their_npub);
964 if self.get_profile_by_id(id).is_none() {
965 let profile = Profile::new();
966 self.insert_or_replace_profile(their_npub, profile);
967
968 if let Some(slim) = self.serialize_profile(id) {
970 emit_event("profile_update", &slim);
971 }
972 }
973
974 let chat_id = self.create_dm_chat(their_npub);
975 self.add_message_to_chat(&chat_id, message)
976 }
977
978 fn chat_index_of_message(&self, message_id: &str) -> Option<usize> {
984 if message_id.is_empty() { return None; }
985 let id = crate::compact::encode_message_id(message_id);
986 self.chats.iter().position(|chat| chat.messages.contains_id(&id))
987 }
988
989 pub fn find_message(&self, message_id: &str) -> Option<(&Chat, Message)> {
990 let chat = &self.chats[self.chat_index_of_message(message_id)?];
991 chat.get_compact_message(message_id).map(|compact| (chat, compact.to_message(&self.interner)))
992 }
993
994 pub fn find_chat_for_message(&self, message_id: &str) -> Option<(usize, String)> {
995 let idx = self.chat_index_of_message(message_id)?;
996 Some((idx, self.chats[idx].id.clone()))
997 }
998
999 pub fn update_message<F>(&mut self, message_id: &str, f: F) -> Option<(String, Message)>
1000 where F: FnOnce(&mut CompactMessage)
1001 {
1002 if message_id.is_empty() { return None; }
1003 let chat_idx = self.chat_index_of_message(message_id)?;
1004 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
1005 let chat_id = self.chats[chat_idx].id.clone();
1006 self.chats[chat_idx].get_compact_message(message_id).map(|m| (chat_id, m.to_message(&self.interner)))
1007 }
1008
1009 pub fn update_message_in_chat<F>(&mut self, chat_id: &str, message_id: &str, f: F) -> Option<Message>
1010 where F: FnOnce(&mut CompactMessage)
1011 {
1012 let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
1013 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
1014 self.chats[chat_idx].get_compact_message(message_id).map(|m| m.to_message(&self.interner))
1015 }
1016
1017 pub fn finalize_pending_message(&mut self, chat_id: &str, pending_id: &str, real_id: &str) -> Option<(String, Message)> {
1018 let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
1019 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(pending_id) {
1020 msg.id = crate::simd::hex::hex_to_bytes_32(real_id);
1021 msg.set_pending(false);
1022 }
1023 self.chats[chat_idx].messages.rebuild_index();
1024 self.chats[chat_idx].get_compact_message(real_id)
1025 .map(|m| (pending_id.to_string(), m.to_message(&self.interner)))
1026 }
1027
1028 pub fn update_attachment<F>(&mut self, chat_hint: &str, msg_id: &str, attachment_id: &str, f: F) -> bool
1029 where F: FnOnce(&mut CompactAttachment)
1030 {
1031 for chat in &mut self.chats {
1032 let is_target = match &chat.chat_type {
1033 ChatType::Community => chat.id == chat_hint,
1035 ChatType::DirectMessage => chat.has_participant(chat_hint, &self.interner),
1036 };
1037 if is_target {
1038 if let Some(msg) = chat.messages.find_by_hex_id_mut(msg_id) {
1039 if let Some(att) = msg.attachments.iter_mut().find(|a| a.id_eq(attachment_id)) {
1040 f(att);
1041 return true;
1042 }
1043 }
1044 }
1045 }
1046 false
1047 }
1048
1049 pub fn add_attachment_to_message(&mut self, chat_id: &str, msg_id: &str, attachment: CompactAttachment) -> bool {
1050 let chat_idx = match self.chats.iter().position(|c| c.id == chat_id || c.has_participant(chat_id, &self.interner)) {
1051 Some(idx) => idx,
1052 None => return false,
1053 };
1054 if let Some(msg) = self.chats[chat_idx].messages.find_by_hex_id_mut(msg_id) {
1055 msg.attachments.push(attachment);
1056 true
1057 } else { false }
1058 }
1059
1060 pub fn add_reaction_to_message(&mut self, message_id: &str, reaction: Reaction) -> Option<(String, bool)> {
1061 if message_id.is_empty() { return None; }
1062 let chat_idx = self.chat_index_of_message(message_id)?;
1063 let chat_id = self.chats[chat_idx].id.clone();
1064 let msg = self.chats[chat_idx].get_compact_message_mut(message_id)?;
1065 let added = msg.add_reaction(reaction, &mut self.interner);
1066 Some((chat_id, added))
1067 }
1068
1069 pub fn find_reaction(&self, reaction_id: &str) -> Option<(String, String, String, bool)> {
1072 if reaction_id.is_empty() { return None; }
1073 let target = crate::simd::hex::hex_to_bytes_32(reaction_id);
1074 for chat in &self.chats {
1075 for msg in chat.iter_compact() {
1076 if let Some(r) = msg.reactions.iter().find(|r| r.id == target) {
1077 let author = self.interner.resolve(r.author_idx).unwrap_or("").to_string();
1078 return Some((chat.id.clone(), msg.id_hex(), author, chat.is_community()));
1079 }
1080 }
1081 }
1082 None
1083 }
1084
1085 pub fn remove_reaction_from_message(&mut self, message_id: &str, reaction_id: &str) -> Option<(String, Message)> {
1088 if message_id.is_empty() { return None; }
1089 let chat_idx = self.chat_index_of_message(message_id)?;
1090 let removed = self.chats[chat_idx]
1091 .get_compact_message_mut(message_id)
1092 .map(|m| m.remove_reaction(reaction_id))
1093 .unwrap_or(false);
1094 if !removed { return None; }
1095 let chat_id = self.chats[chat_idx].id.clone();
1096 self.chats[chat_idx]
1097 .get_compact_message(message_id)
1098 .map(|m| (chat_id, m.to_message(&self.interner)))
1099 }
1100
1101 pub fn remove_message(&mut self, message_id: &str) -> Option<(String, Message)> {
1102 if message_id.is_empty() { return None; }
1103 for chat in &mut self.chats {
1104 if let Some(compact) = chat.messages.find_by_hex_id(message_id) {
1105 let msg = compact.to_message(&self.interner);
1106 let removed_id = compact.id;
1107 let removed_at = compact.at;
1108 let chat_id = chat.id.clone();
1109 let was_marker = chat.last_read == removed_id;
1110 chat.messages.remove_by_hex_id(message_id);
1111 if was_marker {
1115 chat.last_read = chat.messages.iter().rev()
1116 .find(|m| m.at <= removed_at && !m.flags.is_mine())
1117 .map(|m| m.id)
1118 .unwrap_or([0u8; 32]);
1119 }
1120 return Some((chat_id, msg));
1121 }
1122 }
1123 None
1124 }
1125
1126 pub fn message_exists(&self, message_id: &str) -> bool {
1127 self.chat_index_of_message(message_id).is_some()
1128 }
1129
1130 pub fn sum_unread_from(&self, counts: &std::collections::HashMap<String, u32>) -> u32 {
1139 let mut total = 0u32;
1140 for chat in &self.chats {
1141 if crate::notify::ring_for_chat(chat) != crate::notify::NotifyLevel::All {
1144 continue;
1145 }
1146 if !chat.is_community() {
1147 if let Some(id) = self.interner.lookup(&chat.id) {
1148 if self.get_profile_by_id(id).is_some_and(|p| p.flags.is_blocked()) {
1149 continue;
1150 }
1151 }
1152 } else if !chat_has_a_row(chat) {
1153 continue;
1154 }
1155 total += counts.get(&chat.id).copied().unwrap_or(0);
1156 }
1157 total
1158 }
1159
1160 pub fn unread_seed(&mut self, counts: std::collections::HashMap<String, u32>) {
1168 self.unread_cache = counts;
1169 self.unread_seeded = true;
1170 }
1171
1172 pub fn unread_clear(&mut self, chat_id: &str) {
1174 self.unread_cache.remove(chat_id);
1175 }
1176
1177 pub fn unread_set(&mut self, chat_id: &str, count: u32) {
1180 if count == 0 {
1181 self.unread_cache.remove(chat_id);
1182 } else {
1183 self.unread_cache.insert(chat_id.to_string(), count);
1184 }
1185 }
1186
1187 pub fn sum_unread(&self) -> u32 {
1190 self.sum_unread_from(&self.unread_cache)
1191 }
1192
1193 pub fn unread_snapshot(&self) -> std::collections::HashMap<String, u32> {
1195 self.unread_cache.clone()
1196 }
1197
1198 pub fn count_unread_messages(&self) -> u32 {
1199 let muted_senders: std::collections::HashSet<u16> = self
1201 .chats
1202 .iter()
1203 .filter(|c| !c.is_community() && crate::notify::muted_for_chat(c))
1204 .filter_map(|c| self.interner.lookup(&c.id))
1205 .collect();
1206 let mut total_unread = 0;
1207 for chat in &self.chats {
1208 if crate::notify::ring_for_chat(chat) != crate::notify::NotifyLevel::All { continue; }
1209 let is_group = chat.is_community();
1210 if !is_group {
1211 if let Some(id) = self.interner.lookup(&chat.id) {
1212 if self.get_profile_by_id(id).is_some_and(|p| p.flags.is_blocked()) { continue; }
1213 }
1214 } else if !chat_has_a_row(chat) {
1215 continue;
1216 }
1217 let mut unread_count = 0u32;
1218 for msg in chat.iter_compact().rev() {
1219 if msg.flags.is_mine() { break; }
1220 if chat.last_read != [0u8; 32] && msg.id == chat.last_read { break; }
1221 if is_group && msg.npub_idx != NO_NPUB {
1222 if muted_senders.contains(&msg.npub_idx) { continue; }
1223 if self.get_profile_by_id(msg.npub_idx).is_some_and(|p| p.flags.is_blocked()) { continue; }
1224 }
1225 unread_count += 1;
1226 }
1227 #[cfg(debug_assertions)]
1229 if unread_count > 0 {
1230 let last_read_hex = crate::compact::decode_message_id(&chat.last_read);
1231 let last_msg_hex = chat.messages.last().map(|m| crate::compact::decode_message_id(&m.id)).unwrap_or_default();
1232 let msg_count = chat.message_count();
1233 eprintln!("[Unread] chat={} unread={} msgs_in_memory={} last_read={} last_msg={}",
1234 &chat.id[..20.min(chat.id.len())], unread_count, msg_count,
1235 &last_read_hex[..16.min(last_read_hex.len())], &last_msg_hex[..16.min(last_msg_hex.len())]);
1236 }
1237 total_unread += unread_count;
1238 }
1239 total_unread
1240 }
1241
1242 pub fn update_typing_and_get_active(&mut self, chat_id: &str, npub: &str, expires_at: u64) -> Vec<String> {
1247 let handle = self.interner.intern(npub);
1248 if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
1249 chat.update_typing_participant(handle, expires_at);
1250 chat.get_active_typers(&self.interner)
1251 } else {
1252 Vec::new()
1253 }
1254 }
1255}
1256
1257impl Default for ChatState {
1258 fn default() -> Self { Self::new() }
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 use super::*;
1264 use crate::types::Message;
1265 use crate::profile::{Profile, SlimProfile, Status};
1266 use crate::simd::hex::bytes_to_hex_32;
1267
1268 fn make_hex_id(seed: u8) -> String {
1275 let mut bytes = [seed; 32];
1276 bytes[0] = seed.wrapping_add(0x10) | 0x10; bytes[1] = seed.wrapping_mul(37);
1278 bytes_to_hex_32(&bytes)
1279 }
1280
1281 fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
1283 Message {
1284 id: make_hex_id(id_seed),
1285 content: content.to_string(),
1286 at: timestamp_ms,
1287 mine,
1288 ..Default::default()
1289 }
1290 }
1291
1292 fn make_message_from(id_seed: u8, content: &str, timestamp_ms: u64, npub: &str) -> Message {
1294 Message {
1295 id: make_hex_id(id_seed),
1296 content: content.to_string(),
1297 at: timestamp_ms,
1298 mine: false,
1299 npub: Some(npub.to_string()),
1300 ..Default::default()
1301 }
1302 }
1303
1304 fn make_slim_profile(id: &str, name: &str) -> SlimProfile {
1306 SlimProfile {
1307 id: id.to_string(),
1308 name: name.to_string(),
1309 display_name: String::new(),
1310 nickname: String::new(),
1311 lud06: String::new(),
1312 lud16: String::new(),
1313 banner: String::new(),
1314 avatar: String::new(),
1315 about: String::new(),
1316 website: String::new(),
1317 nip05: String::new(),
1318 status: Status::new(),
1319 last_updated: 0,
1320 mine: false,
1321 bot: false,
1322 is_blocked: false,
1323 avatar_cached: String::new(),
1324 banner_cached: String::new(),
1325 }
1326 }
1327
1328 #[test]
1333 fn insert_or_replace_profile_creates_new() {
1334 let mut state = ChatState::new();
1335 let profile = Profile::new();
1336 state.insert_or_replace_profile("npub1alice", profile);
1337
1338 assert!(
1339 state.get_profile("npub1alice").is_some(),
1340 "newly inserted profile should be retrievable"
1341 );
1342 assert_eq!(state.profiles.len(), 1, "should have exactly one profile");
1343 }
1344
1345 #[test]
1346 fn insert_or_replace_profile_updates_existing() {
1347 let mut state = ChatState::new();
1348 let mut p1 = Profile::new();
1349 p1.name = "Alice".to_string().into_boxed_str();
1350 state.insert_or_replace_profile("npub1alice", p1);
1351
1352 let mut p2 = Profile::new();
1353 p2.name = "Alice Updated".to_string().into_boxed_str();
1354 state.insert_or_replace_profile("npub1alice", p2);
1355
1356 let fetched = state.get_profile("npub1alice").expect("profile should exist");
1357 assert_eq!(
1358 &*fetched.name, "Alice Updated",
1359 "profile name should be updated after replace"
1360 );
1361 assert_eq!(state.profiles.len(), 1, "should still be one profile, not duplicated");
1362 }
1363
1364 #[test]
1365 fn get_profile_by_npub() {
1366 let mut state = ChatState::new();
1367 let mut profile = Profile::new();
1368 profile.name = "Bob".to_string().into_boxed_str();
1369 state.insert_or_replace_profile("npub1bob", profile);
1370
1371 let fetched = state.get_profile("npub1bob").expect("profile should be found");
1372 assert_eq!(&*fetched.name, "Bob", "fetched profile name should match");
1373 }
1374
1375 #[test]
1376 fn get_profile_returns_none_for_unknown() {
1377 let state = ChatState::new();
1378 assert!(
1379 state.get_profile("npub1unknown").is_none(),
1380 "unknown npub should return None"
1381 );
1382 }
1383
1384 #[test]
1385 fn get_profile_by_id_works() {
1386 let mut state = ChatState::new();
1387 let mut profile = Profile::new();
1388 profile.name = "Charlie".to_string().into_boxed_str();
1389 state.insert_or_replace_profile("npub1charlie", profile);
1390
1391 let id = state.interner.lookup("npub1charlie").expect("npub should be interned");
1392 let fetched = state.get_profile_by_id(id).expect("profile should be found by id");
1393 assert_eq!(&*fetched.name, "Charlie", "profile looked up by id should match");
1394 }
1395
1396 #[test]
1397 fn get_profile_by_id_returns_none_for_invalid() {
1398 let state = ChatState::new();
1399 assert!(
1400 state.get_profile_by_id(9999).is_none(),
1401 "invalid interner id should return None"
1402 );
1403 }
1404
1405 #[test]
1406 fn merge_db_profiles_sets_mine_flag() {
1407 let mut state = ChatState::new();
1408 let slim_mine = make_slim_profile("npub1me", "Me");
1409 let slim_other = make_slim_profile("npub1other", "Other");
1410
1411 state.merge_db_profiles(vec![slim_mine, slim_other], "npub1me");
1412
1413 let me = state.get_profile("npub1me").expect("my profile should exist");
1414 assert!(me.flags.is_mine(), "my profile should have mine flag set");
1415
1416 let other = state.get_profile("npub1other").expect("other profile should exist");
1417 assert!(!other.flags.is_mine(), "other profile should not have mine flag");
1418 }
1419
1420 #[test]
1421 fn serialize_profile_roundtrip() {
1422 let mut state = ChatState::new();
1423 let mut profile = Profile::new();
1424 profile.name = "Roundtrip".to_string().into_boxed_str();
1425 profile.about = "Test about".to_string().into_boxed_str();
1426 profile.flags.set_blocked(true);
1427 state.insert_or_replace_profile("npub1round", profile);
1428
1429 let id = state.interner.lookup("npub1round").unwrap();
1430 let slim = state.serialize_profile(id).expect("serialization should succeed");
1431
1432 assert_eq!(slim.id, "npub1round", "serialized id should match");
1433 assert_eq!(slim.name, "Roundtrip", "serialized name should match");
1434 assert_eq!(slim.about, "Test about", "serialized about should match");
1435 assert!(slim.is_blocked, "serialized blocked flag should be true");
1436
1437 let restored = slim.to_profile();
1439 assert_eq!(&*restored.name, "Roundtrip", "restored name should match");
1440 assert!(restored.flags.is_blocked(), "restored blocked flag should be true");
1441 }
1442
1443 #[test]
1444 fn binary_search_maintains_sorted_order_with_100_profiles() {
1445 let mut state = ChatState::new();
1446
1447 let npubs: Vec<String> = (0..100).map(|i| format!("npub1user{:04}", i)).collect();
1449 let mut shuffled = npubs.clone();
1450 for i in (1..shuffled.len()).rev() {
1452 let j = (i * 37 + 13) % (i + 1);
1453 shuffled.swap(i, j);
1454 }
1455
1456 for npub in &shuffled {
1457 let mut profile = Profile::new();
1458 profile.name = npub.clone().into_boxed_str();
1459 state.insert_or_replace_profile(npub, profile);
1460 }
1461
1462 for npub in &npubs {
1464 assert!(
1465 state.get_profile(npub).is_some(),
1466 "profile {} should be retrievable after bulk insert",
1467 npub
1468 );
1469 }
1470
1471 for window in state.profiles.windows(2) {
1473 assert!(
1474 window[0].id < window[1].id,
1475 "profiles should be sorted by interner id"
1476 );
1477 }
1478
1479 assert_eq!(state.profiles.len(), 100, "should have exactly 100 profiles");
1480 }
1481
1482 #[test]
1483 fn insert_same_npub_twice_updates_not_duplicates() {
1484 let mut state = ChatState::new();
1485
1486 for i in 0..5 {
1487 let mut profile = Profile::new();
1488 profile.name = format!("version_{}", i).into_boxed_str();
1489 state.insert_or_replace_profile("npub1repeated", profile);
1490 }
1491
1492 assert_eq!(state.profiles.len(), 1, "repeated inserts should not create duplicates");
1493 let p = state.get_profile("npub1repeated").unwrap();
1494 assert_eq!(&*p.name, "version_4", "should retain the last update");
1495 }
1496
1497 #[test]
1498 fn get_profile_mut_modifies_in_place() {
1499 let mut state = ChatState::new();
1500 let profile = Profile::new();
1501 state.insert_or_replace_profile("npub1mutable", profile);
1502
1503 let p = state.get_profile_mut("npub1mutable").expect("profile should exist");
1504 p.name = "Mutated".to_string().into_boxed_str();
1505
1506 let fetched = state.get_profile("npub1mutable").unwrap();
1507 assert_eq!(&*fetched.name, "Mutated", "mutation should persist");
1508 }
1509
1510 #[test]
1515 fn create_dm_chat_creates_new() {
1516 let mut state = ChatState::new();
1517 let id = state.create_dm_chat("npub1peer");
1518
1519 assert_eq!(id, "npub1peer", "returned id should match the npub");
1520 assert!(state.get_chat("npub1peer").is_some(), "chat should be created");
1521 assert_eq!(state.chats.len(), 1, "should have exactly one chat");
1522 }
1523
1524 #[test]
1525 fn create_dm_chat_is_idempotent() {
1526 let mut state = ChatState::new();
1527 state.create_dm_chat("npub1peer");
1528 state.create_dm_chat("npub1peer");
1529 state.create_dm_chat("npub1peer");
1530
1531 assert_eq!(state.chats.len(), 1, "repeated creates should not duplicate");
1532 }
1533
1534 #[test]
1535 fn ensure_community_chat_idempotent() {
1536 let mut state = ChatState::new();
1537 state.ensure_community_chat("grp1");
1538 state.ensure_community_chat("grp1");
1539
1540 assert_eq!(state.chats.len(), 1, "second call should not create a duplicate");
1541 let chat = state.get_chat("grp1").expect("community chat should exist");
1542 assert!(chat.is_community(), "should be a Community chat");
1543 }
1544
1545 #[test]
1546 fn get_chat_by_id() {
1547 let mut state = ChatState::new();
1548 state.create_dm_chat("npub1x");
1549
1550 let chat = state.get_chat("npub1x").expect("chat should exist");
1551 assert_eq!(chat.id, "npub1x", "chat id should match");
1552 }
1553
1554 #[test]
1555 fn get_chat_returns_none_for_missing() {
1556 let state = ChatState::new();
1557 assert!(state.get_chat("nonexistent").is_none(), "missing chat should return None");
1558 }
1559
1560 #[test]
1561 fn get_chat_mut_modifies_in_place() {
1562 let mut state = ChatState::new();
1563 state.create_dm_chat("npub1editable");
1564
1565 let chat = state.get_chat_mut("npub1editable").expect("chat should exist");
1566 chat.muted = true;
1567
1568 let refetched = state.get_chat("npub1editable").unwrap();
1569 assert!(refetched.muted, "muted flag should persist after mutation");
1570 }
1571
1572 #[test]
1573 fn multiple_different_chats() {
1574 let mut state = ChatState::new();
1575 state.create_dm_chat("npub1alice");
1576 state.create_dm_chat("npub1bob");
1577 state.ensure_community_chat("grp1");
1578
1579 assert_eq!(state.chats.len(), 3, "should have three distinct chats");
1580 }
1581
1582 #[test]
1587 fn add_message_to_chat_single() {
1588 let mut state = ChatState::new();
1589 state.create_dm_chat("npub1peer");
1590
1591 let msg = make_message(1, "hello", 1700000000000, false);
1592 let added = state.add_message_to_chat("npub1peer", &msg);
1593
1594 assert!(added, "first message should be added successfully");
1595 let chat = state.get_chat("npub1peer").unwrap();
1596 assert_eq!(chat.message_count(), 1, "chat should have one message");
1597 }
1598
1599 #[test]
1600 fn add_message_to_chat_dedup_rejects_same_id() {
1601 let mut state = ChatState::new();
1602 state.create_dm_chat("npub1peer");
1603
1604 let msg1 = make_message(1, "hello", 1700000000000, false);
1605 let msg2 = make_message(1, "duplicate", 1700000001000, false);
1606
1607 let added1 = state.add_message_to_chat("npub1peer", &msg1);
1608 let added2 = state.add_message_to_chat("npub1peer", &msg2);
1609
1610 assert!(added1, "first insert should succeed");
1611 assert!(!added2, "duplicate ID should be rejected");
1612 assert_eq!(
1613 state.get_chat("npub1peer").unwrap().message_count(), 1,
1614 "should still have only one message"
1615 );
1616 }
1617
1618 #[test]
1619 fn add_messages_to_chat_batch_works() {
1620 let mut state = ChatState::new();
1621 state.create_dm_chat("npub1peer");
1622
1623 let msgs: Vec<Message> = (0..10).map(|i| {
1624 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1625 }).collect();
1626
1627 let added = state.add_messages_to_chat_batch("npub1peer", msgs);
1628 assert_eq!(added, 10, "all 10 messages should be added");
1629 assert_eq!(
1630 state.get_chat("npub1peer").unwrap().message_count(), 10,
1631 "chat should have 10 messages"
1632 );
1633 }
1634
1635 #[test]
1636 fn add_messages_to_chat_batch_dedup() {
1637 let mut state = ChatState::new();
1638 state.create_dm_chat("npub1peer");
1639
1640 let msgs1: Vec<Message> = (0..5).map(|i| {
1642 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1643 }).collect();
1644 state.add_messages_to_chat_batch("npub1peer", msgs1);
1645
1646 let msgs2: Vec<Message> = (3..8).map(|i| {
1648 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1649 }).collect();
1650 let added = state.add_messages_to_chat_batch("npub1peer", msgs2);
1651
1652 assert_eq!(added, 3, "only 3 new messages should be added (5, 6, 7)");
1653 assert_eq!(
1654 state.get_chat("npub1peer").unwrap().message_count(), 8,
1655 "total should be 8 unique messages"
1656 );
1657 }
1658
1659 #[test]
1660 fn add_message_to_participant_creates_profile_and_chat() {
1661 let mut state = ChatState::new();
1662
1663 let msg = make_message(1, "hi there", 1700000000000, false);
1664 let added = state.add_message_to_participant("npub1stranger", &msg);
1665
1666 assert!(added, "message should be added");
1667 assert!(
1668 state.get_profile("npub1stranger").is_some(),
1669 "profile should be auto-created for unknown participant"
1670 );
1671 assert!(
1672 state.get_chat("npub1stranger").is_some(),
1673 "DM chat should be auto-created"
1674 );
1675 }
1676
1677 #[test]
1678 fn add_message_to_participant_uses_existing_profile() {
1679 let mut state = ChatState::new();
1680
1681 let mut profile = Profile::new();
1683 profile.name = "Known User".to_string().into_boxed_str();
1684 state.insert_or_replace_profile("npub1known", profile);
1685
1686 let msg = make_message(1, "hello", 1700000000000, false);
1687 state.add_message_to_participant("npub1known", &msg);
1688
1689 let p = state.get_profile("npub1known").unwrap();
1691 assert_eq!(&*p.name, "Known User", "existing profile should not be overwritten");
1692 }
1693
1694 #[test]
1695 fn find_message_across_chats() {
1696 let mut state = ChatState::new();
1697 state.create_dm_chat("npub1a");
1698 state.create_dm_chat("npub1b");
1699
1700 let msg_a = make_message(1, "in chat a", 1700000000000, false);
1701 let msg_b = make_message(2, "in chat b", 1700000001000, false);
1702 let msg_id_b = msg_b.id.clone();
1703
1704 state.add_message_to_chat("npub1a", &msg_a);
1705 state.add_message_to_chat("npub1b", &msg_b);
1706
1707 let (chat, found_msg) = state.find_message(&msg_id_b).expect("message should be found");
1708 assert_eq!(chat.id, "npub1b", "should find in correct chat");
1709 assert_eq!(found_msg.content, "in chat b", "content should match");
1710 }
1711
1712 #[test]
1713 fn find_message_returns_none_for_unknown() {
1714 let state = ChatState::new();
1715 assert!(
1716 state.find_message(&make_hex_id(99)).is_none(),
1717 "unknown message id should return None"
1718 );
1719 }
1720
1721 #[test]
1722 fn find_message_empty_id_returns_none() {
1723 let state = ChatState::new();
1724 assert!(state.find_message("").is_none(), "empty id should return None");
1725 }
1726
1727 #[test]
1728 fn update_message_mutates_and_returns() {
1729 let mut state = ChatState::new();
1730 state.create_dm_chat("npub1peer");
1731
1732 let msg = make_message(1, "original", 1700000000000, false);
1733 let msg_id = msg.id.clone();
1734 state.add_message_to_chat("npub1peer", &msg);
1735
1736 let result = state.update_message(&msg_id, |cm| {
1737 cm.content = "updated content".to_string().into_boxed_str();
1738 });
1739
1740 let (chat_id, updated) = result.expect("update should return Some");
1741 assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1742 assert_eq!(updated.content, "updated content", "content should be updated");
1743 }
1744
1745 #[test]
1746 fn update_message_returns_none_for_missing() {
1747 let mut state = ChatState::new();
1748 let result = state.update_message(&make_hex_id(99), |_cm| {});
1749 assert!(result.is_none(), "updating nonexistent message should return None");
1750 }
1751
1752 #[test]
1753 fn finalize_pending_message_changes_id() {
1754 let mut state = ChatState::new();
1755 state.create_dm_chat("npub1peer");
1756
1757 let mut msg = make_message(1, "pending msg", 1700000000000, true);
1758 msg.pending = true;
1759 let pending_id = msg.id.clone();
1760 state.add_message_to_chat("npub1peer", &msg);
1761
1762 let real_id = make_hex_id(2);
1763 let result = state.finalize_pending_message("npub1peer", &pending_id, &real_id);
1764
1765 let (old_id, finalized) = result.expect("finalize should succeed");
1766 assert_eq!(old_id, pending_id, "should return old pending id");
1767 assert_eq!(finalized.id, real_id, "message id should now be the real id");
1768 assert!(!finalized.pending, "message should no longer be pending");
1769
1770 assert!(
1772 state.find_message(&pending_id).is_none(),
1773 "pending id should no longer resolve"
1774 );
1775 assert!(
1777 state.find_message(&real_id).is_some(),
1778 "real id should now resolve"
1779 );
1780 }
1781
1782 #[test]
1783 fn remove_message_works() {
1784 let mut state = ChatState::new();
1785 state.create_dm_chat("npub1peer");
1786
1787 let msg = make_message(1, "deleteme", 1700000000000, false);
1788 let msg_id = msg.id.clone();
1789 state.add_message_to_chat("npub1peer", &msg);
1790
1791 let result = state.remove_message(&msg_id);
1792 assert!(result.is_some(), "remove should return the removed message");
1793
1794 let (chat_id, removed) = result.unwrap();
1795 assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1796 assert_eq!(removed.content, "deleteme", "content should match");
1797
1798 assert!(
1799 state.find_message(&msg_id).is_none(),
1800 "removed message should no longer be findable"
1801 );
1802 }
1803
1804 #[test]
1805 fn remove_message_retreats_last_read_marker() {
1806 let mut state = ChatState::new();
1807 state.create_dm_chat("npub1peer");
1808 let m1 = make_message(1, "one", 1_700_000_000_000, false);
1809 let m2 = make_message(2, "two", 1_700_000_001_000, false);
1810 let (m1_id, m2_id) = (m1.id.clone(), m2.id.clone());
1811 state.add_message_to_chat("npub1peer", &m1);
1812 state.add_message_to_chat("npub1peer", &m2);
1813
1814 state.chats.iter_mut().find(|c| c.id == "npub1peer").unwrap().last_read =
1816 crate::compact::encode_message_id(&m2_id);
1817
1818 state.remove_message(&m2_id);
1820 assert_eq!(state.get_chat("npub1peer").unwrap().last_read,
1821 crate::compact::encode_message_id(&m1_id), "marker retreats to m1");
1822
1823 state.remove_message(&m1_id);
1825 assert_eq!(state.get_chat("npub1peer").unwrap().last_read, [0u8; 32],
1826 "no predecessor → marker clears");
1827 }
1828
1829 #[test]
1830 fn remove_message_returns_none_for_missing() {
1831 let mut state = ChatState::new();
1832 assert!(
1833 state.remove_message(&make_hex_id(99)).is_none(),
1834 "removing nonexistent message should return None"
1835 );
1836 }
1837
1838 #[test]
1839 fn message_exists_check() {
1840 let mut state = ChatState::new();
1841 state.create_dm_chat("npub1peer");
1842
1843 let msg = make_message(1, "exists", 1700000000000, false);
1844 let msg_id = msg.id.clone();
1845 state.add_message_to_chat("npub1peer", &msg);
1846
1847 assert!(state.message_exists(&msg_id), "added message should exist");
1848 assert!(!state.message_exists(&make_hex_id(99)), "unknown id should not exist");
1849 assert!(!state.message_exists(""), "empty id should not exist");
1850 }
1851
1852 #[test]
1853 fn chat_reordering_newest_first_after_message_add() {
1854 let mut state = ChatState::new();
1855 state.create_dm_chat("npub1old");
1856 state.create_dm_chat("npub1new");
1857
1858 let old_msg = make_message(1, "old", 1700000000000, false);
1860 state.add_message_to_chat("npub1old", &old_msg);
1861
1862 let new_msg = make_message(2, "new", 1700000002000, false);
1864 state.add_message_to_chat("npub1new", &new_msg);
1865
1866 assert_eq!(
1867 state.chats[0].id, "npub1new",
1868 "chat with newest message should be first"
1869 );
1870 assert_eq!(
1871 state.chats[1].id, "npub1old",
1872 "chat with older message should be second"
1873 );
1874 }
1875
1876 #[test]
1877 fn batch_add_does_not_reorder_for_old_messages() {
1878 let mut state = ChatState::new();
1879 state.create_dm_chat("npub1active");
1880 state.create_dm_chat("npub1history");
1881
1882 let recent = make_message(1, "recent", 1700000010000, false);
1884 state.add_message_to_chat("npub1active", &recent);
1885
1886 let old_msgs: Vec<Message> = (10..15).map(|i| {
1888 make_message(i, &format!("old {}", i), 1700000000000 + i as u64 * 100, false)
1889 }).collect();
1890 state.add_messages_to_chat_batch("npub1history", old_msgs);
1891
1892 assert_eq!(
1893 state.chats[0].id, "npub1active",
1894 "active chat should remain first when batch has only old messages"
1895 );
1896 }
1897
1898 #[test]
1899 fn stress_test_50_messages_in_5_chats() {
1900 let mut state = ChatState::new();
1901
1902 for i in 0..5 {
1903 state.create_dm_chat(&format!("npub1chat{}", i));
1904 }
1905
1906 let mut total_added = 0;
1907 for i in 0..50u8 {
1908 let chat_idx = i as usize % 5;
1909 let chat_id = format!("npub1chat{}", chat_idx);
1910 let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, i % 3 == 0);
1911 if state.add_message_to_chat(&chat_id, &msg) {
1912 total_added += 1;
1913 }
1914 }
1915
1916 assert_eq!(total_added, 50, "all 50 unique messages should be added");
1917
1918 let total_in_chats: usize = state.chats.iter().map(|c| c.message_count()).sum();
1919 assert_eq!(total_in_chats, 50, "total messages across all chats should be 50");
1920
1921 for i in 0..5 {
1923 let chat = state.get_chat(&format!("npub1chat{}", i)).unwrap();
1924 assert_eq!(
1925 chat.message_count(), 10,
1926 "chat {} should have 10 messages",
1927 i
1928 );
1929 }
1930
1931 for i in 0..50u8 {
1933 assert!(
1934 state.message_exists(&make_hex_id(i)),
1935 "message {} should exist",
1936 i
1937 );
1938 }
1939 }
1940
1941 #[test]
1942 fn add_message_auto_creates_dm_chat() {
1943 let mut state = ChatState::new();
1944
1945 let msg = make_message(1, "auto create", 1700000000000, false);
1947 let added = state.add_message_to_chat("npub1auto", &msg);
1948
1949 assert!(added, "message should be added");
1950 assert!(state.get_chat("npub1auto").is_some(), "DM chat should be auto-created");
1951 }
1952
1953 #[test]
1954 fn add_message_auto_creates_community_chat() {
1955 let mut state = ChatState::new();
1956
1957 let msg = make_message(1, "group msg", 1700000000000, false);
1959 let added = state.add_message_to_chat("group_abc123", &msg);
1960
1961 assert!(added, "message should be added");
1962 let chat = state.get_chat("group_abc123").expect("community chat should be auto-created");
1963 assert!(chat.is_community(), "auto-created non-npub chat should be a Community chat");
1964 }
1965
1966 #[test]
1971 fn count_unread_messages_basic() {
1972 let mut state = ChatState::new();
1973 state.create_dm_chat("npub1peer");
1974
1975 for i in 0..5u8 {
1976 let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false);
1977 state.add_message_to_chat("npub1peer", &msg);
1978 }
1979
1980 assert_eq!(state.count_unread_messages(), 5, "all 5 non-mine messages should be unread");
1981 }
1982
1983 #[test]
1984 fn count_unread_muted_chat_skipped() {
1985 let mut state = ChatState::new();
1986 state.create_dm_chat("npub1muted");
1987
1988 let msg = make_message(1, "muted msg", 1700000000000, false);
1989 state.add_message_to_chat("npub1muted", &msg);
1990
1991 state.get_chat_mut("npub1muted").unwrap().muted = true;
1992
1993 assert_eq!(state.count_unread_messages(), 0, "muted chat should not count toward unread");
1994 }
1995
1996 #[test]
1997 fn count_unread_blocked_user_skipped() {
1998 let mut state = ChatState::new();
1999
2000 let mut profile = Profile::new();
2001 profile.flags.set_blocked(true);
2002 state.insert_or_replace_profile("npub1blocked", profile);
2003 state.create_dm_chat("npub1blocked");
2004
2005 let msg = make_message(1, "blocked msg", 1700000000000, false);
2006 state.add_message_to_chat("npub1blocked", &msg);
2007
2008 assert_eq!(state.count_unread_messages(), 0, "blocked user DM should not count");
2009 }
2010
2011 #[test]
2012 fn unread_cache_seed_clear_set_and_sum() {
2013 let mut state = ChatState::new();
2014 state.create_dm_chat("npub1a");
2015 state.create_dm_chat("npub1b");
2016
2017 let mut seed = std::collections::HashMap::new();
2018 seed.insert("npub1a".to_string(), 3u32);
2019 seed.insert("npub1b".to_string(), 2u32);
2020 state.unread_seed(seed);
2021 assert!(state.unread_seeded);
2022 assert_eq!(state.sum_unread(), 5);
2023
2024 state.unread_clear("npub1a");
2025 assert_eq!(state.sum_unread(), 2, "clear drops a's 3");
2026
2027 state.unread_set("npub1b", 4);
2028 assert_eq!(state.sum_unread(), 4, "reconcile b to exact 4");
2029 state.unread_set("npub1b", 0);
2030 assert_eq!(state.sum_unread(), 0);
2031 assert!(!state.unread_cache.contains_key("npub1b"), "a zero count drops the entry");
2032 }
2033
2034 #[test]
2035 fn unread_cache_sum_honours_muted_and_blocked() {
2036 let mut state = ChatState::new();
2037 let mut blocked = Profile::new();
2038 blocked.flags.set_blocked(true);
2039 state.insert_or_replace_profile("npub1blk", blocked);
2040 state.create_dm_chat("npub1blk");
2041 state.create_dm_chat("npub1mut");
2042 state.get_chat_mut("npub1mut").unwrap().muted = true;
2043 state.create_dm_chat("npub1ok");
2044
2045 let mut seed = std::collections::HashMap::new();
2046 seed.insert("npub1blk".to_string(), 5u32);
2047 seed.insert("npub1mut".to_string(), 7u32);
2048 seed.insert("npub1ok".to_string(), 2u32);
2049 state.unread_seed(seed);
2050
2051 assert_eq!(state.sum_unread(), 2);
2053 }
2054
2055 #[test]
2056 fn count_unread_own_messages_break_count() {
2057 let mut state = ChatState::new();
2058 state.create_dm_chat("npub1peer");
2059
2060 let msg1 = make_message(1, "them 1", 1700000001000, false);
2062 let msg2 = make_message(2, "them 2", 1700000002000, false);
2063 let msg3 = make_message(3, "them 3", 1700000003000, false);
2064 let msg_mine = make_message(4, "me", 1700000004000, true);
2065 let msg5 = make_message(5, "them 4", 1700000005000, false);
2066 let msg6 = make_message(6, "them 5", 1700000006000, false);
2067
2068 for m in [msg1, msg2, msg3, msg_mine, msg5, msg6] {
2069 state.add_message_to_chat("npub1peer", &m);
2070 }
2071
2072 assert_eq!(
2074 state.count_unread_messages(), 2,
2075 "only messages after last 'mine' should count as unread"
2076 );
2077 }
2078
2079 #[test]
2080 fn count_unread_last_read_marker_breaks_count() {
2081 let mut state = ChatState::new();
2082 state.create_dm_chat("npub1peer");
2083
2084 let msg1 = make_message(1, "old", 1700000001000, false);
2085 let msg2 = make_message(2, "read up to here", 1700000002000, false);
2086 let msg3 = make_message(3, "new 1", 1700000003000, false);
2087 let msg4 = make_message(4, "new 2", 1700000004000, false);
2088 let read_marker_id = msg2.id.clone();
2089
2090 for m in [msg1, msg2, msg3, msg4] {
2091 state.add_message_to_chat("npub1peer", &m);
2092 }
2093
2094 let chat = state.get_chat_mut("npub1peer").unwrap();
2096 chat.last_read = crate::simd::hex::hex_to_bytes_32(&read_marker_id);
2097
2098 assert_eq!(
2099 state.count_unread_messages(), 2,
2100 "only messages after last_read marker should count"
2101 );
2102 }
2103
2104 #[test]
2105 fn count_unread_empty_chats_is_zero() {
2106 let mut state = ChatState::new();
2107 state.create_dm_chat("npub1empty1");
2108 state.create_dm_chat("npub1empty2");
2109
2110 assert_eq!(state.count_unread_messages(), 0, "empty chats should have zero unread");
2111 }
2112
2113 #[test]
2114 fn count_unread_blocked_group_member_messages_skipped() {
2115 let mut state = ChatState::new();
2116
2117 let mut blocked_profile = Profile::new();
2119 blocked_profile.flags.set_blocked(true);
2120 state.insert_or_replace_profile("npub1blockedmember", blocked_profile);
2121
2122 let normal_profile = Profile::new();
2124 state.insert_or_replace_profile("npub1normal", normal_profile);
2125
2126 state.ensure_community_chat("grp1");
2129 if let Some(chat) = state.chats.iter_mut().find(|c| c.id == "grp1") {
2130 chat.metadata.custom_fields.insert("community_id".to_string(), "c".repeat(64));
2131 }
2132
2133 let msg_blocked = make_message_from(1, "blocked says hi", 1700000001000, "npub1blockedmember");
2135 state.add_message_to_chat("grp1", &msg_blocked);
2136
2137 let msg_normal = make_message_from(2, "normal says hi", 1700000002000, "npub1normal");
2139 state.add_message_to_chat("grp1", &msg_normal);
2140
2141 assert_eq!(
2142 state.count_unread_messages(), 1,
2143 "only the non-blocked member's message should count"
2144 );
2145 }
2146
2147 #[test]
2148 fn count_unread_muted_sender_group_messages_skipped() {
2149 let mut state = ChatState::new();
2150 state.insert_or_replace_profile("npub1mutedmember", Profile::new());
2151 state.insert_or_replace_profile("npub1normal", Profile::new());
2152
2153 state.create_dm_chat("npub1mutedmember");
2155 state.get_chat_mut("npub1mutedmember").unwrap().muted = true;
2156
2157 state.ensure_community_chat("grp1");
2158 if let Some(chat) = state.chats.iter_mut().find(|c| c.id == "grp1") {
2159 chat.metadata.custom_fields.insert("community_id".to_string(), "c".repeat(64));
2160 }
2161
2162 let msg_muted = make_message_from(1, "muted says hi", 1700000001000, "npub1mutedmember");
2163 state.add_message_to_chat("grp1", &msg_muted);
2164 let msg_normal = make_message_from(2, "normal says hi", 1700000002000, "npub1normal");
2165 state.add_message_to_chat("grp1", &msg_normal);
2166
2167 assert_eq!(
2168 state.count_unread_messages(), 1,
2169 "a muted sender's community messages should not count"
2170 );
2171 }
2172
2173 #[test]
2174 fn count_unread_multiple_chats_summed() {
2175 let mut state = ChatState::new();
2176
2177 for i in 0..3 {
2178 let npub = format!("npub1chat{}", i);
2179 state.create_dm_chat(&npub);
2180 for j in 0..3u8 {
2181 let msg = make_message(
2182 i * 10 + j,
2183 &format!("msg {}-{}", i, j),
2184 1700000000000 + j as u64 * 1000,
2185 false,
2186 );
2187 state.add_message_to_chat(&npub, &msg);
2188 }
2189 }
2190
2191 assert_eq!(
2192 state.count_unread_messages(), 9,
2193 "3 chats x 3 unread each = 9 total"
2194 );
2195 }
2196
2197 #[test]
2202 fn update_typing_and_get_active_basic() {
2203 let mut state = ChatState::new();
2204 state.create_dm_chat("npub1peer");
2205
2206 let far_future = web_time::SystemTime::now()
2208 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs() + 300;
2209
2210 let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
2211 assert_eq!(active.len(), 1, "should have one active typer");
2212 assert_eq!(active[0], "npub1typer", "typer npub should match");
2213 }
2214
2215 #[test]
2216 fn update_typing_expired_typers_filtered() {
2217 let mut state = ChatState::new();
2218 state.create_dm_chat("npub1peer");
2219
2220 let expired = 1000;
2222 let active = state.update_typing_and_get_active("npub1peer", "npub1expired", expired);
2223
2224 assert!(active.is_empty(), "expired typer should be filtered out");
2225 }
2226
2227 #[test]
2228 fn update_typing_multiple_typers() {
2229 let mut state = ChatState::new();
2230 state.create_dm_chat("npub1peer");
2231
2232 let far_future = web_time::SystemTime::now()
2233 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs() + 300;
2234
2235 state.update_typing_and_get_active("npub1peer", "npub1typer1", far_future);
2236 let active = state.update_typing_and_get_active("npub1peer", "npub1typer2", far_future);
2237
2238 assert_eq!(active.len(), 2, "should have two active typers");
2239 }
2240
2241 #[test]
2242 fn update_typing_unknown_chat_returns_empty() {
2243 let mut state = ChatState::new();
2244 let far_future = web_time::SystemTime::now()
2245 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs() + 300;
2246
2247 let active = state.update_typing_and_get_active("npub1nonexistent", "npub1typer", far_future);
2248 assert!(active.is_empty(), "unknown chat should return empty typers");
2249 }
2250
2251 #[test]
2252 fn update_typing_refreshes_existing_typer() {
2253 let mut state = ChatState::new();
2254 state.create_dm_chat("npub1peer");
2255
2256 let far_future = web_time::SystemTime::now()
2257 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs() + 300;
2258
2259 state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
2260 let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future + 100);
2262
2263 assert_eq!(active.len(), 1, "should still have only one typer entry after refresh");
2264 }
2265
2266 #[test]
2271 fn wrapper_id_cache_historical_and_pending() {
2272 let mut cache = WrapperIdCache::new();
2273
2274 let id1 = [1u8; 32];
2275 let id2 = [2u8; 32];
2276 let id3 = [3u8; 32];
2277
2278 cache.load(vec![id1, id2]);
2279 cache.insert(id3);
2280
2281 assert!(cache.contains(&id1), "historical id should be found");
2282 assert!(cache.contains(&id2), "historical id should be found");
2283 assert!(cache.contains(&id3), "pending id should be found");
2284 assert!(!cache.contains(&[4u8; 32]), "unknown id should not be found");
2285 assert_eq!(cache.len(), 3, "total count should be 3");
2286 }
2287
2288 #[test]
2289 fn wrapper_id_cache_clear() {
2290 let mut cache = WrapperIdCache::new();
2291 cache.load(vec![[1u8; 32]]);
2292 cache.insert([2u8; 32]);
2293
2294 cache.clear();
2295
2296 assert_eq!(cache.len(), 0, "cache should be empty after clear");
2297 assert!(!cache.contains(&[1u8; 32]), "cleared historical should not be found");
2298 assert!(!cache.contains(&[2u8; 32]), "cleared pending should not be found");
2299 }
2300}