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