1use nostr_sdk::prelude::*;
7use std::sync::{OnceLock, RwLock};
8use std::collections::HashSet;
9use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
10use std::sync::LazyLock;
11use tokio::sync::Mutex;
12
13use crate::chat::{Chat, ChatType};
14use crate::compact::{CompactMessage, CompactAttachment, NpubInterner, NO_NPUB};
15use crate::profile::{Profile, SlimProfile};
16use crate::types::{Message, Reaction};
17use crate::traits::emit_event;
18
19pub struct WrapperIdCache {
24 historical: Vec<[u8; 32]>,
25 pending: HashSet<[u8; 32]>,
26}
27
28impl WrapperIdCache {
29 pub fn new() -> Self { Self { historical: Vec::new(), pending: HashSet::new() } }
30
31 pub fn load(&mut self, mut ids: Vec<[u8; 32]>) {
32 ids.sort_unstable();
33 self.historical = ids;
34 self.pending.clear();
35 }
36
37 #[inline]
38 pub fn contains(&self, id: &[u8; 32]) -> bool {
39 self.historical.binary_search(id).is_ok() || self.pending.contains(id)
40 }
41
42 #[inline]
43 pub fn insert(&mut self, id: [u8; 32]) { self.pending.insert(id); }
44
45 pub fn clear(&mut self) {
46 self.historical.clear();
47 self.historical.shrink_to_fit();
48 self.pending.clear();
49 self.pending.shrink_to_fit();
50 }
51
52 pub fn len(&self) -> usize { self.historical.len() + self.pending.len() }
53}
54
55impl Default for WrapperIdCache {
56 fn default() -> Self { Self::new() }
57}
58
59pub static TRUSTED_RELAYS: &[&str] = &[
64 "wss://jskitty.com/nostr",
65 "wss://asia.vectorapp.io/nostr",
66 "wss://nostr.computingcache.com",
67];
68
69pub async fn active_trusted_relays() -> Vec<&'static str> {
70 let Some(client) = nostr_client() else { return Vec::new() };
71 let pool_relays = client.relays().await;
72 TRUSTED_RELAYS.iter().copied()
73 .filter(|url| {
74 let normalized = url.trim_end_matches('/');
75 pool_relays.keys().any(|r| r.as_str().trim_end_matches('/') == normalized)
76 })
77 .collect()
78}
79
80pub static BLOSSOM_SERVERS: OnceLock<std::sync::Mutex<Vec<String>>> = OnceLock::new();
84
85pub fn init_blossom_servers() -> Vec<String> {
86 crate::blossom_servers::DEFAULT_BLOSSOM_SERVERS
87 .iter().map(|s| s.to_string()).collect()
88}
89
90pub fn get_blossom_servers() -> Vec<String> {
91 BLOSSOM_SERVERS
92 .get_or_init(|| std::sync::Mutex::new(init_blossom_servers()))
93 .lock().unwrap().clone()
94}
95
96pub static MNEMONIC_SEED: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
97pub static PENDING_NSEC: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
98
99pub static PENDING_BUNKER_SETUP:
108 std::sync::Mutex<Option<(zeroize::Zeroizing<String>, String)>> =
109 std::sync::Mutex::new(None);
110
111#[inline]
112pub fn set_pending_bunker_setup(url: String, remote_pk_hex: String) {
113 *PENDING_BUNKER_SETUP.lock().unwrap() =
114 Some((zeroize::Zeroizing::new(url), remote_pk_hex));
115}
116
117#[inline]
118pub fn pending_bunker_setup() -> Option<(String, String)> {
119 PENDING_BUNKER_SETUP.lock().unwrap()
120 .as_ref()
121 .map(|(z, pk)| (String::clone(&**z), pk.clone()))
122}
123
124#[inline]
125pub fn clear_pending_bunker_setup() {
126 *PENDING_BUNKER_SETUP.lock().unwrap() = None;
127}
128
129pub static ENCRYPTION_KEY: crate::crypto::GuardedKey = crate::crypto::GuardedKey::empty();
130
131pub static ENCRYPTION_ENABLED: AtomicBool = AtomicBool::new(false);
132
133#[inline]
134pub fn is_encryption_enabled_fast() -> bool { ENCRYPTION_ENABLED.load(Ordering::Acquire) }
135
136#[inline]
137pub fn set_encryption_enabled(enabled: bool) { ENCRYPTION_ENABLED.store(enabled, Ordering::Release); }
138
139pub fn resolve_encryption_enabled(
153 encryption_enabled_row: Option<&str>,
154 security_type_row: Option<&str>,
155) -> bool {
156 match encryption_enabled_row {
157 Some("false") => false,
158 Some(_) => true,
159 None => security_type_row.is_some(),
160 }
161}
162
163pub fn resolve_encryption_enabled_from_db() -> bool {
166 let enc = crate::db::get_sql_setting("encryption_enabled".to_string()).ok().flatten();
167 let sec = crate::db::get_sql_setting("security_type".to_string()).ok().flatten();
168 resolve_encryption_enabled(enc.as_deref(), sec.as_deref())
169}
170
171pub fn init_encryption_enabled() {
172 let enabled = resolve_encryption_enabled_from_db();
173 set_encryption_enabled(enabled);
174}
175
176#[cfg(test)]
177mod resolve_encryption_enabled_tests {
178 use super::*;
179
180 #[test]
181 fn explicit_false_wins_even_with_security_type() {
182 assert!(!resolve_encryption_enabled(Some("false"), Some("password")));
183 }
184
185 #[test]
186 fn explicit_true_is_encrypted() {
187 assert!(resolve_encryption_enabled(Some("true"), None));
188 }
189
190 #[test]
191 fn missing_row_defaults_to_security_type_presence() {
192 assert!(resolve_encryption_enabled(None, Some("password")));
195 assert!(!resolve_encryption_enabled(None, None));
197 }
198
199 #[test]
200 fn explicit_non_false_value_is_encrypted() {
201 assert!(resolve_encryption_enabled(Some("1"), None));
204 assert!(resolve_encryption_enabled(Some(""), None));
205 }
206}
207
208pub static NOSTR_CLIENT: LazyLock<RwLock<Option<Client>>> =
217 LazyLock::new(|| RwLock::new(None));
218
219pub static MY_SECRET_KEY: crate::crypto::GuardedKey = crate::crypto::GuardedKey::empty();
220
221pub static MY_PUBLIC_KEY: LazyLock<RwLock<Option<PublicKey>>> =
222 LazyLock::new(|| RwLock::new(None));
223
224#[inline]
228pub fn nostr_client() -> Option<Client> {
229 NOSTR_CLIENT.read().unwrap().as_ref().cloned()
230}
231
232#[inline]
234pub fn has_active_session() -> bool {
235 NOSTR_CLIENT.read().unwrap().is_some()
236}
237
238#[inline]
240pub fn my_public_key() -> Option<PublicKey> {
241 *MY_PUBLIC_KEY.read().unwrap()
242}
243
244#[inline]
248pub fn set_nostr_client(client: Client) {
249 *NOSTR_CLIENT.write().unwrap() = Some(client);
250}
251
252#[inline]
254pub fn set_my_public_key(pk: PublicKey) {
255 *MY_PUBLIC_KEY.write().unwrap() = Some(pk);
256}
257
258#[inline]
262pub fn take_nostr_client() -> Option<Client> {
263 NOSTR_CLIENT.write().unwrap().take()
264}
265
266#[inline]
269pub fn clear_my_public_key() {
270 *MY_PUBLIC_KEY.write().unwrap() = None;
271}
272
273#[derive(Clone)]
274pub struct PendingInviteAcceptance {
275 pub invite_code: String,
276 pub inviter_pubkey: PublicKey,
277}
278
279pub static PENDING_INVITE: LazyLock<RwLock<Option<PendingInviteAcceptance>>> =
283 LazyLock::new(|| RwLock::new(None));
284
285#[inline]
286pub fn pending_invite() -> Option<PendingInviteAcceptance> {
287 PENDING_INVITE.read().unwrap().clone()
288}
289
290#[inline]
291pub fn set_pending_invite(invite: PendingInviteAcceptance) {
292 *PENDING_INVITE.write().unwrap() = Some(invite);
293}
294
295#[inline]
296pub fn clear_pending_invite() {
297 *PENDING_INVITE.write().unwrap() = None;
298}
299
300pub static NOTIFIED_WELCOMES: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
301
302static SESSION_GENERATION: AtomicU64 = AtomicU64::new(0);
317
318#[inline]
320pub fn current_session_generation() -> u64 {
321 SESSION_GENERATION.load(Ordering::Acquire)
322}
323
324#[inline]
328pub fn bump_session_generation() -> u64 {
329 SESSION_GENERATION.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
330}
331
332#[derive(Copy, Clone, Debug)]
338pub struct SessionGuard {
339 generation: u64,
340}
341
342impl SessionGuard {
343 #[inline]
345 pub fn capture() -> Self {
346 Self { generation: current_session_generation() }
347 }
348
349 #[inline]
353 pub fn is_valid(&self) -> bool {
354 self.generation == current_session_generation()
355 }
356
357 #[inline]
359 pub fn generation(&self) -> u64 {
360 self.generation
361 }
362}
363
364#[cfg(test)]
365mod session_generation_tests {
366 use super::*;
367
368 #[test]
369 fn guard_is_valid_when_no_swap_occurred() {
370 let guard = SessionGuard::capture();
371 assert!(guard.is_valid());
372 }
373
374 #[test]
375 fn guard_invalidates_after_bump() {
376 let guard = SessionGuard::capture();
377 bump_session_generation();
378 assert!(!guard.is_valid(), "guard must invalidate after a swap");
379 }
380
381 #[test]
382 fn bump_advances_counter_monotonically() {
383 let before = current_session_generation();
384 let after = bump_session_generation();
385 assert_eq!(after, before.wrapping_add(1));
386 assert_eq!(current_session_generation(), after);
387 }
388}
389
390#[cfg(test)]
391mod session_globals_tests {
392 use super::*;
393
394 #[test]
398 fn session_helpers_round_trip_and_clear() {
399 clear_my_public_key();
403 clear_pending_invite();
404
405 let keys = Keys::parse(
410 "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
411 ).expect("parse test nsec");
412 let pk = keys.public_key;
413
414 assert_eq!(my_public_key(), None, "starts as None");
415
416 set_my_public_key(pk);
417 assert_eq!(my_public_key(), Some(pk));
418
419 clear_my_public_key();
420 assert_eq!(my_public_key(), None, "cleared returns None");
421
422 let invite = PendingInviteAcceptance {
424 invite_code: "abc123".to_string(),
425 inviter_pubkey: pk,
426 };
427
428 assert!(pending_invite().is_none(), "starts as None");
429
430 set_pending_invite(invite.clone());
431 let got = pending_invite().expect("set then read");
432 assert_eq!(got.invite_code, invite.invite_code);
433 assert_eq!(got.inviter_pubkey, invite.inviter_pubkey);
434
435 clear_pending_invite();
436 assert!(pending_invite().is_none(), "cleared returns None");
437
438 assert!(!has_active_session(), "no client installed");
441 assert!(take_nostr_client().is_none(), "take from empty returns None");
442 assert!(!has_active_session(), "still none after take-of-empty");
443
444 clear_pending_bunker_setup();
449 assert!(pending_bunker_setup().is_none(), "starts as None");
450
451 let url = "bunker://0123456789abcdef?relay=wss%3A%2F%2Frelay.example&secret=topsecret".to_string();
452 let pk_hex = "0123456789abcdef".to_string();
453 set_pending_bunker_setup(url.clone(), pk_hex.clone());
454
455 let first = pending_bunker_setup().expect("first peek");
458 let second = pending_bunker_setup().expect("second peek");
459 assert_eq!(first.0, url, "url survives clone-out from Zeroizing");
460 assert_eq!(first.0, second.0, "successive peeks return same data");
461 assert_eq!(first.1, pk_hex);
462
463 let url2 = "bunker://feedface?relay=wss%3A%2F%2Falt".to_string();
467 set_pending_bunker_setup(url2.clone(), "feedface".to_string());
468 let after = pending_bunker_setup().expect("overwritten read");
469 assert_eq!(after.0, url2);
470 assert_eq!(after.1, "feedface");
471
472 clear_pending_bunker_setup();
473 assert!(pending_bunker_setup().is_none(), "cleared returns None");
474
475 let guard = SessionGuard::capture();
480 assert!(guard.is_valid(), "fresh capture is valid");
481 bump_session_generation();
482 assert!(!guard.is_valid(),
483 "captured guard must invalidate after generation bump");
484 }
485}
486
487pub static WRAPPER_ID_CACHE: LazyLock<Mutex<WrapperIdCache>> = LazyLock::new(|| Mutex::new(WrapperIdCache::new()));
488
489pub static STATE: LazyLock<Mutex<ChatState>> = LazyLock::new(|| Mutex::new(ChatState::new()));
490
491pub static ACTIVE_CHAT: LazyLock<RwLock<Option<String>>> =
497 LazyLock::new(|| RwLock::new(None));
498
499pub fn set_active_chat(chat_id: Option<String>) {
500 if let Ok(mut guard) = ACTIVE_CHAT.write() {
501 *guard = chat_id;
502 }
503}
504
505pub fn get_active_chat() -> Option<String> {
506 ACTIVE_CHAT.read().ok().and_then(|g| g.clone())
507}
508
509pub static PROCESSING_GATE: AtomicBool = AtomicBool::new(true);
514pub static PENDING_EVENTS: LazyLock<Mutex<Vec<(Event, bool)>>> = LazyLock::new(|| Mutex::new(Vec::new()));
515
516#[inline]
517pub fn is_processing_allowed() -> bool { PROCESSING_GATE.load(Ordering::Acquire) }
518pub fn close_processing_gate() { PROCESSING_GATE.store(false, Ordering::Release); }
519pub fn open_processing_gate() { PROCESSING_GATE.store(true, Ordering::Release); }
520
521#[derive(Clone, Debug)]
526pub struct ChatState {
527 pub profiles: Vec<Profile>,
528 pub chats: Vec<Chat>,
529 pub interner: NpubInterner,
530 pub is_syncing: bool,
531 pub db_loaded: bool,
532 #[cfg(debug_assertions)]
533 pub cache_stats: crate::stats::CacheStats,
534}
535
536impl ChatState {
537 pub fn new() -> Self {
538 Self {
539 profiles: Vec::new(),
540 chats: Vec::new(),
541 interner: NpubInterner::new(),
542 is_syncing: false,
543 db_loaded: false,
544 #[cfg(debug_assertions)]
545 cache_stats: crate::stats::CacheStats::new(),
546 }
547 }
548
549 pub fn merge_db_profiles(&mut self, slim_profiles: Vec<SlimProfile>, my_npub: &str) {
554 for slim in slim_profiles {
555 let mut full_profile = slim.to_profile();
556 full_profile.flags.set_mine(slim.id == my_npub);
557 self.insert_or_replace_profile(&slim.id, full_profile);
558 }
559 }
560
561 pub fn insert_or_replace_profile(&mut self, npub: &str, mut profile: Profile) {
562 let id = self.interner.intern(npub);
563 profile.id = id;
564 match self.profiles.binary_search_by(|p| p.id.cmp(&id)) {
565 Ok(idx) => self.profiles[idx] = profile,
566 Err(idx) => self.profiles.insert(idx, profile),
567 }
568 }
569
570 pub fn get_profile(&self, npub: &str) -> Option<&Profile> {
571 self.interner.lookup(npub).and_then(|id| self.get_profile_by_id(id))
572 }
573
574 pub fn get_profile_mut(&mut self, npub: &str) -> Option<&mut Profile> {
575 self.interner.lookup(npub).and_then(move |id| self.get_profile_mut_by_id(id))
576 }
577
578 #[inline]
579 pub fn get_profile_by_id(&self, id: u16) -> Option<&Profile> {
580 self.profiles.binary_search_by(|p| p.id.cmp(&id)).ok().map(|idx| &self.profiles[idx])
581 }
582
583 #[inline]
584 pub fn get_profile_mut_by_id(&mut self, id: u16) -> Option<&mut Profile> {
585 self.profiles.binary_search_by(|p| p.id.cmp(&id)).ok().map(move |idx| &mut self.profiles[idx])
586 }
587
588 pub fn serialize_profile(&self, id: u16) -> Option<SlimProfile> {
589 self.get_profile_by_id(id).map(|p| SlimProfile::from_profile(p, &self.interner))
590 }
591
592 pub fn get_chat(&self, id: &str) -> Option<&Chat> { self.chats.iter().find(|c| c.id == id) }
597 pub fn get_chat_mut(&mut self, id: &str) -> Option<&mut Chat> { self.chats.iter_mut().find(|c| c.id == id) }
598
599 pub fn create_dm_chat(&mut self, their_npub: &str) -> String {
600 if self.get_chat(their_npub).is_none() {
601 let chat = Chat::new_dm(their_npub.to_string(), &mut self.interner);
602 self.chats.push(chat);
603 }
604 their_npub.to_string()
605 }
606
607 pub fn ensure_community_chat(&mut self, channel_id: &str) {
613 if !self.chats.iter().any(|c| c.id == channel_id) {
614 let chat =
615 Chat::new_community_channel(channel_id.to_string(), Vec::new(), &mut self.interner);
616 self.chats.push(chat);
617 }
618 }
619
620 pub fn upsert_community_chat(
625 &mut self,
626 channel_id: &str,
627 name: &str,
628 description: &str,
629 community_id: &str,
630 is_owner: bool,
631 has_icon: bool,
632 owner_npub: Option<&str>,
633 created_at_ms: Option<u64>,
634 dissolved: bool,
635 ) {
636 self.ensure_community_chat(channel_id);
637 if let Some(chat) = self.chats.iter_mut().find(|c| c.id == channel_id) {
638 let cf = &mut chat.metadata.custom_fields;
639 cf.insert("name".to_string(), name.to_string());
640 cf.insert("description".to_string(), description.to_string());
641 cf.insert("community_id".to_string(), community_id.to_string());
642 cf.insert("is_owner".to_string(), is_owner.to_string());
643 cf.insert("dissolved".to_string(), dissolved.to_string());
645 if let Some(ms) = created_at_ms {
647 cf.insert("created_at".to_string(), ms.to_string());
648 }
649 match owner_npub {
651 Some(n) => { cf.insert("owner_npub".to_string(), n.to_string()); }
652 None => { cf.remove("owner_npub"); }
653 }
654 if has_icon {
655 cf.insert("icon".to_string(), "1".to_string());
656 } else {
657 cf.remove("icon");
658 }
659 }
660 }
661
662 pub fn add_message_to_chat(&mut self, chat_id: &str, message: Message) -> bool {
663 let compact = CompactMessage::from_message(&message, &mut self.interner);
664
665 let (is_msg_added, chat_idx) = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
666 let added = self.chats[idx].add_compact_message(compact);
667 (added, idx)
668 } else {
669 let mut chat = if chat_id.starts_with("npub1") {
670 Chat::new_dm(chat_id.to_string(), &mut self.interner)
671 } else {
672 Chat::new(chat_id.to_string(), ChatType::Community, vec![])
673 };
674 let was_added = chat.add_compact_message(compact);
675 self.chats.push(chat);
676 (was_added, self.chats.len() - 1)
677 };
678
679 if is_msg_added && chat_idx > 0 {
680 let this_time = self.chats[chat_idx].last_message_time();
681 let target = self.chats[..chat_idx].iter()
682 .position(|c| c.last_message_time() <= this_time)
683 .unwrap_or(chat_idx);
684 if target < chat_idx {
685 self.chats[target..=chat_idx].rotate_right(1);
686 }
687 }
688
689 is_msg_added
690 }
691
692 pub fn add_messages_to_chat_batch(&mut self, chat_id: &str, messages: Vec<Message>) -> usize {
693 if messages.is_empty() { return 0; }
694
695 let compact_messages: Vec<_> = messages.into_iter()
696 .map(|msg| CompactMessage::from_message_owned(msg, &mut self.interner))
697 .collect();
698
699 let chat_idx = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
700 idx
701 } else {
702 let chat = if chat_id.starts_with("npub1") {
703 Chat::new_dm(chat_id.to_string(), &mut self.interner)
704 } else {
705 Chat::new(chat_id.to_string(), ChatType::Community, vec![])
706 };
707 self.chats.push(chat);
708 self.chats.len() - 1
709 };
710
711 let old_last_time = self.chats[chat_idx].messages.last_timestamp();
712 let added = self.chats[chat_idx].messages.insert_batch(compact_messages);
713
714 if added > 0 && self.chats[chat_idx].messages.last_timestamp() != old_last_time && chat_idx > 0 {
715 let this_time = self.chats[chat_idx].last_message_time();
716 let target = self.chats[..chat_idx].iter()
717 .position(|c| c.last_message_time() <= this_time)
718 .unwrap_or(chat_idx);
719 if target < chat_idx {
720 self.chats[target..=chat_idx].rotate_right(1);
721 }
722 }
723
724 added
725 }
726
727 pub fn add_message_to_participant(&mut self, their_npub: &str, message: Message) -> bool {
731 let id = self.interner.intern(their_npub);
732 if self.get_profile_by_id(id).is_none() {
733 let profile = Profile::new();
734 self.insert_or_replace_profile(their_npub, profile);
735
736 if let Some(slim) = self.serialize_profile(id) {
738 emit_event("profile_update", &slim);
739 }
740 }
741
742 let chat_id = self.create_dm_chat(their_npub);
743 self.add_message_to_chat(&chat_id, message)
744 }
745
746 pub fn find_message(&self, message_id: &str) -> Option<(&Chat, Message)> {
751 if message_id.is_empty() { return None; }
752 for chat in &self.chats {
753 if let Some(compact) = chat.get_compact_message(message_id) {
754 return Some((chat, compact.to_message(&self.interner)));
755 }
756 }
757 None
758 }
759
760 pub fn find_chat_for_message(&self, message_id: &str) -> Option<(usize, String)> {
761 if message_id.is_empty() { return None; }
762 for (idx, chat) in self.chats.iter().enumerate() {
763 if chat.has_message(message_id) { return Some((idx, chat.id.clone())); }
764 }
765 None
766 }
767
768 pub fn update_message<F>(&mut self, message_id: &str, f: F) -> Option<(String, Message)>
769 where F: FnOnce(&mut CompactMessage)
770 {
771 if message_id.is_empty() { return None; }
772 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
773 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
774 let chat_id = self.chats[chat_idx].id.clone();
775 self.chats[chat_idx].get_compact_message(message_id).map(|m| (chat_id, m.to_message(&self.interner)))
776 }
777
778 pub fn update_message_in_chat<F>(&mut self, chat_id: &str, message_id: &str, f: F) -> Option<Message>
779 where F: FnOnce(&mut CompactMessage)
780 {
781 let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
782 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
783 self.chats[chat_idx].get_compact_message(message_id).map(|m| m.to_message(&self.interner))
784 }
785
786 pub fn finalize_pending_message(&mut self, chat_id: &str, pending_id: &str, real_id: &str) -> Option<(String, Message)> {
787 let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
788 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(pending_id) {
789 msg.id = crate::simd::hex::hex_to_bytes_32(real_id);
790 msg.set_pending(false);
791 }
792 self.chats[chat_idx].messages.rebuild_index();
793 self.chats[chat_idx].get_compact_message(real_id)
794 .map(|m| (pending_id.to_string(), m.to_message(&self.interner)))
795 }
796
797 pub fn update_attachment<F>(&mut self, chat_hint: &str, msg_id: &str, attachment_id: &str, f: F) -> bool
798 where F: FnOnce(&mut CompactAttachment)
799 {
800 for chat in &mut self.chats {
801 let is_target = match &chat.chat_type {
802 ChatType::Community => chat.id == chat_hint,
804 ChatType::DirectMessage => chat.has_participant(chat_hint, &self.interner),
805 };
806 if is_target {
807 if let Some(msg) = chat.messages.find_by_hex_id_mut(msg_id) {
808 if let Some(att) = msg.attachments.iter_mut().find(|a| a.id_eq(attachment_id)) {
809 f(att);
810 return true;
811 }
812 }
813 }
814 }
815 false
816 }
817
818 pub fn add_attachment_to_message(&mut self, chat_id: &str, msg_id: &str, attachment: CompactAttachment) -> bool {
819 let chat_idx = match self.chats.iter().position(|c| c.id == chat_id || c.has_participant(chat_id, &self.interner)) {
820 Some(idx) => idx,
821 None => return false,
822 };
823 if let Some(msg) = self.chats[chat_idx].messages.find_by_hex_id_mut(msg_id) {
824 msg.attachments.push(attachment);
825 true
826 } else { false }
827 }
828
829 pub fn add_reaction_to_message(&mut self, message_id: &str, reaction: Reaction) -> Option<(String, bool)> {
830 if message_id.is_empty() { return None; }
831 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
832 let chat_id = self.chats[chat_idx].id.clone();
833 let msg = self.chats[chat_idx].get_compact_message_mut(message_id)?;
834 let added = msg.add_reaction(reaction, &mut self.interner);
835 Some((chat_id, added))
836 }
837
838 pub fn remove_message(&mut self, message_id: &str) -> Option<(String, Message)> {
839 if message_id.is_empty() { return None; }
840 for chat in &mut self.chats {
841 if let Some(compact) = chat.messages.find_by_hex_id(message_id) {
842 let msg = compact.to_message(&self.interner);
843 let chat_id = chat.id.clone();
844 chat.messages.remove_by_hex_id(message_id);
845 return Some((chat_id, msg));
846 }
847 }
848 None
849 }
850
851 pub fn message_exists(&self, message_id: &str) -> bool {
852 !message_id.is_empty() && self.chats.iter().any(|chat| chat.has_message(message_id))
853 }
854
855 pub fn sum_unread_from(&self, counts: &std::collections::HashMap<String, u32>) -> u32 {
864 let mut total = 0u32;
865 for chat in &self.chats {
866 if chat.muted {
867 continue;
868 }
869 if !chat.is_community() {
870 if let Some(id) = self.interner.lookup(&chat.id) {
871 if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) {
872 continue;
873 }
874 }
875 }
876 total += counts.get(&chat.id).copied().unwrap_or(0);
877 }
878 total
879 }
880
881 pub fn count_unread_messages(&self) -> u32 {
882 let mut total_unread = 0;
883 for chat in &self.chats {
884 if chat.muted { continue; }
885 let is_group = chat.is_community();
886 if !is_group {
887 if let Some(id) = self.interner.lookup(&chat.id) {
888 if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) { continue; }
889 }
890 }
891 let mut unread_count = 0u32;
892 for msg in chat.iter_compact().rev() {
893 if msg.flags.is_mine() { break; }
894 if chat.last_read != [0u8; 32] && msg.id == chat.last_read { break; }
895 if is_group && msg.npub_idx != NO_NPUB {
896 if self.get_profile_by_id(msg.npub_idx).map_or(false, |p| p.flags.is_blocked()) { continue; }
897 }
898 unread_count += 1;
899 }
900 #[cfg(debug_assertions)]
902 if unread_count > 0 {
903 let last_read_hex = crate::compact::decode_message_id(&chat.last_read);
904 let last_msg_hex = chat.messages.last().map(|m| crate::compact::decode_message_id(&m.id)).unwrap_or_default();
905 let msg_count = chat.message_count();
906 eprintln!("[Unread] chat={} unread={} msgs_in_memory={} last_read={} last_msg={}",
907 &chat.id[..20.min(chat.id.len())], unread_count, msg_count,
908 &last_read_hex[..16.min(last_read_hex.len())], &last_msg_hex[..16.min(last_msg_hex.len())]);
909 }
910 total_unread += unread_count;
911 }
912 total_unread
913 }
914
915 pub fn update_typing_and_get_active(&mut self, chat_id: &str, npub: &str, expires_at: u64) -> Vec<String> {
920 let handle = self.interner.intern(npub);
921 if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
922 chat.update_typing_participant(handle, expires_at);
923 chat.get_active_typers(&self.interner)
924 } else {
925 Vec::new()
926 }
927 }
928}
929
930impl Default for ChatState {
931 fn default() -> Self { Self::new() }
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937 use crate::types::Message;
938 use crate::profile::{Profile, SlimProfile, Status};
939 use crate::simd::hex::bytes_to_hex_32;
940
941 fn make_hex_id(seed: u8) -> String {
948 let mut bytes = [seed; 32];
949 bytes[0] = seed.wrapping_add(0x10) | 0x10; bytes[1] = seed.wrapping_mul(37);
951 bytes_to_hex_32(&bytes)
952 }
953
954 fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
956 Message {
957 id: make_hex_id(id_seed),
958 content: content.to_string(),
959 at: timestamp_ms,
960 mine,
961 ..Default::default()
962 }
963 }
964
965 fn make_message_from(id_seed: u8, content: &str, timestamp_ms: u64, npub: &str) -> Message {
967 Message {
968 id: make_hex_id(id_seed),
969 content: content.to_string(),
970 at: timestamp_ms,
971 mine: false,
972 npub: Some(npub.to_string()),
973 ..Default::default()
974 }
975 }
976
977 fn make_slim_profile(id: &str, name: &str) -> SlimProfile {
979 SlimProfile {
980 id: id.to_string(),
981 name: name.to_string(),
982 display_name: String::new(),
983 nickname: String::new(),
984 lud06: String::new(),
985 lud16: String::new(),
986 banner: String::new(),
987 avatar: String::new(),
988 about: String::new(),
989 website: String::new(),
990 nip05: String::new(),
991 status: Status::new(),
992 last_updated: 0,
993 mine: false,
994 bot: false,
995 is_blocked: false,
996 avatar_cached: String::new(),
997 banner_cached: String::new(),
998 }
999 }
1000
1001 #[test]
1006 fn insert_or_replace_profile_creates_new() {
1007 let mut state = ChatState::new();
1008 let profile = Profile::new();
1009 state.insert_or_replace_profile("npub1alice", profile);
1010
1011 assert!(
1012 state.get_profile("npub1alice").is_some(),
1013 "newly inserted profile should be retrievable"
1014 );
1015 assert_eq!(state.profiles.len(), 1, "should have exactly one profile");
1016 }
1017
1018 #[test]
1019 fn insert_or_replace_profile_updates_existing() {
1020 let mut state = ChatState::new();
1021 let mut p1 = Profile::new();
1022 p1.name = "Alice".to_string().into_boxed_str();
1023 state.insert_or_replace_profile("npub1alice", p1);
1024
1025 let mut p2 = Profile::new();
1026 p2.name = "Alice Updated".to_string().into_boxed_str();
1027 state.insert_or_replace_profile("npub1alice", p2);
1028
1029 let fetched = state.get_profile("npub1alice").expect("profile should exist");
1030 assert_eq!(
1031 &*fetched.name, "Alice Updated",
1032 "profile name should be updated after replace"
1033 );
1034 assert_eq!(state.profiles.len(), 1, "should still be one profile, not duplicated");
1035 }
1036
1037 #[test]
1038 fn get_profile_by_npub() {
1039 let mut state = ChatState::new();
1040 let mut profile = Profile::new();
1041 profile.name = "Bob".to_string().into_boxed_str();
1042 state.insert_or_replace_profile("npub1bob", profile);
1043
1044 let fetched = state.get_profile("npub1bob").expect("profile should be found");
1045 assert_eq!(&*fetched.name, "Bob", "fetched profile name should match");
1046 }
1047
1048 #[test]
1049 fn get_profile_returns_none_for_unknown() {
1050 let state = ChatState::new();
1051 assert!(
1052 state.get_profile("npub1unknown").is_none(),
1053 "unknown npub should return None"
1054 );
1055 }
1056
1057 #[test]
1058 fn get_profile_by_id_works() {
1059 let mut state = ChatState::new();
1060 let mut profile = Profile::new();
1061 profile.name = "Charlie".to_string().into_boxed_str();
1062 state.insert_or_replace_profile("npub1charlie", profile);
1063
1064 let id = state.interner.lookup("npub1charlie").expect("npub should be interned");
1065 let fetched = state.get_profile_by_id(id).expect("profile should be found by id");
1066 assert_eq!(&*fetched.name, "Charlie", "profile looked up by id should match");
1067 }
1068
1069 #[test]
1070 fn get_profile_by_id_returns_none_for_invalid() {
1071 let state = ChatState::new();
1072 assert!(
1073 state.get_profile_by_id(9999).is_none(),
1074 "invalid interner id should return None"
1075 );
1076 }
1077
1078 #[test]
1079 fn merge_db_profiles_sets_mine_flag() {
1080 let mut state = ChatState::new();
1081 let slim_mine = make_slim_profile("npub1me", "Me");
1082 let slim_other = make_slim_profile("npub1other", "Other");
1083
1084 state.merge_db_profiles(vec![slim_mine, slim_other], "npub1me");
1085
1086 let me = state.get_profile("npub1me").expect("my profile should exist");
1087 assert!(me.flags.is_mine(), "my profile should have mine flag set");
1088
1089 let other = state.get_profile("npub1other").expect("other profile should exist");
1090 assert!(!other.flags.is_mine(), "other profile should not have mine flag");
1091 }
1092
1093 #[test]
1094 fn serialize_profile_roundtrip() {
1095 let mut state = ChatState::new();
1096 let mut profile = Profile::new();
1097 profile.name = "Roundtrip".to_string().into_boxed_str();
1098 profile.about = "Test about".to_string().into_boxed_str();
1099 profile.flags.set_blocked(true);
1100 state.insert_or_replace_profile("npub1round", profile);
1101
1102 let id = state.interner.lookup("npub1round").unwrap();
1103 let slim = state.serialize_profile(id).expect("serialization should succeed");
1104
1105 assert_eq!(slim.id, "npub1round", "serialized id should match");
1106 assert_eq!(slim.name, "Roundtrip", "serialized name should match");
1107 assert_eq!(slim.about, "Test about", "serialized about should match");
1108 assert!(slim.is_blocked, "serialized blocked flag should be true");
1109
1110 let restored = slim.to_profile();
1112 assert_eq!(&*restored.name, "Roundtrip", "restored name should match");
1113 assert!(restored.flags.is_blocked(), "restored blocked flag should be true");
1114 }
1115
1116 #[test]
1117 fn binary_search_maintains_sorted_order_with_100_profiles() {
1118 let mut state = ChatState::new();
1119
1120 let npubs: Vec<String> = (0..100).map(|i| format!("npub1user{:04}", i)).collect();
1122 let mut shuffled = npubs.clone();
1123 for i in (1..shuffled.len()).rev() {
1125 let j = (i * 37 + 13) % (i + 1);
1126 shuffled.swap(i, j);
1127 }
1128
1129 for npub in &shuffled {
1130 let mut profile = Profile::new();
1131 profile.name = npub.clone().into_boxed_str();
1132 state.insert_or_replace_profile(npub, profile);
1133 }
1134
1135 for npub in &npubs {
1137 assert!(
1138 state.get_profile(npub).is_some(),
1139 "profile {} should be retrievable after bulk insert",
1140 npub
1141 );
1142 }
1143
1144 for window in state.profiles.windows(2) {
1146 assert!(
1147 window[0].id < window[1].id,
1148 "profiles should be sorted by interner id"
1149 );
1150 }
1151
1152 assert_eq!(state.profiles.len(), 100, "should have exactly 100 profiles");
1153 }
1154
1155 #[test]
1156 fn insert_same_npub_twice_updates_not_duplicates() {
1157 let mut state = ChatState::new();
1158
1159 for i in 0..5 {
1160 let mut profile = Profile::new();
1161 profile.name = format!("version_{}", i).into_boxed_str();
1162 state.insert_or_replace_profile("npub1repeated", profile);
1163 }
1164
1165 assert_eq!(state.profiles.len(), 1, "repeated inserts should not create duplicates");
1166 let p = state.get_profile("npub1repeated").unwrap();
1167 assert_eq!(&*p.name, "version_4", "should retain the last update");
1168 }
1169
1170 #[test]
1171 fn get_profile_mut_modifies_in_place() {
1172 let mut state = ChatState::new();
1173 let profile = Profile::new();
1174 state.insert_or_replace_profile("npub1mutable", profile);
1175
1176 let p = state.get_profile_mut("npub1mutable").expect("profile should exist");
1177 p.name = "Mutated".to_string().into_boxed_str();
1178
1179 let fetched = state.get_profile("npub1mutable").unwrap();
1180 assert_eq!(&*fetched.name, "Mutated", "mutation should persist");
1181 }
1182
1183 #[test]
1188 fn create_dm_chat_creates_new() {
1189 let mut state = ChatState::new();
1190 let id = state.create_dm_chat("npub1peer");
1191
1192 assert_eq!(id, "npub1peer", "returned id should match the npub");
1193 assert!(state.get_chat("npub1peer").is_some(), "chat should be created");
1194 assert_eq!(state.chats.len(), 1, "should have exactly one chat");
1195 }
1196
1197 #[test]
1198 fn create_dm_chat_is_idempotent() {
1199 let mut state = ChatState::new();
1200 state.create_dm_chat("npub1peer");
1201 state.create_dm_chat("npub1peer");
1202 state.create_dm_chat("npub1peer");
1203
1204 assert_eq!(state.chats.len(), 1, "repeated creates should not duplicate");
1205 }
1206
1207 #[test]
1208 fn ensure_community_chat_idempotent() {
1209 let mut state = ChatState::new();
1210 state.ensure_community_chat("grp1");
1211 state.ensure_community_chat("grp1");
1212
1213 assert_eq!(state.chats.len(), 1, "second call should not create a duplicate");
1214 let chat = state.get_chat("grp1").expect("community chat should exist");
1215 assert!(chat.is_community(), "should be a Community chat");
1216 }
1217
1218 #[test]
1219 fn get_chat_by_id() {
1220 let mut state = ChatState::new();
1221 state.create_dm_chat("npub1x");
1222
1223 let chat = state.get_chat("npub1x").expect("chat should exist");
1224 assert_eq!(chat.id, "npub1x", "chat id should match");
1225 }
1226
1227 #[test]
1228 fn get_chat_returns_none_for_missing() {
1229 let state = ChatState::new();
1230 assert!(state.get_chat("nonexistent").is_none(), "missing chat should return None");
1231 }
1232
1233 #[test]
1234 fn get_chat_mut_modifies_in_place() {
1235 let mut state = ChatState::new();
1236 state.create_dm_chat("npub1editable");
1237
1238 let chat = state.get_chat_mut("npub1editable").expect("chat should exist");
1239 chat.muted = true;
1240
1241 let refetched = state.get_chat("npub1editable").unwrap();
1242 assert!(refetched.muted, "muted flag should persist after mutation");
1243 }
1244
1245 #[test]
1246 fn multiple_different_chats() {
1247 let mut state = ChatState::new();
1248 state.create_dm_chat("npub1alice");
1249 state.create_dm_chat("npub1bob");
1250 state.ensure_community_chat("grp1");
1251
1252 assert_eq!(state.chats.len(), 3, "should have three distinct chats");
1253 }
1254
1255 #[test]
1260 fn add_message_to_chat_single() {
1261 let mut state = ChatState::new();
1262 state.create_dm_chat("npub1peer");
1263
1264 let msg = make_message(1, "hello", 1700000000000, false);
1265 let added = state.add_message_to_chat("npub1peer", msg);
1266
1267 assert!(added, "first message should be added successfully");
1268 let chat = state.get_chat("npub1peer").unwrap();
1269 assert_eq!(chat.message_count(), 1, "chat should have one message");
1270 }
1271
1272 #[test]
1273 fn add_message_to_chat_dedup_rejects_same_id() {
1274 let mut state = ChatState::new();
1275 state.create_dm_chat("npub1peer");
1276
1277 let msg1 = make_message(1, "hello", 1700000000000, false);
1278 let msg2 = make_message(1, "duplicate", 1700000001000, false);
1279
1280 let added1 = state.add_message_to_chat("npub1peer", msg1);
1281 let added2 = state.add_message_to_chat("npub1peer", msg2);
1282
1283 assert!(added1, "first insert should succeed");
1284 assert!(!added2, "duplicate ID should be rejected");
1285 assert_eq!(
1286 state.get_chat("npub1peer").unwrap().message_count(), 1,
1287 "should still have only one message"
1288 );
1289 }
1290
1291 #[test]
1292 fn add_messages_to_chat_batch_works() {
1293 let mut state = ChatState::new();
1294 state.create_dm_chat("npub1peer");
1295
1296 let msgs: Vec<Message> = (0..10).map(|i| {
1297 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1298 }).collect();
1299
1300 let added = state.add_messages_to_chat_batch("npub1peer", msgs);
1301 assert_eq!(added, 10, "all 10 messages should be added");
1302 assert_eq!(
1303 state.get_chat("npub1peer").unwrap().message_count(), 10,
1304 "chat should have 10 messages"
1305 );
1306 }
1307
1308 #[test]
1309 fn add_messages_to_chat_batch_dedup() {
1310 let mut state = ChatState::new();
1311 state.create_dm_chat("npub1peer");
1312
1313 let msgs1: Vec<Message> = (0..5).map(|i| {
1315 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1316 }).collect();
1317 state.add_messages_to_chat_batch("npub1peer", msgs1);
1318
1319 let msgs2: Vec<Message> = (3..8).map(|i| {
1321 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1322 }).collect();
1323 let added = state.add_messages_to_chat_batch("npub1peer", msgs2);
1324
1325 assert_eq!(added, 3, "only 3 new messages should be added (5, 6, 7)");
1326 assert_eq!(
1327 state.get_chat("npub1peer").unwrap().message_count(), 8,
1328 "total should be 8 unique messages"
1329 );
1330 }
1331
1332 #[test]
1333 fn add_message_to_participant_creates_profile_and_chat() {
1334 let mut state = ChatState::new();
1335
1336 let msg = make_message(1, "hi there", 1700000000000, false);
1337 let added = state.add_message_to_participant("npub1stranger", msg);
1338
1339 assert!(added, "message should be added");
1340 assert!(
1341 state.get_profile("npub1stranger").is_some(),
1342 "profile should be auto-created for unknown participant"
1343 );
1344 assert!(
1345 state.get_chat("npub1stranger").is_some(),
1346 "DM chat should be auto-created"
1347 );
1348 }
1349
1350 #[test]
1351 fn add_message_to_participant_uses_existing_profile() {
1352 let mut state = ChatState::new();
1353
1354 let mut profile = Profile::new();
1356 profile.name = "Known User".to_string().into_boxed_str();
1357 state.insert_or_replace_profile("npub1known", profile);
1358
1359 let msg = make_message(1, "hello", 1700000000000, false);
1360 state.add_message_to_participant("npub1known", msg);
1361
1362 let p = state.get_profile("npub1known").unwrap();
1364 assert_eq!(&*p.name, "Known User", "existing profile should not be overwritten");
1365 }
1366
1367 #[test]
1368 fn find_message_across_chats() {
1369 let mut state = ChatState::new();
1370 state.create_dm_chat("npub1a");
1371 state.create_dm_chat("npub1b");
1372
1373 let msg_a = make_message(1, "in chat a", 1700000000000, false);
1374 let msg_b = make_message(2, "in chat b", 1700000001000, false);
1375 let msg_id_b = msg_b.id.clone();
1376
1377 state.add_message_to_chat("npub1a", msg_a);
1378 state.add_message_to_chat("npub1b", msg_b);
1379
1380 let (chat, found_msg) = state.find_message(&msg_id_b).expect("message should be found");
1381 assert_eq!(chat.id, "npub1b", "should find in correct chat");
1382 assert_eq!(found_msg.content, "in chat b", "content should match");
1383 }
1384
1385 #[test]
1386 fn find_message_returns_none_for_unknown() {
1387 let state = ChatState::new();
1388 assert!(
1389 state.find_message(&make_hex_id(99)).is_none(),
1390 "unknown message id should return None"
1391 );
1392 }
1393
1394 #[test]
1395 fn find_message_empty_id_returns_none() {
1396 let state = ChatState::new();
1397 assert!(state.find_message("").is_none(), "empty id should return None");
1398 }
1399
1400 #[test]
1401 fn update_message_mutates_and_returns() {
1402 let mut state = ChatState::new();
1403 state.create_dm_chat("npub1peer");
1404
1405 let msg = make_message(1, "original", 1700000000000, false);
1406 let msg_id = msg.id.clone();
1407 state.add_message_to_chat("npub1peer", msg);
1408
1409 let result = state.update_message(&msg_id, |cm| {
1410 cm.content = "updated content".to_string().into_boxed_str();
1411 });
1412
1413 let (chat_id, updated) = result.expect("update should return Some");
1414 assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1415 assert_eq!(updated.content, "updated content", "content should be updated");
1416 }
1417
1418 #[test]
1419 fn update_message_returns_none_for_missing() {
1420 let mut state = ChatState::new();
1421 let result = state.update_message(&make_hex_id(99), |_cm| {});
1422 assert!(result.is_none(), "updating nonexistent message should return None");
1423 }
1424
1425 #[test]
1426 fn finalize_pending_message_changes_id() {
1427 let mut state = ChatState::new();
1428 state.create_dm_chat("npub1peer");
1429
1430 let mut msg = make_message(1, "pending msg", 1700000000000, true);
1431 msg.pending = true;
1432 let pending_id = msg.id.clone();
1433 state.add_message_to_chat("npub1peer", msg);
1434
1435 let real_id = make_hex_id(2);
1436 let result = state.finalize_pending_message("npub1peer", &pending_id, &real_id);
1437
1438 let (old_id, finalized) = result.expect("finalize should succeed");
1439 assert_eq!(old_id, pending_id, "should return old pending id");
1440 assert_eq!(finalized.id, real_id, "message id should now be the real id");
1441 assert!(!finalized.pending, "message should no longer be pending");
1442
1443 assert!(
1445 state.find_message(&pending_id).is_none(),
1446 "pending id should no longer resolve"
1447 );
1448 assert!(
1450 state.find_message(&real_id).is_some(),
1451 "real id should now resolve"
1452 );
1453 }
1454
1455 #[test]
1456 fn remove_message_works() {
1457 let mut state = ChatState::new();
1458 state.create_dm_chat("npub1peer");
1459
1460 let msg = make_message(1, "deleteme", 1700000000000, false);
1461 let msg_id = msg.id.clone();
1462 state.add_message_to_chat("npub1peer", msg);
1463
1464 let result = state.remove_message(&msg_id);
1465 assert!(result.is_some(), "remove should return the removed message");
1466
1467 let (chat_id, removed) = result.unwrap();
1468 assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1469 assert_eq!(removed.content, "deleteme", "content should match");
1470
1471 assert!(
1472 state.find_message(&msg_id).is_none(),
1473 "removed message should no longer be findable"
1474 );
1475 }
1476
1477 #[test]
1478 fn remove_message_returns_none_for_missing() {
1479 let mut state = ChatState::new();
1480 assert!(
1481 state.remove_message(&make_hex_id(99)).is_none(),
1482 "removing nonexistent message should return None"
1483 );
1484 }
1485
1486 #[test]
1487 fn message_exists_check() {
1488 let mut state = ChatState::new();
1489 state.create_dm_chat("npub1peer");
1490
1491 let msg = make_message(1, "exists", 1700000000000, false);
1492 let msg_id = msg.id.clone();
1493 state.add_message_to_chat("npub1peer", msg);
1494
1495 assert!(state.message_exists(&msg_id), "added message should exist");
1496 assert!(!state.message_exists(&make_hex_id(99)), "unknown id should not exist");
1497 assert!(!state.message_exists(""), "empty id should not exist");
1498 }
1499
1500 #[test]
1501 fn chat_reordering_newest_first_after_message_add() {
1502 let mut state = ChatState::new();
1503 state.create_dm_chat("npub1old");
1504 state.create_dm_chat("npub1new");
1505
1506 let old_msg = make_message(1, "old", 1700000000000, false);
1508 state.add_message_to_chat("npub1old", old_msg);
1509
1510 let new_msg = make_message(2, "new", 1700000002000, false);
1512 state.add_message_to_chat("npub1new", new_msg);
1513
1514 assert_eq!(
1515 state.chats[0].id, "npub1new",
1516 "chat with newest message should be first"
1517 );
1518 assert_eq!(
1519 state.chats[1].id, "npub1old",
1520 "chat with older message should be second"
1521 );
1522 }
1523
1524 #[test]
1525 fn batch_add_does_not_reorder_for_old_messages() {
1526 let mut state = ChatState::new();
1527 state.create_dm_chat("npub1active");
1528 state.create_dm_chat("npub1history");
1529
1530 let recent = make_message(1, "recent", 1700000010000, false);
1532 state.add_message_to_chat("npub1active", recent);
1533
1534 let old_msgs: Vec<Message> = (10..15).map(|i| {
1536 make_message(i, &format!("old {}", i), 1700000000000 + i as u64 * 100, false)
1537 }).collect();
1538 state.add_messages_to_chat_batch("npub1history", old_msgs);
1539
1540 assert_eq!(
1541 state.chats[0].id, "npub1active",
1542 "active chat should remain first when batch has only old messages"
1543 );
1544 }
1545
1546 #[test]
1547 fn stress_test_50_messages_in_5_chats() {
1548 let mut state = ChatState::new();
1549
1550 for i in 0..5 {
1551 state.create_dm_chat(&format!("npub1chat{}", i));
1552 }
1553
1554 let mut total_added = 0;
1555 for i in 0..50u8 {
1556 let chat_idx = i as usize % 5;
1557 let chat_id = format!("npub1chat{}", chat_idx);
1558 let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, i % 3 == 0);
1559 if state.add_message_to_chat(&chat_id, msg) {
1560 total_added += 1;
1561 }
1562 }
1563
1564 assert_eq!(total_added, 50, "all 50 unique messages should be added");
1565
1566 let total_in_chats: usize = state.chats.iter().map(|c| c.message_count()).sum();
1567 assert_eq!(total_in_chats, 50, "total messages across all chats should be 50");
1568
1569 for i in 0..5 {
1571 let chat = state.get_chat(&format!("npub1chat{}", i)).unwrap();
1572 assert_eq!(
1573 chat.message_count(), 10,
1574 "chat {} should have 10 messages",
1575 i
1576 );
1577 }
1578
1579 for i in 0..50u8 {
1581 assert!(
1582 state.message_exists(&make_hex_id(i)),
1583 "message {} should exist",
1584 i
1585 );
1586 }
1587 }
1588
1589 #[test]
1590 fn add_message_auto_creates_dm_chat() {
1591 let mut state = ChatState::new();
1592
1593 let msg = make_message(1, "auto create", 1700000000000, false);
1595 let added = state.add_message_to_chat("npub1auto", msg);
1596
1597 assert!(added, "message should be added");
1598 assert!(state.get_chat("npub1auto").is_some(), "DM chat should be auto-created");
1599 }
1600
1601 #[test]
1602 fn add_message_auto_creates_community_chat() {
1603 let mut state = ChatState::new();
1604
1605 let msg = make_message(1, "group msg", 1700000000000, false);
1607 let added = state.add_message_to_chat("group_abc123", msg);
1608
1609 assert!(added, "message should be added");
1610 let chat = state.get_chat("group_abc123").expect("community chat should be auto-created");
1611 assert!(chat.is_community(), "auto-created non-npub chat should be a Community chat");
1612 }
1613
1614 #[test]
1619 fn count_unread_messages_basic() {
1620 let mut state = ChatState::new();
1621 state.create_dm_chat("npub1peer");
1622
1623 for i in 0..5u8 {
1624 let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false);
1625 state.add_message_to_chat("npub1peer", msg);
1626 }
1627
1628 assert_eq!(state.count_unread_messages(), 5, "all 5 non-mine messages should be unread");
1629 }
1630
1631 #[test]
1632 fn count_unread_muted_chat_skipped() {
1633 let mut state = ChatState::new();
1634 state.create_dm_chat("npub1muted");
1635
1636 let msg = make_message(1, "muted msg", 1700000000000, false);
1637 state.add_message_to_chat("npub1muted", msg);
1638
1639 state.get_chat_mut("npub1muted").unwrap().muted = true;
1640
1641 assert_eq!(state.count_unread_messages(), 0, "muted chat should not count toward unread");
1642 }
1643
1644 #[test]
1645 fn count_unread_blocked_user_skipped() {
1646 let mut state = ChatState::new();
1647
1648 let mut profile = Profile::new();
1649 profile.flags.set_blocked(true);
1650 state.insert_or_replace_profile("npub1blocked", profile);
1651 state.create_dm_chat("npub1blocked");
1652
1653 let msg = make_message(1, "blocked msg", 1700000000000, false);
1654 state.add_message_to_chat("npub1blocked", msg);
1655
1656 assert_eq!(state.count_unread_messages(), 0, "blocked user DM should not count");
1657 }
1658
1659 #[test]
1660 fn count_unread_own_messages_break_count() {
1661 let mut state = ChatState::new();
1662 state.create_dm_chat("npub1peer");
1663
1664 let msg1 = make_message(1, "them 1", 1700000001000, false);
1666 let msg2 = make_message(2, "them 2", 1700000002000, false);
1667 let msg3 = make_message(3, "them 3", 1700000003000, false);
1668 let msg_mine = make_message(4, "me", 1700000004000, true);
1669 let msg5 = make_message(5, "them 4", 1700000005000, false);
1670 let msg6 = make_message(6, "them 5", 1700000006000, false);
1671
1672 for m in [msg1, msg2, msg3, msg_mine, msg5, msg6] {
1673 state.add_message_to_chat("npub1peer", m);
1674 }
1675
1676 assert_eq!(
1678 state.count_unread_messages(), 2,
1679 "only messages after last 'mine' should count as unread"
1680 );
1681 }
1682
1683 #[test]
1684 fn count_unread_last_read_marker_breaks_count() {
1685 let mut state = ChatState::new();
1686 state.create_dm_chat("npub1peer");
1687
1688 let msg1 = make_message(1, "old", 1700000001000, false);
1689 let msg2 = make_message(2, "read up to here", 1700000002000, false);
1690 let msg3 = make_message(3, "new 1", 1700000003000, false);
1691 let msg4 = make_message(4, "new 2", 1700000004000, false);
1692 let read_marker_id = msg2.id.clone();
1693
1694 for m in [msg1, msg2, msg3, msg4] {
1695 state.add_message_to_chat("npub1peer", m);
1696 }
1697
1698 let chat = state.get_chat_mut("npub1peer").unwrap();
1700 chat.last_read = crate::simd::hex::hex_to_bytes_32(&read_marker_id);
1701
1702 assert_eq!(
1703 state.count_unread_messages(), 2,
1704 "only messages after last_read marker should count"
1705 );
1706 }
1707
1708 #[test]
1709 fn count_unread_empty_chats_is_zero() {
1710 let mut state = ChatState::new();
1711 state.create_dm_chat("npub1empty1");
1712 state.create_dm_chat("npub1empty2");
1713
1714 assert_eq!(state.count_unread_messages(), 0, "empty chats should have zero unread");
1715 }
1716
1717 #[test]
1718 fn count_unread_blocked_group_member_messages_skipped() {
1719 let mut state = ChatState::new();
1720
1721 let mut blocked_profile = Profile::new();
1723 blocked_profile.flags.set_blocked(true);
1724 state.insert_or_replace_profile("npub1blockedmember", blocked_profile);
1725
1726 let normal_profile = Profile::new();
1728 state.insert_or_replace_profile("npub1normal", normal_profile);
1729
1730 state.ensure_community_chat("grp1");
1731
1732 let msg_blocked = make_message_from(1, "blocked says hi", 1700000001000, "npub1blockedmember");
1734 state.add_message_to_chat("grp1", msg_blocked);
1735
1736 let msg_normal = make_message_from(2, "normal says hi", 1700000002000, "npub1normal");
1738 state.add_message_to_chat("grp1", msg_normal);
1739
1740 assert_eq!(
1741 state.count_unread_messages(), 1,
1742 "only the non-blocked member's message should count"
1743 );
1744 }
1745
1746 #[test]
1747 fn count_unread_multiple_chats_summed() {
1748 let mut state = ChatState::new();
1749
1750 for i in 0..3 {
1751 let npub = format!("npub1chat{}", i);
1752 state.create_dm_chat(&npub);
1753 for j in 0..3u8 {
1754 let msg = make_message(
1755 i * 10 + j,
1756 &format!("msg {}-{}", i, j),
1757 1700000000000 + j as u64 * 1000,
1758 false,
1759 );
1760 state.add_message_to_chat(&npub, msg);
1761 }
1762 }
1763
1764 assert_eq!(
1765 state.count_unread_messages(), 9,
1766 "3 chats x 3 unread each = 9 total"
1767 );
1768 }
1769
1770 #[test]
1775 fn update_typing_and_get_active_basic() {
1776 let mut state = ChatState::new();
1777 state.create_dm_chat("npub1peer");
1778
1779 let far_future = std::time::SystemTime::now()
1781 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1782
1783 let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
1784 assert_eq!(active.len(), 1, "should have one active typer");
1785 assert_eq!(active[0], "npub1typer", "typer npub should match");
1786 }
1787
1788 #[test]
1789 fn update_typing_expired_typers_filtered() {
1790 let mut state = ChatState::new();
1791 state.create_dm_chat("npub1peer");
1792
1793 let expired = 1000;
1795 let active = state.update_typing_and_get_active("npub1peer", "npub1expired", expired);
1796
1797 assert!(active.is_empty(), "expired typer should be filtered out");
1798 }
1799
1800 #[test]
1801 fn update_typing_multiple_typers() {
1802 let mut state = ChatState::new();
1803 state.create_dm_chat("npub1peer");
1804
1805 let far_future = std::time::SystemTime::now()
1806 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1807
1808 state.update_typing_and_get_active("npub1peer", "npub1typer1", far_future);
1809 let active = state.update_typing_and_get_active("npub1peer", "npub1typer2", far_future);
1810
1811 assert_eq!(active.len(), 2, "should have two active typers");
1812 }
1813
1814 #[test]
1815 fn update_typing_unknown_chat_returns_empty() {
1816 let mut state = ChatState::new();
1817 let far_future = std::time::SystemTime::now()
1818 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1819
1820 let active = state.update_typing_and_get_active("npub1nonexistent", "npub1typer", far_future);
1821 assert!(active.is_empty(), "unknown chat should return empty typers");
1822 }
1823
1824 #[test]
1825 fn update_typing_refreshes_existing_typer() {
1826 let mut state = ChatState::new();
1827 state.create_dm_chat("npub1peer");
1828
1829 let far_future = std::time::SystemTime::now()
1830 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1831
1832 state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
1833 let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future + 100);
1835
1836 assert_eq!(active.len(), 1, "should still have only one typer entry after refresh");
1837 }
1838
1839 #[test]
1844 fn wrapper_id_cache_historical_and_pending() {
1845 let mut cache = WrapperIdCache::new();
1846
1847 let id1 = [1u8; 32];
1848 let id2 = [2u8; 32];
1849 let id3 = [3u8; 32];
1850
1851 cache.load(vec![id1, id2]);
1852 cache.insert(id3);
1853
1854 assert!(cache.contains(&id1), "historical id should be found");
1855 assert!(cache.contains(&id2), "historical id should be found");
1856 assert!(cache.contains(&id3), "pending id should be found");
1857 assert!(!cache.contains(&[4u8; 32]), "unknown id should not be found");
1858 assert_eq!(cache.len(), 3, "total count should be 3");
1859 }
1860
1861 #[test]
1862 fn wrapper_id_cache_clear() {
1863 let mut cache = WrapperIdCache::new();
1864 cache.load(vec![[1u8; 32]]);
1865 cache.insert([2u8; 32]);
1866
1867 cache.clear();
1868
1869 assert_eq!(cache.len(), 0, "cache should be empty after clear");
1870 assert!(!cache.contains(&[1u8; 32]), "cleared historical should not be found");
1871 assert!(!cache.contains(&[2u8; 32]), "cleared pending should not be found");
1872 }
1873}