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 "wss://relay.ditto.pub",
72];
73
74pub static DISCOVERY_RELAYS: &[&str] = &[
82 "wss://purplepag.es",
83 "wss://relay.primal.net",
84 "wss://nos.lol",
85 "wss://relay.snort.social",
86];
87
88pub static DISCOVERY_READ_ONLY_RELAYS: &[&str] = &["wss://relay.ditto.pub"];
95
96pub fn discovery_relay_iter() -> impl Iterator<Item = &'static str> {
99 DISCOVERY_RELAYS
100 .iter()
101 .chain(DISCOVERY_READ_ONLY_RELAYS.iter())
102 .copied()
103}
104
105pub async fn active_trusted_relays() -> Vec<&'static str> {
106 let Some(client) = nostr_client() else { return Vec::new() };
107 let pool_relays = client.relays().await;
108 TRUSTED_RELAYS.iter().copied()
109 .filter(|url| {
110 let normalized = url.trim_end_matches('/');
111 pool_relays.keys().any(|r| r.as_str().trim_end_matches('/') == normalized)
112 })
113 .collect()
114}
115
116pub static BLOSSOM_SERVERS: OnceLock<std::sync::Mutex<Vec<String>>> = OnceLock::new();
120
121pub fn init_blossom_servers() -> Vec<String> {
122 crate::blossom_servers::DEFAULT_BLOSSOM_SERVERS
123 .iter().map(|s| s.to_string()).collect()
124}
125
126pub fn get_blossom_servers() -> Vec<String> {
127 BLOSSOM_SERVERS
128 .get_or_init(|| std::sync::Mutex::new(init_blossom_servers()))
129 .lock().unwrap().clone()
130}
131
132pub static MNEMONIC_SEED: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
133pub static PENDING_NSEC: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
134
135pub static PENDING_BUNKER_SETUP:
144 std::sync::Mutex<Option<(zeroize::Zeroizing<String>, String)>> =
145 std::sync::Mutex::new(None);
146
147#[inline]
148pub fn set_pending_bunker_setup(url: String, remote_pk_hex: String) {
149 *PENDING_BUNKER_SETUP.lock().unwrap() =
150 Some((zeroize::Zeroizing::new(url), remote_pk_hex));
151}
152
153#[inline]
154pub fn pending_bunker_setup() -> Option<(String, String)> {
155 PENDING_BUNKER_SETUP.lock().unwrap()
156 .as_ref()
157 .map(|(z, pk)| (String::clone(&**z), pk.clone()))
158}
159
160#[inline]
161pub fn clear_pending_bunker_setup() {
162 *PENDING_BUNKER_SETUP.lock().unwrap() = None;
163}
164
165pub static PENDING_NIP55_SETUP: std::sync::Mutex<Option<(String, String)>> =
174 std::sync::Mutex::new(None);
175
176#[inline]
177pub fn set_pending_nip55_setup(user_pubkey_hex: String, signer_package: String) {
178 *PENDING_NIP55_SETUP.lock().unwrap() = Some((user_pubkey_hex, signer_package));
179}
180
181#[inline]
182pub fn pending_nip55_setup() -> Option<(String, String)> {
183 PENDING_NIP55_SETUP.lock().unwrap().clone()
184}
185
186#[inline]
187pub fn clear_pending_nip55_setup() {
188 *PENDING_NIP55_SETUP.lock().unwrap() = None;
189}
190
191pub static ENCRYPTION_KEY: crate::crypto::GuardedKey = crate::crypto::GuardedKey::empty();
192
193pub static ENCRYPTION_ENABLED: AtomicBool = AtomicBool::new(false);
194
195#[inline]
196pub fn is_encryption_enabled_fast() -> bool { ENCRYPTION_ENABLED.load(Ordering::Acquire) }
197
198#[inline]
199pub fn set_encryption_enabled(enabled: bool) { ENCRYPTION_ENABLED.store(enabled, Ordering::Release); }
200
201pub fn resolve_encryption_enabled(
215 encryption_enabled_row: Option<&str>,
216 security_type_row: Option<&str>,
217) -> bool {
218 match encryption_enabled_row {
219 Some("false") => false,
220 Some(_) => true,
221 None => security_type_row.is_some(),
222 }
223}
224
225pub fn resolve_encryption_enabled_from_db() -> bool {
228 let enc = crate::db::get_sql_setting("encryption_enabled".to_string()).ok().flatten();
229 let sec = crate::db::get_sql_setting("security_type".to_string()).ok().flatten();
230 resolve_encryption_enabled(enc.as_deref(), sec.as_deref())
231}
232
233pub fn init_encryption_enabled() {
234 let enabled = resolve_encryption_enabled_from_db();
235 set_encryption_enabled(enabled);
236}
237
238#[cfg(test)]
239mod resolve_encryption_enabled_tests {
240 use super::*;
241
242 #[test]
243 fn explicit_false_wins_even_with_security_type() {
244 assert!(!resolve_encryption_enabled(Some("false"), Some("password")));
245 }
246
247 #[test]
248 fn explicit_true_is_encrypted() {
249 assert!(resolve_encryption_enabled(Some("true"), None));
250 }
251
252 #[test]
253 fn missing_row_defaults_to_security_type_presence() {
254 assert!(resolve_encryption_enabled(None, Some("password")));
257 assert!(!resolve_encryption_enabled(None, None));
259 }
260
261 #[test]
262 fn explicit_non_false_value_is_encrypted() {
263 assert!(resolve_encryption_enabled(Some("1"), None));
266 assert!(resolve_encryption_enabled(Some(""), None));
267 }
268}
269
270pub static NOSTR_CLIENT: LazyLock<RwLock<Option<Client>>> =
279 LazyLock::new(|| RwLock::new(None));
280
281pub static MY_SECRET_KEY: crate::crypto::GuardedKey = crate::crypto::GuardedKey::empty();
282
283pub static MY_PUBLIC_KEY: LazyLock<RwLock<Option<PublicKey>>> =
284 LazyLock::new(|| RwLock::new(None));
285
286#[inline]
290pub fn nostr_client() -> Option<Client> {
291 NOSTR_CLIENT.read().unwrap().as_ref().cloned()
292}
293
294#[inline]
296pub fn has_active_session() -> bool {
297 NOSTR_CLIENT.read().unwrap().is_some()
298}
299
300#[inline]
302pub fn my_public_key() -> Option<PublicKey> {
303 *MY_PUBLIC_KEY.read().unwrap()
304}
305
306#[inline]
310pub fn set_nostr_client(client: Client) {
311 *NOSTR_CLIENT.write().unwrap() = Some(client);
312}
313
314#[inline]
316pub fn set_my_public_key(pk: PublicKey) {
317 *MY_PUBLIC_KEY.write().unwrap() = Some(pk);
318}
319
320#[inline]
324pub fn take_nostr_client() -> Option<Client> {
325 NOSTR_CLIENT.write().unwrap().take()
326}
327
328#[inline]
331pub fn clear_my_public_key() {
332 *MY_PUBLIC_KEY.write().unwrap() = None;
333}
334
335#[derive(Clone)]
336pub struct PendingInviteAcceptance {
337 pub invite_code: String,
338 pub inviter_pubkey: PublicKey,
339}
340
341pub static PENDING_INVITE: LazyLock<RwLock<Option<PendingInviteAcceptance>>> =
345 LazyLock::new(|| RwLock::new(None));
346
347#[inline]
348pub fn pending_invite() -> Option<PendingInviteAcceptance> {
349 PENDING_INVITE.read().unwrap().clone()
350}
351
352#[inline]
353pub fn set_pending_invite(invite: PendingInviteAcceptance) {
354 *PENDING_INVITE.write().unwrap() = Some(invite);
355}
356
357#[inline]
358pub fn clear_pending_invite() {
359 *PENDING_INVITE.write().unwrap() = None;
360}
361
362pub static NOTIFIED_WELCOMES: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
363
364static SESSION_GENERATION: AtomicU64 = AtomicU64::new(0);
379
380static DELETED_MESSAGE_TOMBSTONES: LazyLock<std::sync::Mutex<HashSet<String>>> =
386 LazyLock::new(|| std::sync::Mutex::new(HashSet::new()));
387
388pub fn note_message_deleted(message_id: &str) {
390 if let Ok(mut set) = DELETED_MESSAGE_TOMBSTONES.lock() {
391 set.insert(message_id.to_string());
392 }
393}
394
395pub fn was_message_deleted(message_id: &str) -> bool {
397 DELETED_MESSAGE_TOMBSTONES.lock().map(|s| s.contains(message_id)).unwrap_or(false)
398}
399
400#[inline]
402pub fn current_session_generation() -> u64 {
403 SESSION_GENERATION.load(Ordering::Acquire)
404}
405
406#[inline]
410pub fn bump_session_generation() -> u64 {
411 if let Ok(mut set) = DELETED_MESSAGE_TOMBSTONES.lock() {
412 set.clear();
413 }
414 SESSION_GENERATION.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
415}
416
417#[derive(Copy, Clone, Debug)]
423pub struct SessionGuard {
424 generation: u64,
425}
426
427impl SessionGuard {
428 #[inline]
430 pub fn capture() -> Self {
431 Self { generation: current_session_generation() }
432 }
433
434 #[inline]
438 pub fn is_valid(&self) -> bool {
439 self.generation == current_session_generation()
440 }
441
442 #[inline]
444 pub fn generation(&self) -> u64 {
445 self.generation
446 }
447}
448
449#[cfg(test)]
450mod session_generation_tests {
451 use super::*;
452
453 #[test]
454 fn guard_is_valid_when_no_swap_occurred() {
455 let guard = SessionGuard::capture();
456 assert!(guard.is_valid());
457 }
458
459 #[test]
460 fn guard_invalidates_after_bump() {
461 let guard = SessionGuard::capture();
462 bump_session_generation();
463 assert!(!guard.is_valid(), "guard must invalidate after a swap");
464 }
465
466 #[test]
467 fn bump_advances_counter_monotonically() {
468 let before = current_session_generation();
469 let after = bump_session_generation();
470 assert_eq!(after, before.wrapping_add(1));
471 assert_eq!(current_session_generation(), after);
472 }
473}
474
475#[cfg(test)]
476mod session_globals_tests {
477 use super::*;
478
479 #[test]
483 fn session_helpers_round_trip_and_clear() {
484 clear_my_public_key();
488 clear_pending_invite();
489
490 let keys = Keys::parse(
495 "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
496 ).expect("parse test nsec");
497 let pk = keys.public_key();
498
499 assert_eq!(my_public_key(), None, "starts as None");
500
501 set_my_public_key(pk);
502 assert_eq!(my_public_key(), Some(pk));
503
504 clear_my_public_key();
505 assert_eq!(my_public_key(), None, "cleared returns None");
506
507 let invite = PendingInviteAcceptance {
509 invite_code: "abc123".to_string(),
510 inviter_pubkey: pk,
511 };
512
513 assert!(pending_invite().is_none(), "starts as None");
514
515 set_pending_invite(invite.clone());
516 let got = pending_invite().expect("set then read");
517 assert_eq!(got.invite_code, invite.invite_code);
518 assert_eq!(got.inviter_pubkey, invite.inviter_pubkey);
519
520 clear_pending_invite();
521 assert!(pending_invite().is_none(), "cleared returns None");
522
523 assert!(!has_active_session(), "no client installed");
526 assert!(take_nostr_client().is_none(), "take from empty returns None");
527 assert!(!has_active_session(), "still none after take-of-empty");
528
529 clear_pending_bunker_setup();
534 assert!(pending_bunker_setup().is_none(), "starts as None");
535
536 let url = "bunker://0123456789abcdef?relay=wss%3A%2F%2Frelay.example&secret=topsecret".to_string();
537 let pk_hex = "0123456789abcdef".to_string();
538 set_pending_bunker_setup(url.clone(), pk_hex.clone());
539
540 let first = pending_bunker_setup().expect("first peek");
543 let second = pending_bunker_setup().expect("second peek");
544 assert_eq!(first.0, url, "url survives clone-out from Zeroizing");
545 assert_eq!(first.0, second.0, "successive peeks return same data");
546 assert_eq!(first.1, pk_hex);
547
548 let url2 = "bunker://feedface?relay=wss%3A%2F%2Falt".to_string();
552 set_pending_bunker_setup(url2.clone(), "feedface".to_string());
553 let after = pending_bunker_setup().expect("overwritten read");
554 assert_eq!(after.0, url2);
555 assert_eq!(after.1, "feedface");
556
557 clear_pending_bunker_setup();
558 assert!(pending_bunker_setup().is_none(), "cleared returns None");
559
560 clear_pending_nip55_setup();
564 assert!(pending_nip55_setup().is_none(), "starts as None");
565
566 let user_pk = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string();
567 let package = "com.greenart7c3.nostrsigner".to_string();
568 set_pending_nip55_setup(user_pk.clone(), package.clone());
569 let peek1 = pending_nip55_setup().expect("first peek");
570 let peek2 = pending_nip55_setup().expect("second peek");
571 assert_eq!(peek1.0, user_pk);
572 assert_eq!(peek1.1, package);
573 assert_eq!(peek1, peek2, "successive peeks return same data");
574
575 set_pending_nip55_setup("beef".to_string(), "org.other.signer".to_string());
577 let after55 = pending_nip55_setup().expect("overwritten read");
578 assert_eq!(after55.0, "beef");
579 assert_eq!(after55.1, "org.other.signer");
580
581 clear_pending_nip55_setup();
582 assert!(pending_nip55_setup().is_none(), "cleared returns None");
583
584 let guard = SessionGuard::capture();
589 assert!(guard.is_valid(), "fresh capture is valid");
590 bump_session_generation();
591 assert!(!guard.is_valid(),
592 "captured guard must invalidate after generation bump");
593 }
594}
595
596pub static WRAPPER_ID_CACHE: LazyLock<Mutex<WrapperIdCache>> = LazyLock::new(|| Mutex::new(WrapperIdCache::new()));
597
598pub static STATE: LazyLock<Mutex<ChatState>> = LazyLock::new(|| Mutex::new(ChatState::new()));
599
600pub static ACTIVE_CHAT: LazyLock<RwLock<Option<String>>> =
606 LazyLock::new(|| RwLock::new(None));
607
608pub fn set_active_chat(chat_id: Option<String>) {
609 if let Ok(mut guard) = ACTIVE_CHAT.write() {
610 *guard = chat_id;
611 }
612}
613
614pub fn get_active_chat() -> Option<String> {
615 ACTIVE_CHAT.read().ok().and_then(|g| g.clone())
616}
617
618pub static PROCESSING_GATE: AtomicBool = AtomicBool::new(true);
623pub static PENDING_EVENTS: LazyLock<Mutex<Vec<(Event, bool)>>> = LazyLock::new(|| Mutex::new(Vec::new()));
624
625#[inline]
626pub fn is_processing_allowed() -> bool { PROCESSING_GATE.load(Ordering::Acquire) }
627pub fn close_processing_gate() { PROCESSING_GATE.store(false, Ordering::Release); }
628pub fn open_processing_gate() { PROCESSING_GATE.store(true, Ordering::Release); }
629
630#[derive(Clone, Debug)]
635pub struct ChatState {
636 pub profiles: Vec<Profile>,
637 pub chats: Vec<Chat>,
638 pub interner: NpubInterner,
639 pub is_syncing: bool,
640 pub db_loaded: bool,
641 pub unread_cache: std::collections::HashMap<String, u32>,
647 pub unread_seeded: bool,
650 #[cfg(debug_assertions)]
651 pub cache_stats: crate::stats::CacheStats,
652}
653
654impl ChatState {
655 pub fn new() -> Self {
656 Self {
657 profiles: Vec::new(),
658 chats: Vec::new(),
659 interner: NpubInterner::new(),
660 is_syncing: false,
661 db_loaded: false,
662 unread_cache: std::collections::HashMap::new(),
663 unread_seeded: false,
664 #[cfg(debug_assertions)]
665 cache_stats: crate::stats::CacheStats::new(),
666 }
667 }
668
669 pub fn merge_db_profiles(&mut self, slim_profiles: Vec<SlimProfile>, my_npub: &str) {
674 for slim in slim_profiles {
675 let mut full_profile = slim.to_profile();
676 full_profile.flags.set_mine(slim.id == my_npub);
677 self.insert_or_replace_profile(&slim.id, full_profile);
678 }
679 }
680
681 pub fn insert_or_replace_profile(&mut self, npub: &str, mut profile: Profile) {
682 let id = self.interner.intern(npub);
683 profile.id = id;
684 match self.profiles.binary_search_by(|p| p.id.cmp(&id)) {
685 Ok(idx) => self.profiles[idx] = profile,
686 Err(idx) => self.profiles.insert(idx, profile),
687 }
688 }
689
690 pub fn get_profile(&self, npub: &str) -> Option<&Profile> {
691 self.interner.lookup(npub).and_then(|id| self.get_profile_by_id(id))
692 }
693
694 pub fn get_profile_mut(&mut self, npub: &str) -> Option<&mut Profile> {
695 self.interner.lookup(npub).and_then(move |id| self.get_profile_mut_by_id(id))
696 }
697
698 #[inline]
699 pub fn get_profile_by_id(&self, id: u16) -> Option<&Profile> {
700 self.profiles.binary_search_by(|p| p.id.cmp(&id)).ok().map(|idx| &self.profiles[idx])
701 }
702
703 #[inline]
704 pub fn get_profile_mut_by_id(&mut self, id: u16) -> Option<&mut Profile> {
705 self.profiles.binary_search_by(|p| p.id.cmp(&id)).ok().map(move |idx| &mut self.profiles[idx])
706 }
707
708 pub fn serialize_profile(&self, id: u16) -> Option<SlimProfile> {
709 self.get_profile_by_id(id).map(|p| SlimProfile::from_profile(p, &self.interner))
710 }
711
712 pub fn get_chat(&self, id: &str) -> Option<&Chat> { self.chats.iter().find(|c| c.id == id) }
717 pub fn get_chat_mut(&mut self, id: &str) -> Option<&mut Chat> { self.chats.iter_mut().find(|c| c.id == id) }
718
719 pub fn create_dm_chat(&mut self, their_npub: &str) -> String {
720 if self.get_chat(their_npub).is_none() {
721 let chat = Chat::new_dm(their_npub.to_string(), &mut self.interner);
722 self.chats.push(chat);
723 }
724 their_npub.to_string()
725 }
726
727 pub fn ensure_community_chat(&mut self, channel_id: &str) {
733 if !self.chats.iter().any(|c| c.id == channel_id) {
734 let chat =
735 Chat::new_community_channel(channel_id.to_string(), Vec::new(), &mut self.interner);
736 self.chats.push(chat);
737 }
738 }
739
740 pub fn upsert_community_chat(
751 &mut self,
752 channel_id: &str,
753 name: &str,
754 description: &str,
755 community_id: &str,
756 is_owner: bool,
757 has_icon: bool,
758 owner_npub: Option<&str>,
759 created_at_ms: Option<u64>,
760 dissolved: bool,
761 protocol: crate::community::ConcordProtocol,
762 channel_name: &str,
763 primary_channel: &str,
764 ) {
765 self.ensure_community_chat(channel_id);
766 if let Some(chat) = self.chats.iter_mut().find(|c| c.id == channel_id) {
767 let cf = &mut chat.metadata.custom_fields;
768 cf.insert("name".to_string(), name.to_string());
769 cf.insert("channel_name".to_string(), channel_name.to_string());
770 cf.insert("primary_channel".to_string(), primary_channel.to_string());
771 cf.insert("description".to_string(), description.to_string());
772 cf.insert("community_id".to_string(), community_id.to_string());
773 cf.insert("is_owner".to_string(), is_owner.to_string());
774 let new_proto = protocol.as_i64();
781 let cur_proto = cf.get("proto_version").and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
782 cf.insert("proto_version".to_string(), new_proto.max(cur_proto).to_string());
783 cf.insert("dissolved".to_string(), dissolved.to_string());
785 if let Some(ms) = created_at_ms {
787 cf.insert("created_at".to_string(), ms.to_string());
788 }
789 match owner_npub {
791 Some(n) => { cf.insert("owner_npub".to_string(), n.to_string()); }
792 None => { cf.remove("owner_npub"); }
793 }
794 if has_icon {
795 cf.insert("icon".to_string(), "1".to_string());
796 } else {
797 cf.remove("icon");
798 }
799 }
800 }
801
802 pub fn add_message_to_chat(&mut self, chat_id: &str, message: &Message) -> bool {
803 let compact = CompactMessage::from_message(message, &mut self.interner);
804
805 let (is_msg_added, chat_idx) = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
806 let added = self.chats[idx].add_compact_message(compact);
807 (added, idx)
808 } else {
809 let mut chat = if chat_id.starts_with("npub1") {
810 Chat::new_dm(chat_id.to_string(), &mut self.interner)
811 } else {
812 Chat::new(chat_id.to_string(), ChatType::Community, vec![])
813 };
814 let was_added = chat.add_compact_message(compact);
815 self.chats.push(chat);
816 (was_added, self.chats.len() - 1)
817 };
818
819 if is_msg_added && chat_idx > 0 {
820 let this_time = self.chats[chat_idx].last_message_time();
821 let target = self.chats[..chat_idx].iter()
822 .position(|c| c.last_message_time() <= this_time)
823 .unwrap_or(chat_idx);
824 if target < chat_idx {
825 self.chats[target..=chat_idx].rotate_right(1);
826 }
827 }
828
829 is_msg_added
830 }
831
832 pub fn add_messages_to_chat_batch(&mut self, chat_id: &str, messages: Vec<Message>) -> usize {
833 if messages.is_empty() { return 0; }
834
835 let compact_messages: Vec<_> = messages.into_iter()
836 .map(|msg| CompactMessage::from_message_owned(msg, &mut self.interner))
837 .collect();
838
839 let chat_idx = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
840 idx
841 } else {
842 let chat = if chat_id.starts_with("npub1") {
843 Chat::new_dm(chat_id.to_string(), &mut self.interner)
844 } else {
845 Chat::new(chat_id.to_string(), ChatType::Community, vec![])
846 };
847 self.chats.push(chat);
848 self.chats.len() - 1
849 };
850
851 let old_last_time = self.chats[chat_idx].messages.last_timestamp();
852 let added = self.chats[chat_idx].messages.insert_batch(compact_messages);
853
854 if added > 0 && self.chats[chat_idx].messages.last_timestamp() != old_last_time && chat_idx > 0 {
855 let this_time = self.chats[chat_idx].last_message_time();
856 let target = self.chats[..chat_idx].iter()
857 .position(|c| c.last_message_time() <= this_time)
858 .unwrap_or(chat_idx);
859 if target < chat_idx {
860 self.chats[target..=chat_idx].rotate_right(1);
861 }
862 }
863
864 added
865 }
866
867 pub fn add_message_to_participant(&mut self, their_npub: &str, message: &Message) -> bool {
871 let id = self.interner.intern(their_npub);
872 if self.get_profile_by_id(id).is_none() {
873 let profile = Profile::new();
874 self.insert_or_replace_profile(their_npub, profile);
875
876 if let Some(slim) = self.serialize_profile(id) {
878 emit_event("profile_update", &slim);
879 }
880 }
881
882 let chat_id = self.create_dm_chat(their_npub);
883 self.add_message_to_chat(&chat_id, message)
884 }
885
886 pub fn find_message(&self, message_id: &str) -> Option<(&Chat, Message)> {
891 if message_id.is_empty() { return None; }
892 for chat in &self.chats {
893 if let Some(compact) = chat.get_compact_message(message_id) {
894 return Some((chat, compact.to_message(&self.interner)));
895 }
896 }
897 None
898 }
899
900 pub fn find_chat_for_message(&self, message_id: &str) -> Option<(usize, String)> {
901 if message_id.is_empty() { return None; }
902 for (idx, chat) in self.chats.iter().enumerate() {
903 if chat.has_message(message_id) { return Some((idx, chat.id.clone())); }
904 }
905 None
906 }
907
908 pub fn update_message<F>(&mut self, message_id: &str, f: F) -> Option<(String, Message)>
909 where F: FnOnce(&mut CompactMessage)
910 {
911 if message_id.is_empty() { return None; }
912 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
913 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
914 let chat_id = self.chats[chat_idx].id.clone();
915 self.chats[chat_idx].get_compact_message(message_id).map(|m| (chat_id, m.to_message(&self.interner)))
916 }
917
918 pub fn update_message_in_chat<F>(&mut self, chat_id: &str, message_id: &str, f: F) -> Option<Message>
919 where F: FnOnce(&mut CompactMessage)
920 {
921 let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
922 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
923 self.chats[chat_idx].get_compact_message(message_id).map(|m| m.to_message(&self.interner))
924 }
925
926 pub fn finalize_pending_message(&mut self, chat_id: &str, pending_id: &str, real_id: &str) -> Option<(String, Message)> {
927 let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
928 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(pending_id) {
929 msg.id = crate::simd::hex::hex_to_bytes_32(real_id);
930 msg.set_pending(false);
931 }
932 self.chats[chat_idx].messages.rebuild_index();
933 self.chats[chat_idx].get_compact_message(real_id)
934 .map(|m| (pending_id.to_string(), m.to_message(&self.interner)))
935 }
936
937 pub fn update_attachment<F>(&mut self, chat_hint: &str, msg_id: &str, attachment_id: &str, f: F) -> bool
938 where F: FnOnce(&mut CompactAttachment)
939 {
940 for chat in &mut self.chats {
941 let is_target = match &chat.chat_type {
942 ChatType::Community => chat.id == chat_hint,
944 ChatType::DirectMessage => chat.has_participant(chat_hint, &self.interner),
945 };
946 if is_target {
947 if let Some(msg) = chat.messages.find_by_hex_id_mut(msg_id) {
948 if let Some(att) = msg.attachments.iter_mut().find(|a| a.id_eq(attachment_id)) {
949 f(att);
950 return true;
951 }
952 }
953 }
954 }
955 false
956 }
957
958 pub fn add_attachment_to_message(&mut self, chat_id: &str, msg_id: &str, attachment: CompactAttachment) -> bool {
959 let chat_idx = match self.chats.iter().position(|c| c.id == chat_id || c.has_participant(chat_id, &self.interner)) {
960 Some(idx) => idx,
961 None => return false,
962 };
963 if let Some(msg) = self.chats[chat_idx].messages.find_by_hex_id_mut(msg_id) {
964 msg.attachments.push(attachment);
965 true
966 } else { false }
967 }
968
969 pub fn add_reaction_to_message(&mut self, message_id: &str, reaction: Reaction) -> Option<(String, bool)> {
970 if message_id.is_empty() { return None; }
971 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
972 let chat_id = self.chats[chat_idx].id.clone();
973 let msg = self.chats[chat_idx].get_compact_message_mut(message_id)?;
974 let added = msg.add_reaction(reaction, &mut self.interner);
975 Some((chat_id, added))
976 }
977
978 pub fn find_reaction(&self, reaction_id: &str) -> Option<(String, String, String, bool)> {
981 if reaction_id.is_empty() { return None; }
982 let target = crate::simd::hex::hex_to_bytes_32(reaction_id);
983 for chat in &self.chats {
984 for msg in chat.iter_compact() {
985 if let Some(r) = msg.reactions.iter().find(|r| r.id == target) {
986 let author = self.interner.resolve(r.author_idx).unwrap_or("").to_string();
987 return Some((chat.id.clone(), msg.id_hex(), author, chat.is_community()));
988 }
989 }
990 }
991 None
992 }
993
994 pub fn remove_reaction_from_message(&mut self, message_id: &str, reaction_id: &str) -> Option<(String, Message)> {
997 if message_id.is_empty() { return None; }
998 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
999 let removed = self.chats[chat_idx]
1000 .get_compact_message_mut(message_id)
1001 .map(|m| m.remove_reaction(reaction_id))
1002 .unwrap_or(false);
1003 if !removed { return None; }
1004 let chat_id = self.chats[chat_idx].id.clone();
1005 self.chats[chat_idx]
1006 .get_compact_message(message_id)
1007 .map(|m| (chat_id, m.to_message(&self.interner)))
1008 }
1009
1010 pub fn remove_message(&mut self, message_id: &str) -> Option<(String, Message)> {
1011 if message_id.is_empty() { return None; }
1012 for chat in &mut self.chats {
1013 if let Some(compact) = chat.messages.find_by_hex_id(message_id) {
1014 let msg = compact.to_message(&self.interner);
1015 let removed_id = compact.id;
1016 let removed_at = compact.at;
1017 let chat_id = chat.id.clone();
1018 let was_marker = chat.last_read == removed_id;
1019 chat.messages.remove_by_hex_id(message_id);
1020 if was_marker {
1024 chat.last_read = chat.messages.iter().rev()
1025 .find(|m| m.at <= removed_at && !m.flags.is_mine())
1026 .map(|m| m.id)
1027 .unwrap_or([0u8; 32]);
1028 }
1029 return Some((chat_id, msg));
1030 }
1031 }
1032 None
1033 }
1034
1035 pub fn message_exists(&self, message_id: &str) -> bool {
1036 !message_id.is_empty() && self.chats.iter().any(|chat| chat.has_message(message_id))
1037 }
1038
1039 pub fn sum_unread_from(&self, counts: &std::collections::HashMap<String, u32>) -> u32 {
1048 let mut total = 0u32;
1049 for chat in &self.chats {
1050 if chat.muted {
1051 continue;
1052 }
1053 if !chat.is_community() {
1054 if let Some(id) = self.interner.lookup(&chat.id) {
1055 if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) {
1056 continue;
1057 }
1058 }
1059 } else if !chat.is_surfaced_community_channel() {
1060 continue;
1064 }
1065 total += counts.get(&chat.id).copied().unwrap_or(0);
1066 }
1067 total
1068 }
1069
1070 pub fn unread_seed(&mut self, counts: std::collections::HashMap<String, u32>) {
1078 self.unread_cache = counts;
1079 self.unread_seeded = true;
1080 }
1081
1082 pub fn unread_clear(&mut self, chat_id: &str) {
1084 self.unread_cache.remove(chat_id);
1085 }
1086
1087 pub fn unread_set(&mut self, chat_id: &str, count: u32) {
1090 if count == 0 {
1091 self.unread_cache.remove(chat_id);
1092 } else {
1093 self.unread_cache.insert(chat_id.to_string(), count);
1094 }
1095 }
1096
1097 pub fn sum_unread(&self) -> u32 {
1100 self.sum_unread_from(&self.unread_cache)
1101 }
1102
1103 pub fn unread_snapshot(&self) -> std::collections::HashMap<String, u32> {
1105 self.unread_cache.clone()
1106 }
1107
1108 pub fn count_unread_messages(&self) -> u32 {
1109 let mut total_unread = 0;
1110 for chat in &self.chats {
1111 if chat.muted { continue; }
1112 let is_group = chat.is_community();
1113 if !is_group {
1114 if let Some(id) = self.interner.lookup(&chat.id) {
1115 if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) { continue; }
1116 }
1117 } else if !chat.is_surfaced_community_channel() {
1118 continue;
1120 }
1121 let mut unread_count = 0u32;
1122 for msg in chat.iter_compact().rev() {
1123 if msg.flags.is_mine() { break; }
1124 if chat.last_read != [0u8; 32] && msg.id == chat.last_read { break; }
1125 if is_group && msg.npub_idx != NO_NPUB {
1126 if self.get_profile_by_id(msg.npub_idx).map_or(false, |p| p.flags.is_blocked()) { continue; }
1127 }
1128 unread_count += 1;
1129 }
1130 #[cfg(debug_assertions)]
1132 if unread_count > 0 {
1133 let last_read_hex = crate::compact::decode_message_id(&chat.last_read);
1134 let last_msg_hex = chat.messages.last().map(|m| crate::compact::decode_message_id(&m.id)).unwrap_or_default();
1135 let msg_count = chat.message_count();
1136 eprintln!("[Unread] chat={} unread={} msgs_in_memory={} last_read={} last_msg={}",
1137 &chat.id[..20.min(chat.id.len())], unread_count, msg_count,
1138 &last_read_hex[..16.min(last_read_hex.len())], &last_msg_hex[..16.min(last_msg_hex.len())]);
1139 }
1140 total_unread += unread_count;
1141 }
1142 total_unread
1143 }
1144
1145 pub fn update_typing_and_get_active(&mut self, chat_id: &str, npub: &str, expires_at: u64) -> Vec<String> {
1150 let handle = self.interner.intern(npub);
1151 if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
1152 chat.update_typing_participant(handle, expires_at);
1153 chat.get_active_typers(&self.interner)
1154 } else {
1155 Vec::new()
1156 }
1157 }
1158}
1159
1160impl Default for ChatState {
1161 fn default() -> Self { Self::new() }
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use super::*;
1167 use crate::types::Message;
1168 use crate::profile::{Profile, SlimProfile, Status};
1169 use crate::simd::hex::bytes_to_hex_32;
1170
1171 fn make_hex_id(seed: u8) -> String {
1178 let mut bytes = [seed; 32];
1179 bytes[0] = seed.wrapping_add(0x10) | 0x10; bytes[1] = seed.wrapping_mul(37);
1181 bytes_to_hex_32(&bytes)
1182 }
1183
1184 fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
1186 Message {
1187 id: make_hex_id(id_seed),
1188 content: content.to_string(),
1189 at: timestamp_ms,
1190 mine,
1191 ..Default::default()
1192 }
1193 }
1194
1195 fn make_message_from(id_seed: u8, content: &str, timestamp_ms: u64, npub: &str) -> Message {
1197 Message {
1198 id: make_hex_id(id_seed),
1199 content: content.to_string(),
1200 at: timestamp_ms,
1201 mine: false,
1202 npub: Some(npub.to_string()),
1203 ..Default::default()
1204 }
1205 }
1206
1207 fn make_slim_profile(id: &str, name: &str) -> SlimProfile {
1209 SlimProfile {
1210 id: id.to_string(),
1211 name: name.to_string(),
1212 display_name: String::new(),
1213 nickname: String::new(),
1214 lud06: String::new(),
1215 lud16: String::new(),
1216 banner: String::new(),
1217 avatar: String::new(),
1218 about: String::new(),
1219 website: String::new(),
1220 nip05: String::new(),
1221 status: Status::new(),
1222 last_updated: 0,
1223 mine: false,
1224 bot: false,
1225 is_blocked: false,
1226 avatar_cached: String::new(),
1227 banner_cached: String::new(),
1228 }
1229 }
1230
1231 #[test]
1236 fn insert_or_replace_profile_creates_new() {
1237 let mut state = ChatState::new();
1238 let profile = Profile::new();
1239 state.insert_or_replace_profile("npub1alice", profile);
1240
1241 assert!(
1242 state.get_profile("npub1alice").is_some(),
1243 "newly inserted profile should be retrievable"
1244 );
1245 assert_eq!(state.profiles.len(), 1, "should have exactly one profile");
1246 }
1247
1248 #[test]
1249 fn insert_or_replace_profile_updates_existing() {
1250 let mut state = ChatState::new();
1251 let mut p1 = Profile::new();
1252 p1.name = "Alice".to_string().into_boxed_str();
1253 state.insert_or_replace_profile("npub1alice", p1);
1254
1255 let mut p2 = Profile::new();
1256 p2.name = "Alice Updated".to_string().into_boxed_str();
1257 state.insert_or_replace_profile("npub1alice", p2);
1258
1259 let fetched = state.get_profile("npub1alice").expect("profile should exist");
1260 assert_eq!(
1261 &*fetched.name, "Alice Updated",
1262 "profile name should be updated after replace"
1263 );
1264 assert_eq!(state.profiles.len(), 1, "should still be one profile, not duplicated");
1265 }
1266
1267 #[test]
1268 fn get_profile_by_npub() {
1269 let mut state = ChatState::new();
1270 let mut profile = Profile::new();
1271 profile.name = "Bob".to_string().into_boxed_str();
1272 state.insert_or_replace_profile("npub1bob", profile);
1273
1274 let fetched = state.get_profile("npub1bob").expect("profile should be found");
1275 assert_eq!(&*fetched.name, "Bob", "fetched profile name should match");
1276 }
1277
1278 #[test]
1279 fn get_profile_returns_none_for_unknown() {
1280 let state = ChatState::new();
1281 assert!(
1282 state.get_profile("npub1unknown").is_none(),
1283 "unknown npub should return None"
1284 );
1285 }
1286
1287 #[test]
1288 fn get_profile_by_id_works() {
1289 let mut state = ChatState::new();
1290 let mut profile = Profile::new();
1291 profile.name = "Charlie".to_string().into_boxed_str();
1292 state.insert_or_replace_profile("npub1charlie", profile);
1293
1294 let id = state.interner.lookup("npub1charlie").expect("npub should be interned");
1295 let fetched = state.get_profile_by_id(id).expect("profile should be found by id");
1296 assert_eq!(&*fetched.name, "Charlie", "profile looked up by id should match");
1297 }
1298
1299 #[test]
1300 fn get_profile_by_id_returns_none_for_invalid() {
1301 let state = ChatState::new();
1302 assert!(
1303 state.get_profile_by_id(9999).is_none(),
1304 "invalid interner id should return None"
1305 );
1306 }
1307
1308 #[test]
1309 fn merge_db_profiles_sets_mine_flag() {
1310 let mut state = ChatState::new();
1311 let slim_mine = make_slim_profile("npub1me", "Me");
1312 let slim_other = make_slim_profile("npub1other", "Other");
1313
1314 state.merge_db_profiles(vec![slim_mine, slim_other], "npub1me");
1315
1316 let me = state.get_profile("npub1me").expect("my profile should exist");
1317 assert!(me.flags.is_mine(), "my profile should have mine flag set");
1318
1319 let other = state.get_profile("npub1other").expect("other profile should exist");
1320 assert!(!other.flags.is_mine(), "other profile should not have mine flag");
1321 }
1322
1323 #[test]
1324 fn serialize_profile_roundtrip() {
1325 let mut state = ChatState::new();
1326 let mut profile = Profile::new();
1327 profile.name = "Roundtrip".to_string().into_boxed_str();
1328 profile.about = "Test about".to_string().into_boxed_str();
1329 profile.flags.set_blocked(true);
1330 state.insert_or_replace_profile("npub1round", profile);
1331
1332 let id = state.interner.lookup("npub1round").unwrap();
1333 let slim = state.serialize_profile(id).expect("serialization should succeed");
1334
1335 assert_eq!(slim.id, "npub1round", "serialized id should match");
1336 assert_eq!(slim.name, "Roundtrip", "serialized name should match");
1337 assert_eq!(slim.about, "Test about", "serialized about should match");
1338 assert!(slim.is_blocked, "serialized blocked flag should be true");
1339
1340 let restored = slim.to_profile();
1342 assert_eq!(&*restored.name, "Roundtrip", "restored name should match");
1343 assert!(restored.flags.is_blocked(), "restored blocked flag should be true");
1344 }
1345
1346 #[test]
1347 fn binary_search_maintains_sorted_order_with_100_profiles() {
1348 let mut state = ChatState::new();
1349
1350 let npubs: Vec<String> = (0..100).map(|i| format!("npub1user{:04}", i)).collect();
1352 let mut shuffled = npubs.clone();
1353 for i in (1..shuffled.len()).rev() {
1355 let j = (i * 37 + 13) % (i + 1);
1356 shuffled.swap(i, j);
1357 }
1358
1359 for npub in &shuffled {
1360 let mut profile = Profile::new();
1361 profile.name = npub.clone().into_boxed_str();
1362 state.insert_or_replace_profile(npub, profile);
1363 }
1364
1365 for npub in &npubs {
1367 assert!(
1368 state.get_profile(npub).is_some(),
1369 "profile {} should be retrievable after bulk insert",
1370 npub
1371 );
1372 }
1373
1374 for window in state.profiles.windows(2) {
1376 assert!(
1377 window[0].id < window[1].id,
1378 "profiles should be sorted by interner id"
1379 );
1380 }
1381
1382 assert_eq!(state.profiles.len(), 100, "should have exactly 100 profiles");
1383 }
1384
1385 #[test]
1386 fn insert_same_npub_twice_updates_not_duplicates() {
1387 let mut state = ChatState::new();
1388
1389 for i in 0..5 {
1390 let mut profile = Profile::new();
1391 profile.name = format!("version_{}", i).into_boxed_str();
1392 state.insert_or_replace_profile("npub1repeated", profile);
1393 }
1394
1395 assert_eq!(state.profiles.len(), 1, "repeated inserts should not create duplicates");
1396 let p = state.get_profile("npub1repeated").unwrap();
1397 assert_eq!(&*p.name, "version_4", "should retain the last update");
1398 }
1399
1400 #[test]
1401 fn get_profile_mut_modifies_in_place() {
1402 let mut state = ChatState::new();
1403 let profile = Profile::new();
1404 state.insert_or_replace_profile("npub1mutable", profile);
1405
1406 let p = state.get_profile_mut("npub1mutable").expect("profile should exist");
1407 p.name = "Mutated".to_string().into_boxed_str();
1408
1409 let fetched = state.get_profile("npub1mutable").unwrap();
1410 assert_eq!(&*fetched.name, "Mutated", "mutation should persist");
1411 }
1412
1413 #[test]
1418 fn create_dm_chat_creates_new() {
1419 let mut state = ChatState::new();
1420 let id = state.create_dm_chat("npub1peer");
1421
1422 assert_eq!(id, "npub1peer", "returned id should match the npub");
1423 assert!(state.get_chat("npub1peer").is_some(), "chat should be created");
1424 assert_eq!(state.chats.len(), 1, "should have exactly one chat");
1425 }
1426
1427 #[test]
1428 fn create_dm_chat_is_idempotent() {
1429 let mut state = ChatState::new();
1430 state.create_dm_chat("npub1peer");
1431 state.create_dm_chat("npub1peer");
1432 state.create_dm_chat("npub1peer");
1433
1434 assert_eq!(state.chats.len(), 1, "repeated creates should not duplicate");
1435 }
1436
1437 #[test]
1438 fn ensure_community_chat_idempotent() {
1439 let mut state = ChatState::new();
1440 state.ensure_community_chat("grp1");
1441 state.ensure_community_chat("grp1");
1442
1443 assert_eq!(state.chats.len(), 1, "second call should not create a duplicate");
1444 let chat = state.get_chat("grp1").expect("community chat should exist");
1445 assert!(chat.is_community(), "should be a Community chat");
1446 }
1447
1448 #[test]
1449 fn get_chat_by_id() {
1450 let mut state = ChatState::new();
1451 state.create_dm_chat("npub1x");
1452
1453 let chat = state.get_chat("npub1x").expect("chat should exist");
1454 assert_eq!(chat.id, "npub1x", "chat id should match");
1455 }
1456
1457 #[test]
1458 fn get_chat_returns_none_for_missing() {
1459 let state = ChatState::new();
1460 assert!(state.get_chat("nonexistent").is_none(), "missing chat should return None");
1461 }
1462
1463 #[test]
1464 fn get_chat_mut_modifies_in_place() {
1465 let mut state = ChatState::new();
1466 state.create_dm_chat("npub1editable");
1467
1468 let chat = state.get_chat_mut("npub1editable").expect("chat should exist");
1469 chat.muted = true;
1470
1471 let refetched = state.get_chat("npub1editable").unwrap();
1472 assert!(refetched.muted, "muted flag should persist after mutation");
1473 }
1474
1475 #[test]
1476 fn multiple_different_chats() {
1477 let mut state = ChatState::new();
1478 state.create_dm_chat("npub1alice");
1479 state.create_dm_chat("npub1bob");
1480 state.ensure_community_chat("grp1");
1481
1482 assert_eq!(state.chats.len(), 3, "should have three distinct chats");
1483 }
1484
1485 #[test]
1490 fn add_message_to_chat_single() {
1491 let mut state = ChatState::new();
1492 state.create_dm_chat("npub1peer");
1493
1494 let msg = make_message(1, "hello", 1700000000000, false);
1495 let added = state.add_message_to_chat("npub1peer", &msg);
1496
1497 assert!(added, "first message should be added successfully");
1498 let chat = state.get_chat("npub1peer").unwrap();
1499 assert_eq!(chat.message_count(), 1, "chat should have one message");
1500 }
1501
1502 #[test]
1503 fn add_message_to_chat_dedup_rejects_same_id() {
1504 let mut state = ChatState::new();
1505 state.create_dm_chat("npub1peer");
1506
1507 let msg1 = make_message(1, "hello", 1700000000000, false);
1508 let msg2 = make_message(1, "duplicate", 1700000001000, false);
1509
1510 let added1 = state.add_message_to_chat("npub1peer", &msg1);
1511 let added2 = state.add_message_to_chat("npub1peer", &msg2);
1512
1513 assert!(added1, "first insert should succeed");
1514 assert!(!added2, "duplicate ID should be rejected");
1515 assert_eq!(
1516 state.get_chat("npub1peer").unwrap().message_count(), 1,
1517 "should still have only one message"
1518 );
1519 }
1520
1521 #[test]
1522 fn add_messages_to_chat_batch_works() {
1523 let mut state = ChatState::new();
1524 state.create_dm_chat("npub1peer");
1525
1526 let msgs: Vec<Message> = (0..10).map(|i| {
1527 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1528 }).collect();
1529
1530 let added = state.add_messages_to_chat_batch("npub1peer", msgs);
1531 assert_eq!(added, 10, "all 10 messages should be added");
1532 assert_eq!(
1533 state.get_chat("npub1peer").unwrap().message_count(), 10,
1534 "chat should have 10 messages"
1535 );
1536 }
1537
1538 #[test]
1539 fn add_messages_to_chat_batch_dedup() {
1540 let mut state = ChatState::new();
1541 state.create_dm_chat("npub1peer");
1542
1543 let msgs1: Vec<Message> = (0..5).map(|i| {
1545 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1546 }).collect();
1547 state.add_messages_to_chat_batch("npub1peer", msgs1);
1548
1549 let msgs2: Vec<Message> = (3..8).map(|i| {
1551 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1552 }).collect();
1553 let added = state.add_messages_to_chat_batch("npub1peer", msgs2);
1554
1555 assert_eq!(added, 3, "only 3 new messages should be added (5, 6, 7)");
1556 assert_eq!(
1557 state.get_chat("npub1peer").unwrap().message_count(), 8,
1558 "total should be 8 unique messages"
1559 );
1560 }
1561
1562 #[test]
1563 fn add_message_to_participant_creates_profile_and_chat() {
1564 let mut state = ChatState::new();
1565
1566 let msg = make_message(1, "hi there", 1700000000000, false);
1567 let added = state.add_message_to_participant("npub1stranger", &msg);
1568
1569 assert!(added, "message should be added");
1570 assert!(
1571 state.get_profile("npub1stranger").is_some(),
1572 "profile should be auto-created for unknown participant"
1573 );
1574 assert!(
1575 state.get_chat("npub1stranger").is_some(),
1576 "DM chat should be auto-created"
1577 );
1578 }
1579
1580 #[test]
1581 fn add_message_to_participant_uses_existing_profile() {
1582 let mut state = ChatState::new();
1583
1584 let mut profile = Profile::new();
1586 profile.name = "Known User".to_string().into_boxed_str();
1587 state.insert_or_replace_profile("npub1known", profile);
1588
1589 let msg = make_message(1, "hello", 1700000000000, false);
1590 state.add_message_to_participant("npub1known", &msg);
1591
1592 let p = state.get_profile("npub1known").unwrap();
1594 assert_eq!(&*p.name, "Known User", "existing profile should not be overwritten");
1595 }
1596
1597 #[test]
1598 fn find_message_across_chats() {
1599 let mut state = ChatState::new();
1600 state.create_dm_chat("npub1a");
1601 state.create_dm_chat("npub1b");
1602
1603 let msg_a = make_message(1, "in chat a", 1700000000000, false);
1604 let msg_b = make_message(2, "in chat b", 1700000001000, false);
1605 let msg_id_b = msg_b.id.clone();
1606
1607 state.add_message_to_chat("npub1a", &msg_a);
1608 state.add_message_to_chat("npub1b", &msg_b);
1609
1610 let (chat, found_msg) = state.find_message(&msg_id_b).expect("message should be found");
1611 assert_eq!(chat.id, "npub1b", "should find in correct chat");
1612 assert_eq!(found_msg.content, "in chat b", "content should match");
1613 }
1614
1615 #[test]
1616 fn find_message_returns_none_for_unknown() {
1617 let state = ChatState::new();
1618 assert!(
1619 state.find_message(&make_hex_id(99)).is_none(),
1620 "unknown message id should return None"
1621 );
1622 }
1623
1624 #[test]
1625 fn find_message_empty_id_returns_none() {
1626 let state = ChatState::new();
1627 assert!(state.find_message("").is_none(), "empty id should return None");
1628 }
1629
1630 #[test]
1631 fn update_message_mutates_and_returns() {
1632 let mut state = ChatState::new();
1633 state.create_dm_chat("npub1peer");
1634
1635 let msg = make_message(1, "original", 1700000000000, false);
1636 let msg_id = msg.id.clone();
1637 state.add_message_to_chat("npub1peer", &msg);
1638
1639 let result = state.update_message(&msg_id, |cm| {
1640 cm.content = "updated content".to_string().into_boxed_str();
1641 });
1642
1643 let (chat_id, updated) = result.expect("update should return Some");
1644 assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1645 assert_eq!(updated.content, "updated content", "content should be updated");
1646 }
1647
1648 #[test]
1649 fn update_message_returns_none_for_missing() {
1650 let mut state = ChatState::new();
1651 let result = state.update_message(&make_hex_id(99), |_cm| {});
1652 assert!(result.is_none(), "updating nonexistent message should return None");
1653 }
1654
1655 #[test]
1656 fn finalize_pending_message_changes_id() {
1657 let mut state = ChatState::new();
1658 state.create_dm_chat("npub1peer");
1659
1660 let mut msg = make_message(1, "pending msg", 1700000000000, true);
1661 msg.pending = true;
1662 let pending_id = msg.id.clone();
1663 state.add_message_to_chat("npub1peer", &msg);
1664
1665 let real_id = make_hex_id(2);
1666 let result = state.finalize_pending_message("npub1peer", &pending_id, &real_id);
1667
1668 let (old_id, finalized) = result.expect("finalize should succeed");
1669 assert_eq!(old_id, pending_id, "should return old pending id");
1670 assert_eq!(finalized.id, real_id, "message id should now be the real id");
1671 assert!(!finalized.pending, "message should no longer be pending");
1672
1673 assert!(
1675 state.find_message(&pending_id).is_none(),
1676 "pending id should no longer resolve"
1677 );
1678 assert!(
1680 state.find_message(&real_id).is_some(),
1681 "real id should now resolve"
1682 );
1683 }
1684
1685 #[test]
1686 fn remove_message_works() {
1687 let mut state = ChatState::new();
1688 state.create_dm_chat("npub1peer");
1689
1690 let msg = make_message(1, "deleteme", 1700000000000, false);
1691 let msg_id = msg.id.clone();
1692 state.add_message_to_chat("npub1peer", &msg);
1693
1694 let result = state.remove_message(&msg_id);
1695 assert!(result.is_some(), "remove should return the removed message");
1696
1697 let (chat_id, removed) = result.unwrap();
1698 assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1699 assert_eq!(removed.content, "deleteme", "content should match");
1700
1701 assert!(
1702 state.find_message(&msg_id).is_none(),
1703 "removed message should no longer be findable"
1704 );
1705 }
1706
1707 #[test]
1708 fn remove_message_retreats_last_read_marker() {
1709 let mut state = ChatState::new();
1710 state.create_dm_chat("npub1peer");
1711 let m1 = make_message(1, "one", 1_700_000_000_000, false);
1712 let m2 = make_message(2, "two", 1_700_000_001_000, false);
1713 let (m1_id, m2_id) = (m1.id.clone(), m2.id.clone());
1714 state.add_message_to_chat("npub1peer", &m1);
1715 state.add_message_to_chat("npub1peer", &m2);
1716
1717 state.chats.iter_mut().find(|c| c.id == "npub1peer").unwrap().last_read =
1719 crate::compact::encode_message_id(&m2_id);
1720
1721 state.remove_message(&m2_id);
1723 assert_eq!(state.get_chat("npub1peer").unwrap().last_read,
1724 crate::compact::encode_message_id(&m1_id), "marker retreats to m1");
1725
1726 state.remove_message(&m1_id);
1728 assert_eq!(state.get_chat("npub1peer").unwrap().last_read, [0u8; 32],
1729 "no predecessor → marker clears");
1730 }
1731
1732 #[test]
1733 fn remove_message_returns_none_for_missing() {
1734 let mut state = ChatState::new();
1735 assert!(
1736 state.remove_message(&make_hex_id(99)).is_none(),
1737 "removing nonexistent message should return None"
1738 );
1739 }
1740
1741 #[test]
1742 fn message_exists_check() {
1743 let mut state = ChatState::new();
1744 state.create_dm_chat("npub1peer");
1745
1746 let msg = make_message(1, "exists", 1700000000000, false);
1747 let msg_id = msg.id.clone();
1748 state.add_message_to_chat("npub1peer", &msg);
1749
1750 assert!(state.message_exists(&msg_id), "added message should exist");
1751 assert!(!state.message_exists(&make_hex_id(99)), "unknown id should not exist");
1752 assert!(!state.message_exists(""), "empty id should not exist");
1753 }
1754
1755 #[test]
1756 fn chat_reordering_newest_first_after_message_add() {
1757 let mut state = ChatState::new();
1758 state.create_dm_chat("npub1old");
1759 state.create_dm_chat("npub1new");
1760
1761 let old_msg = make_message(1, "old", 1700000000000, false);
1763 state.add_message_to_chat("npub1old", &old_msg);
1764
1765 let new_msg = make_message(2, "new", 1700000002000, false);
1767 state.add_message_to_chat("npub1new", &new_msg);
1768
1769 assert_eq!(
1770 state.chats[0].id, "npub1new",
1771 "chat with newest message should be first"
1772 );
1773 assert_eq!(
1774 state.chats[1].id, "npub1old",
1775 "chat with older message should be second"
1776 );
1777 }
1778
1779 #[test]
1780 fn batch_add_does_not_reorder_for_old_messages() {
1781 let mut state = ChatState::new();
1782 state.create_dm_chat("npub1active");
1783 state.create_dm_chat("npub1history");
1784
1785 let recent = make_message(1, "recent", 1700000010000, false);
1787 state.add_message_to_chat("npub1active", &recent);
1788
1789 let old_msgs: Vec<Message> = (10..15).map(|i| {
1791 make_message(i, &format!("old {}", i), 1700000000000 + i as u64 * 100, false)
1792 }).collect();
1793 state.add_messages_to_chat_batch("npub1history", old_msgs);
1794
1795 assert_eq!(
1796 state.chats[0].id, "npub1active",
1797 "active chat should remain first when batch has only old messages"
1798 );
1799 }
1800
1801 #[test]
1802 fn stress_test_50_messages_in_5_chats() {
1803 let mut state = ChatState::new();
1804
1805 for i in 0..5 {
1806 state.create_dm_chat(&format!("npub1chat{}", i));
1807 }
1808
1809 let mut total_added = 0;
1810 for i in 0..50u8 {
1811 let chat_idx = i as usize % 5;
1812 let chat_id = format!("npub1chat{}", chat_idx);
1813 let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, i % 3 == 0);
1814 if state.add_message_to_chat(&chat_id, &msg) {
1815 total_added += 1;
1816 }
1817 }
1818
1819 assert_eq!(total_added, 50, "all 50 unique messages should be added");
1820
1821 let total_in_chats: usize = state.chats.iter().map(|c| c.message_count()).sum();
1822 assert_eq!(total_in_chats, 50, "total messages across all chats should be 50");
1823
1824 for i in 0..5 {
1826 let chat = state.get_chat(&format!("npub1chat{}", i)).unwrap();
1827 assert_eq!(
1828 chat.message_count(), 10,
1829 "chat {} should have 10 messages",
1830 i
1831 );
1832 }
1833
1834 for i in 0..50u8 {
1836 assert!(
1837 state.message_exists(&make_hex_id(i)),
1838 "message {} should exist",
1839 i
1840 );
1841 }
1842 }
1843
1844 #[test]
1845 fn add_message_auto_creates_dm_chat() {
1846 let mut state = ChatState::new();
1847
1848 let msg = make_message(1, "auto create", 1700000000000, false);
1850 let added = state.add_message_to_chat("npub1auto", &msg);
1851
1852 assert!(added, "message should be added");
1853 assert!(state.get_chat("npub1auto").is_some(), "DM chat should be auto-created");
1854 }
1855
1856 #[test]
1857 fn add_message_auto_creates_community_chat() {
1858 let mut state = ChatState::new();
1859
1860 let msg = make_message(1, "group msg", 1700000000000, false);
1862 let added = state.add_message_to_chat("group_abc123", &msg);
1863
1864 assert!(added, "message should be added");
1865 let chat = state.get_chat("group_abc123").expect("community chat should be auto-created");
1866 assert!(chat.is_community(), "auto-created non-npub chat should be a Community chat");
1867 }
1868
1869 #[test]
1874 fn count_unread_messages_basic() {
1875 let mut state = ChatState::new();
1876 state.create_dm_chat("npub1peer");
1877
1878 for i in 0..5u8 {
1879 let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false);
1880 state.add_message_to_chat("npub1peer", &msg);
1881 }
1882
1883 assert_eq!(state.count_unread_messages(), 5, "all 5 non-mine messages should be unread");
1884 }
1885
1886 #[test]
1887 fn count_unread_muted_chat_skipped() {
1888 let mut state = ChatState::new();
1889 state.create_dm_chat("npub1muted");
1890
1891 let msg = make_message(1, "muted msg", 1700000000000, false);
1892 state.add_message_to_chat("npub1muted", &msg);
1893
1894 state.get_chat_mut("npub1muted").unwrap().muted = true;
1895
1896 assert_eq!(state.count_unread_messages(), 0, "muted chat should not count toward unread");
1897 }
1898
1899 #[test]
1900 fn count_unread_blocked_user_skipped() {
1901 let mut state = ChatState::new();
1902
1903 let mut profile = Profile::new();
1904 profile.flags.set_blocked(true);
1905 state.insert_or_replace_profile("npub1blocked", profile);
1906 state.create_dm_chat("npub1blocked");
1907
1908 let msg = make_message(1, "blocked msg", 1700000000000, false);
1909 state.add_message_to_chat("npub1blocked", &msg);
1910
1911 assert_eq!(state.count_unread_messages(), 0, "blocked user DM should not count");
1912 }
1913
1914 #[test]
1915 fn unread_cache_seed_clear_set_and_sum() {
1916 let mut state = ChatState::new();
1917 state.create_dm_chat("npub1a");
1918 state.create_dm_chat("npub1b");
1919
1920 let mut seed = std::collections::HashMap::new();
1921 seed.insert("npub1a".to_string(), 3u32);
1922 seed.insert("npub1b".to_string(), 2u32);
1923 state.unread_seed(seed);
1924 assert!(state.unread_seeded);
1925 assert_eq!(state.sum_unread(), 5);
1926
1927 state.unread_clear("npub1a");
1928 assert_eq!(state.sum_unread(), 2, "clear drops a's 3");
1929
1930 state.unread_set("npub1b", 4);
1931 assert_eq!(state.sum_unread(), 4, "reconcile b to exact 4");
1932 state.unread_set("npub1b", 0);
1933 assert_eq!(state.sum_unread(), 0);
1934 assert!(!state.unread_cache.contains_key("npub1b"), "a zero count drops the entry");
1935 }
1936
1937 #[test]
1938 fn unread_cache_sum_honours_muted_and_blocked() {
1939 let mut state = ChatState::new();
1940 let mut blocked = Profile::new();
1941 blocked.flags.set_blocked(true);
1942 state.insert_or_replace_profile("npub1blk", blocked);
1943 state.create_dm_chat("npub1blk");
1944 state.create_dm_chat("npub1mut");
1945 state.get_chat_mut("npub1mut").unwrap().muted = true;
1946 state.create_dm_chat("npub1ok");
1947
1948 let mut seed = std::collections::HashMap::new();
1949 seed.insert("npub1blk".to_string(), 5u32);
1950 seed.insert("npub1mut".to_string(), 7u32);
1951 seed.insert("npub1ok".to_string(), 2u32);
1952 state.unread_seed(seed);
1953
1954 assert_eq!(state.sum_unread(), 2);
1956 }
1957
1958 #[test]
1959 fn count_unread_own_messages_break_count() {
1960 let mut state = ChatState::new();
1961 state.create_dm_chat("npub1peer");
1962
1963 let msg1 = make_message(1, "them 1", 1700000001000, false);
1965 let msg2 = make_message(2, "them 2", 1700000002000, false);
1966 let msg3 = make_message(3, "them 3", 1700000003000, false);
1967 let msg_mine = make_message(4, "me", 1700000004000, true);
1968 let msg5 = make_message(5, "them 4", 1700000005000, false);
1969 let msg6 = make_message(6, "them 5", 1700000006000, false);
1970
1971 for m in [msg1, msg2, msg3, msg_mine, msg5, msg6] {
1972 state.add_message_to_chat("npub1peer", &m);
1973 }
1974
1975 assert_eq!(
1977 state.count_unread_messages(), 2,
1978 "only messages after last 'mine' should count as unread"
1979 );
1980 }
1981
1982 #[test]
1983 fn count_unread_last_read_marker_breaks_count() {
1984 let mut state = ChatState::new();
1985 state.create_dm_chat("npub1peer");
1986
1987 let msg1 = make_message(1, "old", 1700000001000, false);
1988 let msg2 = make_message(2, "read up to here", 1700000002000, false);
1989 let msg3 = make_message(3, "new 1", 1700000003000, false);
1990 let msg4 = make_message(4, "new 2", 1700000004000, false);
1991 let read_marker_id = msg2.id.clone();
1992
1993 for m in [msg1, msg2, msg3, msg4] {
1994 state.add_message_to_chat("npub1peer", &m);
1995 }
1996
1997 let chat = state.get_chat_mut("npub1peer").unwrap();
1999 chat.last_read = crate::simd::hex::hex_to_bytes_32(&read_marker_id);
2000
2001 assert_eq!(
2002 state.count_unread_messages(), 2,
2003 "only messages after last_read marker should count"
2004 );
2005 }
2006
2007 #[test]
2008 fn count_unread_empty_chats_is_zero() {
2009 let mut state = ChatState::new();
2010 state.create_dm_chat("npub1empty1");
2011 state.create_dm_chat("npub1empty2");
2012
2013 assert_eq!(state.count_unread_messages(), 0, "empty chats should have zero unread");
2014 }
2015
2016 #[test]
2017 fn count_unread_blocked_group_member_messages_skipped() {
2018 let mut state = ChatState::new();
2019
2020 let mut blocked_profile = Profile::new();
2022 blocked_profile.flags.set_blocked(true);
2023 state.insert_or_replace_profile("npub1blockedmember", blocked_profile);
2024
2025 let normal_profile = Profile::new();
2027 state.insert_or_replace_profile("npub1normal", normal_profile);
2028
2029 state.ensure_community_chat("grp1");
2032 if let Some(chat) = state.chats.iter_mut().find(|c| c.id == "grp1") {
2033 chat.metadata.custom_fields.insert("community_id".to_string(), "c".repeat(64));
2034 }
2035
2036 let msg_blocked = make_message_from(1, "blocked says hi", 1700000001000, "npub1blockedmember");
2038 state.add_message_to_chat("grp1", &msg_blocked);
2039
2040 let msg_normal = make_message_from(2, "normal says hi", 1700000002000, "npub1normal");
2042 state.add_message_to_chat("grp1", &msg_normal);
2043
2044 assert_eq!(
2045 state.count_unread_messages(), 1,
2046 "only the non-blocked member's message should count"
2047 );
2048 }
2049
2050 #[test]
2051 fn count_unread_multiple_chats_summed() {
2052 let mut state = ChatState::new();
2053
2054 for i in 0..3 {
2055 let npub = format!("npub1chat{}", i);
2056 state.create_dm_chat(&npub);
2057 for j in 0..3u8 {
2058 let msg = make_message(
2059 i * 10 + j,
2060 &format!("msg {}-{}", i, j),
2061 1700000000000 + j as u64 * 1000,
2062 false,
2063 );
2064 state.add_message_to_chat(&npub, &msg);
2065 }
2066 }
2067
2068 assert_eq!(
2069 state.count_unread_messages(), 9,
2070 "3 chats x 3 unread each = 9 total"
2071 );
2072 }
2073
2074 #[test]
2079 fn update_typing_and_get_active_basic() {
2080 let mut state = ChatState::new();
2081 state.create_dm_chat("npub1peer");
2082
2083 let far_future = std::time::SystemTime::now()
2085 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
2086
2087 let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
2088 assert_eq!(active.len(), 1, "should have one active typer");
2089 assert_eq!(active[0], "npub1typer", "typer npub should match");
2090 }
2091
2092 #[test]
2093 fn update_typing_expired_typers_filtered() {
2094 let mut state = ChatState::new();
2095 state.create_dm_chat("npub1peer");
2096
2097 let expired = 1000;
2099 let active = state.update_typing_and_get_active("npub1peer", "npub1expired", expired);
2100
2101 assert!(active.is_empty(), "expired typer should be filtered out");
2102 }
2103
2104 #[test]
2105 fn update_typing_multiple_typers() {
2106 let mut state = ChatState::new();
2107 state.create_dm_chat("npub1peer");
2108
2109 let far_future = std::time::SystemTime::now()
2110 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
2111
2112 state.update_typing_and_get_active("npub1peer", "npub1typer1", far_future);
2113 let active = state.update_typing_and_get_active("npub1peer", "npub1typer2", far_future);
2114
2115 assert_eq!(active.len(), 2, "should have two active typers");
2116 }
2117
2118 #[test]
2119 fn update_typing_unknown_chat_returns_empty() {
2120 let mut state = ChatState::new();
2121 let far_future = std::time::SystemTime::now()
2122 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
2123
2124 let active = state.update_typing_and_get_active("npub1nonexistent", "npub1typer", far_future);
2125 assert!(active.is_empty(), "unknown chat should return empty typers");
2126 }
2127
2128 #[test]
2129 fn update_typing_refreshes_existing_typer() {
2130 let mut state = ChatState::new();
2131 state.create_dm_chat("npub1peer");
2132
2133 let far_future = std::time::SystemTime::now()
2134 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
2135
2136 state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
2137 let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future + 100);
2139
2140 assert_eq!(active.len(), 1, "should still have only one typer entry after refresh");
2141 }
2142
2143 #[test]
2148 fn wrapper_id_cache_historical_and_pending() {
2149 let mut cache = WrapperIdCache::new();
2150
2151 let id1 = [1u8; 32];
2152 let id2 = [2u8; 32];
2153 let id3 = [3u8; 32];
2154
2155 cache.load(vec![id1, id2]);
2156 cache.insert(id3);
2157
2158 assert!(cache.contains(&id1), "historical id should be found");
2159 assert!(cache.contains(&id2), "historical id should be found");
2160 assert!(cache.contains(&id3), "pending id should be found");
2161 assert!(!cache.contains(&[4u8; 32]), "unknown id should not be found");
2162 assert_eq!(cache.len(), 3, "total count should be 3");
2163 }
2164
2165 #[test]
2166 fn wrapper_id_cache_clear() {
2167 let mut cache = WrapperIdCache::new();
2168 cache.load(vec![[1u8; 32]]);
2169 cache.insert([2u8; 32]);
2170
2171 cache.clear();
2172
2173 assert_eq!(cache.len(), 0, "cache should be empty after clear");
2174 assert!(!cache.contains(&[1u8; 32]), "cleared historical should not be found");
2175 assert!(!cache.contains(&[2u8; 32]), "cleared pending should not be found");
2176 }
2177}