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 static DISCOVERY_RELAYS: &[&str] = &[
77 "wss://purplepag.es",
78 "wss://relay.primal.net",
79 "wss://nos.lol",
80 "wss://relay.snort.social",
81];
82
83pub static DISCOVERY_READ_ONLY_RELAYS: &[&str] = &["wss://relay.ditto.pub"];
90
91pub fn discovery_relay_iter() -> impl Iterator<Item = &'static str> {
94 DISCOVERY_RELAYS
95 .iter()
96 .chain(DISCOVERY_READ_ONLY_RELAYS.iter())
97 .copied()
98}
99
100pub async fn active_trusted_relays() -> Vec<&'static str> {
101 let Some(client) = nostr_client() else { return Vec::new() };
102 let pool_relays = client.relays().await;
103 TRUSTED_RELAYS.iter().copied()
104 .filter(|url| {
105 let normalized = url.trim_end_matches('/');
106 pool_relays.keys().any(|r| r.as_str().trim_end_matches('/') == normalized)
107 })
108 .collect()
109}
110
111pub static BLOSSOM_SERVERS: OnceLock<std::sync::Mutex<Vec<String>>> = OnceLock::new();
115
116pub fn init_blossom_servers() -> Vec<String> {
117 crate::blossom_servers::DEFAULT_BLOSSOM_SERVERS
118 .iter().map(|s| s.to_string()).collect()
119}
120
121pub fn get_blossom_servers() -> Vec<String> {
122 BLOSSOM_SERVERS
123 .get_or_init(|| std::sync::Mutex::new(init_blossom_servers()))
124 .lock().unwrap().clone()
125}
126
127pub static MNEMONIC_SEED: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
128pub static PENDING_NSEC: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
129
130pub static PENDING_BUNKER_SETUP:
139 std::sync::Mutex<Option<(zeroize::Zeroizing<String>, String)>> =
140 std::sync::Mutex::new(None);
141
142#[inline]
143pub fn set_pending_bunker_setup(url: String, remote_pk_hex: String) {
144 *PENDING_BUNKER_SETUP.lock().unwrap() =
145 Some((zeroize::Zeroizing::new(url), remote_pk_hex));
146}
147
148#[inline]
149pub fn pending_bunker_setup() -> Option<(String, String)> {
150 PENDING_BUNKER_SETUP.lock().unwrap()
151 .as_ref()
152 .map(|(z, pk)| (String::clone(&**z), pk.clone()))
153}
154
155#[inline]
156pub fn clear_pending_bunker_setup() {
157 *PENDING_BUNKER_SETUP.lock().unwrap() = None;
158}
159
160pub static ENCRYPTION_KEY: crate::crypto::GuardedKey = crate::crypto::GuardedKey::empty();
161
162pub static ENCRYPTION_ENABLED: AtomicBool = AtomicBool::new(false);
163
164#[inline]
165pub fn is_encryption_enabled_fast() -> bool { ENCRYPTION_ENABLED.load(Ordering::Acquire) }
166
167#[inline]
168pub fn set_encryption_enabled(enabled: bool) { ENCRYPTION_ENABLED.store(enabled, Ordering::Release); }
169
170pub fn resolve_encryption_enabled(
184 encryption_enabled_row: Option<&str>,
185 security_type_row: Option<&str>,
186) -> bool {
187 match encryption_enabled_row {
188 Some("false") => false,
189 Some(_) => true,
190 None => security_type_row.is_some(),
191 }
192}
193
194pub fn resolve_encryption_enabled_from_db() -> bool {
197 let enc = crate::db::get_sql_setting("encryption_enabled".to_string()).ok().flatten();
198 let sec = crate::db::get_sql_setting("security_type".to_string()).ok().flatten();
199 resolve_encryption_enabled(enc.as_deref(), sec.as_deref())
200}
201
202pub fn init_encryption_enabled() {
203 let enabled = resolve_encryption_enabled_from_db();
204 set_encryption_enabled(enabled);
205}
206
207#[cfg(test)]
208mod resolve_encryption_enabled_tests {
209 use super::*;
210
211 #[test]
212 fn explicit_false_wins_even_with_security_type() {
213 assert!(!resolve_encryption_enabled(Some("false"), Some("password")));
214 }
215
216 #[test]
217 fn explicit_true_is_encrypted() {
218 assert!(resolve_encryption_enabled(Some("true"), None));
219 }
220
221 #[test]
222 fn missing_row_defaults_to_security_type_presence() {
223 assert!(resolve_encryption_enabled(None, Some("password")));
226 assert!(!resolve_encryption_enabled(None, None));
228 }
229
230 #[test]
231 fn explicit_non_false_value_is_encrypted() {
232 assert!(resolve_encryption_enabled(Some("1"), None));
235 assert!(resolve_encryption_enabled(Some(""), None));
236 }
237}
238
239pub static NOSTR_CLIENT: LazyLock<RwLock<Option<Client>>> =
248 LazyLock::new(|| RwLock::new(None));
249
250pub static MY_SECRET_KEY: crate::crypto::GuardedKey = crate::crypto::GuardedKey::empty();
251
252pub static MY_PUBLIC_KEY: LazyLock<RwLock<Option<PublicKey>>> =
253 LazyLock::new(|| RwLock::new(None));
254
255#[inline]
259pub fn nostr_client() -> Option<Client> {
260 NOSTR_CLIENT.read().unwrap().as_ref().cloned()
261}
262
263#[inline]
265pub fn has_active_session() -> bool {
266 NOSTR_CLIENT.read().unwrap().is_some()
267}
268
269#[inline]
271pub fn my_public_key() -> Option<PublicKey> {
272 *MY_PUBLIC_KEY.read().unwrap()
273}
274
275#[inline]
279pub fn set_nostr_client(client: Client) {
280 *NOSTR_CLIENT.write().unwrap() = Some(client);
281}
282
283#[inline]
285pub fn set_my_public_key(pk: PublicKey) {
286 *MY_PUBLIC_KEY.write().unwrap() = Some(pk);
287}
288
289#[inline]
293pub fn take_nostr_client() -> Option<Client> {
294 NOSTR_CLIENT.write().unwrap().take()
295}
296
297#[inline]
300pub fn clear_my_public_key() {
301 *MY_PUBLIC_KEY.write().unwrap() = None;
302}
303
304#[derive(Clone)]
305pub struct PendingInviteAcceptance {
306 pub invite_code: String,
307 pub inviter_pubkey: PublicKey,
308}
309
310pub static PENDING_INVITE: LazyLock<RwLock<Option<PendingInviteAcceptance>>> =
314 LazyLock::new(|| RwLock::new(None));
315
316#[inline]
317pub fn pending_invite() -> Option<PendingInviteAcceptance> {
318 PENDING_INVITE.read().unwrap().clone()
319}
320
321#[inline]
322pub fn set_pending_invite(invite: PendingInviteAcceptance) {
323 *PENDING_INVITE.write().unwrap() = Some(invite);
324}
325
326#[inline]
327pub fn clear_pending_invite() {
328 *PENDING_INVITE.write().unwrap() = None;
329}
330
331pub static NOTIFIED_WELCOMES: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
332
333static SESSION_GENERATION: AtomicU64 = AtomicU64::new(0);
348
349#[inline]
351pub fn current_session_generation() -> u64 {
352 SESSION_GENERATION.load(Ordering::Acquire)
353}
354
355#[inline]
359pub fn bump_session_generation() -> u64 {
360 SESSION_GENERATION.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
361}
362
363#[derive(Copy, Clone, Debug)]
369pub struct SessionGuard {
370 generation: u64,
371}
372
373impl SessionGuard {
374 #[inline]
376 pub fn capture() -> Self {
377 Self { generation: current_session_generation() }
378 }
379
380 #[inline]
384 pub fn is_valid(&self) -> bool {
385 self.generation == current_session_generation()
386 }
387
388 #[inline]
390 pub fn generation(&self) -> u64 {
391 self.generation
392 }
393}
394
395#[cfg(test)]
396mod session_generation_tests {
397 use super::*;
398
399 #[test]
400 fn guard_is_valid_when_no_swap_occurred() {
401 let guard = SessionGuard::capture();
402 assert!(guard.is_valid());
403 }
404
405 #[test]
406 fn guard_invalidates_after_bump() {
407 let guard = SessionGuard::capture();
408 bump_session_generation();
409 assert!(!guard.is_valid(), "guard must invalidate after a swap");
410 }
411
412 #[test]
413 fn bump_advances_counter_monotonically() {
414 let before = current_session_generation();
415 let after = bump_session_generation();
416 assert_eq!(after, before.wrapping_add(1));
417 assert_eq!(current_session_generation(), after);
418 }
419}
420
421#[cfg(test)]
422mod session_globals_tests {
423 use super::*;
424
425 #[test]
429 fn session_helpers_round_trip_and_clear() {
430 clear_my_public_key();
434 clear_pending_invite();
435
436 let keys = Keys::parse(
441 "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
442 ).expect("parse test nsec");
443 let pk = keys.public_key;
444
445 assert_eq!(my_public_key(), None, "starts as None");
446
447 set_my_public_key(pk);
448 assert_eq!(my_public_key(), Some(pk));
449
450 clear_my_public_key();
451 assert_eq!(my_public_key(), None, "cleared returns None");
452
453 let invite = PendingInviteAcceptance {
455 invite_code: "abc123".to_string(),
456 inviter_pubkey: pk,
457 };
458
459 assert!(pending_invite().is_none(), "starts as None");
460
461 set_pending_invite(invite.clone());
462 let got = pending_invite().expect("set then read");
463 assert_eq!(got.invite_code, invite.invite_code);
464 assert_eq!(got.inviter_pubkey, invite.inviter_pubkey);
465
466 clear_pending_invite();
467 assert!(pending_invite().is_none(), "cleared returns None");
468
469 assert!(!has_active_session(), "no client installed");
472 assert!(take_nostr_client().is_none(), "take from empty returns None");
473 assert!(!has_active_session(), "still none after take-of-empty");
474
475 clear_pending_bunker_setup();
480 assert!(pending_bunker_setup().is_none(), "starts as None");
481
482 let url = "bunker://0123456789abcdef?relay=wss%3A%2F%2Frelay.example&secret=topsecret".to_string();
483 let pk_hex = "0123456789abcdef".to_string();
484 set_pending_bunker_setup(url.clone(), pk_hex.clone());
485
486 let first = pending_bunker_setup().expect("first peek");
489 let second = pending_bunker_setup().expect("second peek");
490 assert_eq!(first.0, url, "url survives clone-out from Zeroizing");
491 assert_eq!(first.0, second.0, "successive peeks return same data");
492 assert_eq!(first.1, pk_hex);
493
494 let url2 = "bunker://feedface?relay=wss%3A%2F%2Falt".to_string();
498 set_pending_bunker_setup(url2.clone(), "feedface".to_string());
499 let after = pending_bunker_setup().expect("overwritten read");
500 assert_eq!(after.0, url2);
501 assert_eq!(after.1, "feedface");
502
503 clear_pending_bunker_setup();
504 assert!(pending_bunker_setup().is_none(), "cleared returns None");
505
506 let guard = SessionGuard::capture();
511 assert!(guard.is_valid(), "fresh capture is valid");
512 bump_session_generation();
513 assert!(!guard.is_valid(),
514 "captured guard must invalidate after generation bump");
515 }
516}
517
518pub static WRAPPER_ID_CACHE: LazyLock<Mutex<WrapperIdCache>> = LazyLock::new(|| Mutex::new(WrapperIdCache::new()));
519
520pub static STATE: LazyLock<Mutex<ChatState>> = LazyLock::new(|| Mutex::new(ChatState::new()));
521
522pub static ACTIVE_CHAT: LazyLock<RwLock<Option<String>>> =
528 LazyLock::new(|| RwLock::new(None));
529
530pub fn set_active_chat(chat_id: Option<String>) {
531 if let Ok(mut guard) = ACTIVE_CHAT.write() {
532 *guard = chat_id;
533 }
534}
535
536pub fn get_active_chat() -> Option<String> {
537 ACTIVE_CHAT.read().ok().and_then(|g| g.clone())
538}
539
540pub static PROCESSING_GATE: AtomicBool = AtomicBool::new(true);
545pub static PENDING_EVENTS: LazyLock<Mutex<Vec<(Event, bool)>>> = LazyLock::new(|| Mutex::new(Vec::new()));
546
547#[inline]
548pub fn is_processing_allowed() -> bool { PROCESSING_GATE.load(Ordering::Acquire) }
549pub fn close_processing_gate() { PROCESSING_GATE.store(false, Ordering::Release); }
550pub fn open_processing_gate() { PROCESSING_GATE.store(true, Ordering::Release); }
551
552#[derive(Clone, Debug)]
557pub struct ChatState {
558 pub profiles: Vec<Profile>,
559 pub chats: Vec<Chat>,
560 pub interner: NpubInterner,
561 pub is_syncing: bool,
562 pub db_loaded: bool,
563 #[cfg(debug_assertions)]
564 pub cache_stats: crate::stats::CacheStats,
565}
566
567impl ChatState {
568 pub fn new() -> Self {
569 Self {
570 profiles: Vec::new(),
571 chats: Vec::new(),
572 interner: NpubInterner::new(),
573 is_syncing: false,
574 db_loaded: false,
575 #[cfg(debug_assertions)]
576 cache_stats: crate::stats::CacheStats::new(),
577 }
578 }
579
580 pub fn merge_db_profiles(&mut self, slim_profiles: Vec<SlimProfile>, my_npub: &str) {
585 for slim in slim_profiles {
586 let mut full_profile = slim.to_profile();
587 full_profile.flags.set_mine(slim.id == my_npub);
588 self.insert_or_replace_profile(&slim.id, full_profile);
589 }
590 }
591
592 pub fn insert_or_replace_profile(&mut self, npub: &str, mut profile: Profile) {
593 let id = self.interner.intern(npub);
594 profile.id = id;
595 match self.profiles.binary_search_by(|p| p.id.cmp(&id)) {
596 Ok(idx) => self.profiles[idx] = profile,
597 Err(idx) => self.profiles.insert(idx, profile),
598 }
599 }
600
601 pub fn get_profile(&self, npub: &str) -> Option<&Profile> {
602 self.interner.lookup(npub).and_then(|id| self.get_profile_by_id(id))
603 }
604
605 pub fn get_profile_mut(&mut self, npub: &str) -> Option<&mut Profile> {
606 self.interner.lookup(npub).and_then(move |id| self.get_profile_mut_by_id(id))
607 }
608
609 #[inline]
610 pub fn get_profile_by_id(&self, id: u16) -> Option<&Profile> {
611 self.profiles.binary_search_by(|p| p.id.cmp(&id)).ok().map(|idx| &self.profiles[idx])
612 }
613
614 #[inline]
615 pub fn get_profile_mut_by_id(&mut self, id: u16) -> Option<&mut Profile> {
616 self.profiles.binary_search_by(|p| p.id.cmp(&id)).ok().map(move |idx| &mut self.profiles[idx])
617 }
618
619 pub fn serialize_profile(&self, id: u16) -> Option<SlimProfile> {
620 self.get_profile_by_id(id).map(|p| SlimProfile::from_profile(p, &self.interner))
621 }
622
623 pub fn get_chat(&self, id: &str) -> Option<&Chat> { self.chats.iter().find(|c| c.id == id) }
628 pub fn get_chat_mut(&mut self, id: &str) -> Option<&mut Chat> { self.chats.iter_mut().find(|c| c.id == id) }
629
630 pub fn create_dm_chat(&mut self, their_npub: &str) -> String {
631 if self.get_chat(their_npub).is_none() {
632 let chat = Chat::new_dm(their_npub.to_string(), &mut self.interner);
633 self.chats.push(chat);
634 }
635 their_npub.to_string()
636 }
637
638 pub fn ensure_community_chat(&mut self, channel_id: &str) {
644 if !self.chats.iter().any(|c| c.id == channel_id) {
645 let chat =
646 Chat::new_community_channel(channel_id.to_string(), Vec::new(), &mut self.interner);
647 self.chats.push(chat);
648 }
649 }
650
651 pub fn upsert_community_chat(
656 &mut self,
657 channel_id: &str,
658 name: &str,
659 description: &str,
660 community_id: &str,
661 is_owner: bool,
662 has_icon: bool,
663 owner_npub: Option<&str>,
664 created_at_ms: Option<u64>,
665 dissolved: bool,
666 ) {
667 self.ensure_community_chat(channel_id);
668 if let Some(chat) = self.chats.iter_mut().find(|c| c.id == channel_id) {
669 let cf = &mut chat.metadata.custom_fields;
670 cf.insert("name".to_string(), name.to_string());
671 cf.insert("description".to_string(), description.to_string());
672 cf.insert("community_id".to_string(), community_id.to_string());
673 cf.insert("is_owner".to_string(), is_owner.to_string());
674 cf.insert("dissolved".to_string(), dissolved.to_string());
676 if let Some(ms) = created_at_ms {
678 cf.insert("created_at".to_string(), ms.to_string());
679 }
680 match owner_npub {
682 Some(n) => { cf.insert("owner_npub".to_string(), n.to_string()); }
683 None => { cf.remove("owner_npub"); }
684 }
685 if has_icon {
686 cf.insert("icon".to_string(), "1".to_string());
687 } else {
688 cf.remove("icon");
689 }
690 }
691 }
692
693 pub fn add_message_to_chat(&mut self, chat_id: &str, message: Message) -> bool {
694 let compact = CompactMessage::from_message(&message, &mut self.interner);
695
696 let (is_msg_added, chat_idx) = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
697 let added = self.chats[idx].add_compact_message(compact);
698 (added, idx)
699 } else {
700 let mut chat = if chat_id.starts_with("npub1") {
701 Chat::new_dm(chat_id.to_string(), &mut self.interner)
702 } else {
703 Chat::new(chat_id.to_string(), ChatType::Community, vec![])
704 };
705 let was_added = chat.add_compact_message(compact);
706 self.chats.push(chat);
707 (was_added, self.chats.len() - 1)
708 };
709
710 if is_msg_added && chat_idx > 0 {
711 let this_time = self.chats[chat_idx].last_message_time();
712 let target = self.chats[..chat_idx].iter()
713 .position(|c| c.last_message_time() <= this_time)
714 .unwrap_or(chat_idx);
715 if target < chat_idx {
716 self.chats[target..=chat_idx].rotate_right(1);
717 }
718 }
719
720 is_msg_added
721 }
722
723 pub fn add_messages_to_chat_batch(&mut self, chat_id: &str, messages: Vec<Message>) -> usize {
724 if messages.is_empty() { return 0; }
725
726 let compact_messages: Vec<_> = messages.into_iter()
727 .map(|msg| CompactMessage::from_message_owned(msg, &mut self.interner))
728 .collect();
729
730 let chat_idx = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
731 idx
732 } else {
733 let chat = if chat_id.starts_with("npub1") {
734 Chat::new_dm(chat_id.to_string(), &mut self.interner)
735 } else {
736 Chat::new(chat_id.to_string(), ChatType::Community, vec![])
737 };
738 self.chats.push(chat);
739 self.chats.len() - 1
740 };
741
742 let old_last_time = self.chats[chat_idx].messages.last_timestamp();
743 let added = self.chats[chat_idx].messages.insert_batch(compact_messages);
744
745 if added > 0 && self.chats[chat_idx].messages.last_timestamp() != old_last_time && chat_idx > 0 {
746 let this_time = self.chats[chat_idx].last_message_time();
747 let target = self.chats[..chat_idx].iter()
748 .position(|c| c.last_message_time() <= this_time)
749 .unwrap_or(chat_idx);
750 if target < chat_idx {
751 self.chats[target..=chat_idx].rotate_right(1);
752 }
753 }
754
755 added
756 }
757
758 pub fn add_message_to_participant(&mut self, their_npub: &str, message: Message) -> bool {
762 let id = self.interner.intern(their_npub);
763 if self.get_profile_by_id(id).is_none() {
764 let profile = Profile::new();
765 self.insert_or_replace_profile(their_npub, profile);
766
767 if let Some(slim) = self.serialize_profile(id) {
769 emit_event("profile_update", &slim);
770 }
771 }
772
773 let chat_id = self.create_dm_chat(their_npub);
774 self.add_message_to_chat(&chat_id, message)
775 }
776
777 pub fn find_message(&self, message_id: &str) -> Option<(&Chat, Message)> {
782 if message_id.is_empty() { return None; }
783 for chat in &self.chats {
784 if let Some(compact) = chat.get_compact_message(message_id) {
785 return Some((chat, compact.to_message(&self.interner)));
786 }
787 }
788 None
789 }
790
791 pub fn find_chat_for_message(&self, message_id: &str) -> Option<(usize, String)> {
792 if message_id.is_empty() { return None; }
793 for (idx, chat) in self.chats.iter().enumerate() {
794 if chat.has_message(message_id) { return Some((idx, chat.id.clone())); }
795 }
796 None
797 }
798
799 pub fn update_message<F>(&mut self, message_id: &str, f: F) -> Option<(String, Message)>
800 where F: FnOnce(&mut CompactMessage)
801 {
802 if message_id.is_empty() { return None; }
803 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
804 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
805 let chat_id = self.chats[chat_idx].id.clone();
806 self.chats[chat_idx].get_compact_message(message_id).map(|m| (chat_id, m.to_message(&self.interner)))
807 }
808
809 pub fn update_message_in_chat<F>(&mut self, chat_id: &str, message_id: &str, f: F) -> Option<Message>
810 where F: FnOnce(&mut CompactMessage)
811 {
812 let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
813 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
814 self.chats[chat_idx].get_compact_message(message_id).map(|m| m.to_message(&self.interner))
815 }
816
817 pub fn finalize_pending_message(&mut self, chat_id: &str, pending_id: &str, real_id: &str) -> Option<(String, Message)> {
818 let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
819 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(pending_id) {
820 msg.id = crate::simd::hex::hex_to_bytes_32(real_id);
821 msg.set_pending(false);
822 }
823 self.chats[chat_idx].messages.rebuild_index();
824 self.chats[chat_idx].get_compact_message(real_id)
825 .map(|m| (pending_id.to_string(), m.to_message(&self.interner)))
826 }
827
828 pub fn update_attachment<F>(&mut self, chat_hint: &str, msg_id: &str, attachment_id: &str, f: F) -> bool
829 where F: FnOnce(&mut CompactAttachment)
830 {
831 for chat in &mut self.chats {
832 let is_target = match &chat.chat_type {
833 ChatType::Community => chat.id == chat_hint,
835 ChatType::DirectMessage => chat.has_participant(chat_hint, &self.interner),
836 };
837 if is_target {
838 if let Some(msg) = chat.messages.find_by_hex_id_mut(msg_id) {
839 if let Some(att) = msg.attachments.iter_mut().find(|a| a.id_eq(attachment_id)) {
840 f(att);
841 return true;
842 }
843 }
844 }
845 }
846 false
847 }
848
849 pub fn add_attachment_to_message(&mut self, chat_id: &str, msg_id: &str, attachment: CompactAttachment) -> bool {
850 let chat_idx = match self.chats.iter().position(|c| c.id == chat_id || c.has_participant(chat_id, &self.interner)) {
851 Some(idx) => idx,
852 None => return false,
853 };
854 if let Some(msg) = self.chats[chat_idx].messages.find_by_hex_id_mut(msg_id) {
855 msg.attachments.push(attachment);
856 true
857 } else { false }
858 }
859
860 pub fn add_reaction_to_message(&mut self, message_id: &str, reaction: Reaction) -> Option<(String, bool)> {
861 if message_id.is_empty() { return None; }
862 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
863 let chat_id = self.chats[chat_idx].id.clone();
864 let msg = self.chats[chat_idx].get_compact_message_mut(message_id)?;
865 let added = msg.add_reaction(reaction, &mut self.interner);
866 Some((chat_id, added))
867 }
868
869 pub fn find_reaction(&self, reaction_id: &str) -> Option<(String, String, String, bool)> {
872 if reaction_id.is_empty() { return None; }
873 let target = crate::simd::hex::hex_to_bytes_32(reaction_id);
874 for chat in &self.chats {
875 for msg in chat.iter_compact() {
876 if let Some(r) = msg.reactions.iter().find(|r| r.id == target) {
877 let author = self.interner.resolve(r.author_idx).unwrap_or("").to_string();
878 return Some((chat.id.clone(), msg.id_hex(), author, chat.is_community()));
879 }
880 }
881 }
882 None
883 }
884
885 pub fn remove_reaction_from_message(&mut self, message_id: &str, reaction_id: &str) -> Option<(String, Message)> {
888 if message_id.is_empty() { return None; }
889 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
890 let removed = self.chats[chat_idx]
891 .get_compact_message_mut(message_id)
892 .map(|m| m.remove_reaction(reaction_id))
893 .unwrap_or(false);
894 if !removed { return None; }
895 let chat_id = self.chats[chat_idx].id.clone();
896 self.chats[chat_idx]
897 .get_compact_message(message_id)
898 .map(|m| (chat_id, m.to_message(&self.interner)))
899 }
900
901 pub fn remove_message(&mut self, message_id: &str) -> Option<(String, Message)> {
902 if message_id.is_empty() { return None; }
903 for chat in &mut self.chats {
904 if let Some(compact) = chat.messages.find_by_hex_id(message_id) {
905 let msg = compact.to_message(&self.interner);
906 let removed_id = compact.id;
907 let removed_at = compact.at;
908 let chat_id = chat.id.clone();
909 let was_marker = chat.last_read == removed_id;
910 chat.messages.remove_by_hex_id(message_id);
911 if was_marker {
915 chat.last_read = chat.messages.iter().rev()
916 .find(|m| m.at <= removed_at && !m.flags.is_mine())
917 .map(|m| m.id)
918 .unwrap_or([0u8; 32]);
919 }
920 return Some((chat_id, msg));
921 }
922 }
923 None
924 }
925
926 pub fn message_exists(&self, message_id: &str) -> bool {
927 !message_id.is_empty() && self.chats.iter().any(|chat| chat.has_message(message_id))
928 }
929
930 pub fn sum_unread_from(&self, counts: &std::collections::HashMap<String, u32>) -> u32 {
939 let mut total = 0u32;
940 for chat in &self.chats {
941 if chat.muted {
942 continue;
943 }
944 if !chat.is_community() {
945 if let Some(id) = self.interner.lookup(&chat.id) {
946 if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) {
947 continue;
948 }
949 }
950 }
951 total += counts.get(&chat.id).copied().unwrap_or(0);
952 }
953 total
954 }
955
956 pub fn count_unread_messages(&self) -> u32 {
957 let mut total_unread = 0;
958 for chat in &self.chats {
959 if chat.muted { continue; }
960 let is_group = chat.is_community();
961 if !is_group {
962 if let Some(id) = self.interner.lookup(&chat.id) {
963 if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) { continue; }
964 }
965 }
966 let mut unread_count = 0u32;
967 for msg in chat.iter_compact().rev() {
968 if msg.flags.is_mine() { break; }
969 if chat.last_read != [0u8; 32] && msg.id == chat.last_read { break; }
970 if is_group && msg.npub_idx != NO_NPUB {
971 if self.get_profile_by_id(msg.npub_idx).map_or(false, |p| p.flags.is_blocked()) { continue; }
972 }
973 unread_count += 1;
974 }
975 #[cfg(debug_assertions)]
977 if unread_count > 0 {
978 let last_read_hex = crate::compact::decode_message_id(&chat.last_read);
979 let last_msg_hex = chat.messages.last().map(|m| crate::compact::decode_message_id(&m.id)).unwrap_or_default();
980 let msg_count = chat.message_count();
981 eprintln!("[Unread] chat={} unread={} msgs_in_memory={} last_read={} last_msg={}",
982 &chat.id[..20.min(chat.id.len())], unread_count, msg_count,
983 &last_read_hex[..16.min(last_read_hex.len())], &last_msg_hex[..16.min(last_msg_hex.len())]);
984 }
985 total_unread += unread_count;
986 }
987 total_unread
988 }
989
990 pub fn update_typing_and_get_active(&mut self, chat_id: &str, npub: &str, expires_at: u64) -> Vec<String> {
995 let handle = self.interner.intern(npub);
996 if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
997 chat.update_typing_participant(handle, expires_at);
998 chat.get_active_typers(&self.interner)
999 } else {
1000 Vec::new()
1001 }
1002 }
1003}
1004
1005impl Default for ChatState {
1006 fn default() -> Self { Self::new() }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012 use crate::types::Message;
1013 use crate::profile::{Profile, SlimProfile, Status};
1014 use crate::simd::hex::bytes_to_hex_32;
1015
1016 fn make_hex_id(seed: u8) -> String {
1023 let mut bytes = [seed; 32];
1024 bytes[0] = seed.wrapping_add(0x10) | 0x10; bytes[1] = seed.wrapping_mul(37);
1026 bytes_to_hex_32(&bytes)
1027 }
1028
1029 fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
1031 Message {
1032 id: make_hex_id(id_seed),
1033 content: content.to_string(),
1034 at: timestamp_ms,
1035 mine,
1036 ..Default::default()
1037 }
1038 }
1039
1040 fn make_message_from(id_seed: u8, content: &str, timestamp_ms: u64, npub: &str) -> Message {
1042 Message {
1043 id: make_hex_id(id_seed),
1044 content: content.to_string(),
1045 at: timestamp_ms,
1046 mine: false,
1047 npub: Some(npub.to_string()),
1048 ..Default::default()
1049 }
1050 }
1051
1052 fn make_slim_profile(id: &str, name: &str) -> SlimProfile {
1054 SlimProfile {
1055 id: id.to_string(),
1056 name: name.to_string(),
1057 display_name: String::new(),
1058 nickname: String::new(),
1059 lud06: String::new(),
1060 lud16: String::new(),
1061 banner: String::new(),
1062 avatar: String::new(),
1063 about: String::new(),
1064 website: String::new(),
1065 nip05: String::new(),
1066 status: Status::new(),
1067 last_updated: 0,
1068 mine: false,
1069 bot: false,
1070 is_blocked: false,
1071 avatar_cached: String::new(),
1072 banner_cached: String::new(),
1073 }
1074 }
1075
1076 #[test]
1081 fn insert_or_replace_profile_creates_new() {
1082 let mut state = ChatState::new();
1083 let profile = Profile::new();
1084 state.insert_or_replace_profile("npub1alice", profile);
1085
1086 assert!(
1087 state.get_profile("npub1alice").is_some(),
1088 "newly inserted profile should be retrievable"
1089 );
1090 assert_eq!(state.profiles.len(), 1, "should have exactly one profile");
1091 }
1092
1093 #[test]
1094 fn insert_or_replace_profile_updates_existing() {
1095 let mut state = ChatState::new();
1096 let mut p1 = Profile::new();
1097 p1.name = "Alice".to_string().into_boxed_str();
1098 state.insert_or_replace_profile("npub1alice", p1);
1099
1100 let mut p2 = Profile::new();
1101 p2.name = "Alice Updated".to_string().into_boxed_str();
1102 state.insert_or_replace_profile("npub1alice", p2);
1103
1104 let fetched = state.get_profile("npub1alice").expect("profile should exist");
1105 assert_eq!(
1106 &*fetched.name, "Alice Updated",
1107 "profile name should be updated after replace"
1108 );
1109 assert_eq!(state.profiles.len(), 1, "should still be one profile, not duplicated");
1110 }
1111
1112 #[test]
1113 fn get_profile_by_npub() {
1114 let mut state = ChatState::new();
1115 let mut profile = Profile::new();
1116 profile.name = "Bob".to_string().into_boxed_str();
1117 state.insert_or_replace_profile("npub1bob", profile);
1118
1119 let fetched = state.get_profile("npub1bob").expect("profile should be found");
1120 assert_eq!(&*fetched.name, "Bob", "fetched profile name should match");
1121 }
1122
1123 #[test]
1124 fn get_profile_returns_none_for_unknown() {
1125 let state = ChatState::new();
1126 assert!(
1127 state.get_profile("npub1unknown").is_none(),
1128 "unknown npub should return None"
1129 );
1130 }
1131
1132 #[test]
1133 fn get_profile_by_id_works() {
1134 let mut state = ChatState::new();
1135 let mut profile = Profile::new();
1136 profile.name = "Charlie".to_string().into_boxed_str();
1137 state.insert_or_replace_profile("npub1charlie", profile);
1138
1139 let id = state.interner.lookup("npub1charlie").expect("npub should be interned");
1140 let fetched = state.get_profile_by_id(id).expect("profile should be found by id");
1141 assert_eq!(&*fetched.name, "Charlie", "profile looked up by id should match");
1142 }
1143
1144 #[test]
1145 fn get_profile_by_id_returns_none_for_invalid() {
1146 let state = ChatState::new();
1147 assert!(
1148 state.get_profile_by_id(9999).is_none(),
1149 "invalid interner id should return None"
1150 );
1151 }
1152
1153 #[test]
1154 fn merge_db_profiles_sets_mine_flag() {
1155 let mut state = ChatState::new();
1156 let slim_mine = make_slim_profile("npub1me", "Me");
1157 let slim_other = make_slim_profile("npub1other", "Other");
1158
1159 state.merge_db_profiles(vec![slim_mine, slim_other], "npub1me");
1160
1161 let me = state.get_profile("npub1me").expect("my profile should exist");
1162 assert!(me.flags.is_mine(), "my profile should have mine flag set");
1163
1164 let other = state.get_profile("npub1other").expect("other profile should exist");
1165 assert!(!other.flags.is_mine(), "other profile should not have mine flag");
1166 }
1167
1168 #[test]
1169 fn serialize_profile_roundtrip() {
1170 let mut state = ChatState::new();
1171 let mut profile = Profile::new();
1172 profile.name = "Roundtrip".to_string().into_boxed_str();
1173 profile.about = "Test about".to_string().into_boxed_str();
1174 profile.flags.set_blocked(true);
1175 state.insert_or_replace_profile("npub1round", profile);
1176
1177 let id = state.interner.lookup("npub1round").unwrap();
1178 let slim = state.serialize_profile(id).expect("serialization should succeed");
1179
1180 assert_eq!(slim.id, "npub1round", "serialized id should match");
1181 assert_eq!(slim.name, "Roundtrip", "serialized name should match");
1182 assert_eq!(slim.about, "Test about", "serialized about should match");
1183 assert!(slim.is_blocked, "serialized blocked flag should be true");
1184
1185 let restored = slim.to_profile();
1187 assert_eq!(&*restored.name, "Roundtrip", "restored name should match");
1188 assert!(restored.flags.is_blocked(), "restored blocked flag should be true");
1189 }
1190
1191 #[test]
1192 fn binary_search_maintains_sorted_order_with_100_profiles() {
1193 let mut state = ChatState::new();
1194
1195 let npubs: Vec<String> = (0..100).map(|i| format!("npub1user{:04}", i)).collect();
1197 let mut shuffled = npubs.clone();
1198 for i in (1..shuffled.len()).rev() {
1200 let j = (i * 37 + 13) % (i + 1);
1201 shuffled.swap(i, j);
1202 }
1203
1204 for npub in &shuffled {
1205 let mut profile = Profile::new();
1206 profile.name = npub.clone().into_boxed_str();
1207 state.insert_or_replace_profile(npub, profile);
1208 }
1209
1210 for npub in &npubs {
1212 assert!(
1213 state.get_profile(npub).is_some(),
1214 "profile {} should be retrievable after bulk insert",
1215 npub
1216 );
1217 }
1218
1219 for window in state.profiles.windows(2) {
1221 assert!(
1222 window[0].id < window[1].id,
1223 "profiles should be sorted by interner id"
1224 );
1225 }
1226
1227 assert_eq!(state.profiles.len(), 100, "should have exactly 100 profiles");
1228 }
1229
1230 #[test]
1231 fn insert_same_npub_twice_updates_not_duplicates() {
1232 let mut state = ChatState::new();
1233
1234 for i in 0..5 {
1235 let mut profile = Profile::new();
1236 profile.name = format!("version_{}", i).into_boxed_str();
1237 state.insert_or_replace_profile("npub1repeated", profile);
1238 }
1239
1240 assert_eq!(state.profiles.len(), 1, "repeated inserts should not create duplicates");
1241 let p = state.get_profile("npub1repeated").unwrap();
1242 assert_eq!(&*p.name, "version_4", "should retain the last update");
1243 }
1244
1245 #[test]
1246 fn get_profile_mut_modifies_in_place() {
1247 let mut state = ChatState::new();
1248 let profile = Profile::new();
1249 state.insert_or_replace_profile("npub1mutable", profile);
1250
1251 let p = state.get_profile_mut("npub1mutable").expect("profile should exist");
1252 p.name = "Mutated".to_string().into_boxed_str();
1253
1254 let fetched = state.get_profile("npub1mutable").unwrap();
1255 assert_eq!(&*fetched.name, "Mutated", "mutation should persist");
1256 }
1257
1258 #[test]
1263 fn create_dm_chat_creates_new() {
1264 let mut state = ChatState::new();
1265 let id = state.create_dm_chat("npub1peer");
1266
1267 assert_eq!(id, "npub1peer", "returned id should match the npub");
1268 assert!(state.get_chat("npub1peer").is_some(), "chat should be created");
1269 assert_eq!(state.chats.len(), 1, "should have exactly one chat");
1270 }
1271
1272 #[test]
1273 fn create_dm_chat_is_idempotent() {
1274 let mut state = ChatState::new();
1275 state.create_dm_chat("npub1peer");
1276 state.create_dm_chat("npub1peer");
1277 state.create_dm_chat("npub1peer");
1278
1279 assert_eq!(state.chats.len(), 1, "repeated creates should not duplicate");
1280 }
1281
1282 #[test]
1283 fn ensure_community_chat_idempotent() {
1284 let mut state = ChatState::new();
1285 state.ensure_community_chat("grp1");
1286 state.ensure_community_chat("grp1");
1287
1288 assert_eq!(state.chats.len(), 1, "second call should not create a duplicate");
1289 let chat = state.get_chat("grp1").expect("community chat should exist");
1290 assert!(chat.is_community(), "should be a Community chat");
1291 }
1292
1293 #[test]
1294 fn get_chat_by_id() {
1295 let mut state = ChatState::new();
1296 state.create_dm_chat("npub1x");
1297
1298 let chat = state.get_chat("npub1x").expect("chat should exist");
1299 assert_eq!(chat.id, "npub1x", "chat id should match");
1300 }
1301
1302 #[test]
1303 fn get_chat_returns_none_for_missing() {
1304 let state = ChatState::new();
1305 assert!(state.get_chat("nonexistent").is_none(), "missing chat should return None");
1306 }
1307
1308 #[test]
1309 fn get_chat_mut_modifies_in_place() {
1310 let mut state = ChatState::new();
1311 state.create_dm_chat("npub1editable");
1312
1313 let chat = state.get_chat_mut("npub1editable").expect("chat should exist");
1314 chat.muted = true;
1315
1316 let refetched = state.get_chat("npub1editable").unwrap();
1317 assert!(refetched.muted, "muted flag should persist after mutation");
1318 }
1319
1320 #[test]
1321 fn multiple_different_chats() {
1322 let mut state = ChatState::new();
1323 state.create_dm_chat("npub1alice");
1324 state.create_dm_chat("npub1bob");
1325 state.ensure_community_chat("grp1");
1326
1327 assert_eq!(state.chats.len(), 3, "should have three distinct chats");
1328 }
1329
1330 #[test]
1335 fn add_message_to_chat_single() {
1336 let mut state = ChatState::new();
1337 state.create_dm_chat("npub1peer");
1338
1339 let msg = make_message(1, "hello", 1700000000000, false);
1340 let added = state.add_message_to_chat("npub1peer", msg);
1341
1342 assert!(added, "first message should be added successfully");
1343 let chat = state.get_chat("npub1peer").unwrap();
1344 assert_eq!(chat.message_count(), 1, "chat should have one message");
1345 }
1346
1347 #[test]
1348 fn add_message_to_chat_dedup_rejects_same_id() {
1349 let mut state = ChatState::new();
1350 state.create_dm_chat("npub1peer");
1351
1352 let msg1 = make_message(1, "hello", 1700000000000, false);
1353 let msg2 = make_message(1, "duplicate", 1700000001000, false);
1354
1355 let added1 = state.add_message_to_chat("npub1peer", msg1);
1356 let added2 = state.add_message_to_chat("npub1peer", msg2);
1357
1358 assert!(added1, "first insert should succeed");
1359 assert!(!added2, "duplicate ID should be rejected");
1360 assert_eq!(
1361 state.get_chat("npub1peer").unwrap().message_count(), 1,
1362 "should still have only one message"
1363 );
1364 }
1365
1366 #[test]
1367 fn add_messages_to_chat_batch_works() {
1368 let mut state = ChatState::new();
1369 state.create_dm_chat("npub1peer");
1370
1371 let msgs: Vec<Message> = (0..10).map(|i| {
1372 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1373 }).collect();
1374
1375 let added = state.add_messages_to_chat_batch("npub1peer", msgs);
1376 assert_eq!(added, 10, "all 10 messages should be added");
1377 assert_eq!(
1378 state.get_chat("npub1peer").unwrap().message_count(), 10,
1379 "chat should have 10 messages"
1380 );
1381 }
1382
1383 #[test]
1384 fn add_messages_to_chat_batch_dedup() {
1385 let mut state = ChatState::new();
1386 state.create_dm_chat("npub1peer");
1387
1388 let msgs1: Vec<Message> = (0..5).map(|i| {
1390 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1391 }).collect();
1392 state.add_messages_to_chat_batch("npub1peer", msgs1);
1393
1394 let msgs2: Vec<Message> = (3..8).map(|i| {
1396 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1397 }).collect();
1398 let added = state.add_messages_to_chat_batch("npub1peer", msgs2);
1399
1400 assert_eq!(added, 3, "only 3 new messages should be added (5, 6, 7)");
1401 assert_eq!(
1402 state.get_chat("npub1peer").unwrap().message_count(), 8,
1403 "total should be 8 unique messages"
1404 );
1405 }
1406
1407 #[test]
1408 fn add_message_to_participant_creates_profile_and_chat() {
1409 let mut state = ChatState::new();
1410
1411 let msg = make_message(1, "hi there", 1700000000000, false);
1412 let added = state.add_message_to_participant("npub1stranger", msg);
1413
1414 assert!(added, "message should be added");
1415 assert!(
1416 state.get_profile("npub1stranger").is_some(),
1417 "profile should be auto-created for unknown participant"
1418 );
1419 assert!(
1420 state.get_chat("npub1stranger").is_some(),
1421 "DM chat should be auto-created"
1422 );
1423 }
1424
1425 #[test]
1426 fn add_message_to_participant_uses_existing_profile() {
1427 let mut state = ChatState::new();
1428
1429 let mut profile = Profile::new();
1431 profile.name = "Known User".to_string().into_boxed_str();
1432 state.insert_or_replace_profile("npub1known", profile);
1433
1434 let msg = make_message(1, "hello", 1700000000000, false);
1435 state.add_message_to_participant("npub1known", msg);
1436
1437 let p = state.get_profile("npub1known").unwrap();
1439 assert_eq!(&*p.name, "Known User", "existing profile should not be overwritten");
1440 }
1441
1442 #[test]
1443 fn find_message_across_chats() {
1444 let mut state = ChatState::new();
1445 state.create_dm_chat("npub1a");
1446 state.create_dm_chat("npub1b");
1447
1448 let msg_a = make_message(1, "in chat a", 1700000000000, false);
1449 let msg_b = make_message(2, "in chat b", 1700000001000, false);
1450 let msg_id_b = msg_b.id.clone();
1451
1452 state.add_message_to_chat("npub1a", msg_a);
1453 state.add_message_to_chat("npub1b", msg_b);
1454
1455 let (chat, found_msg) = state.find_message(&msg_id_b).expect("message should be found");
1456 assert_eq!(chat.id, "npub1b", "should find in correct chat");
1457 assert_eq!(found_msg.content, "in chat b", "content should match");
1458 }
1459
1460 #[test]
1461 fn find_message_returns_none_for_unknown() {
1462 let state = ChatState::new();
1463 assert!(
1464 state.find_message(&make_hex_id(99)).is_none(),
1465 "unknown message id should return None"
1466 );
1467 }
1468
1469 #[test]
1470 fn find_message_empty_id_returns_none() {
1471 let state = ChatState::new();
1472 assert!(state.find_message("").is_none(), "empty id should return None");
1473 }
1474
1475 #[test]
1476 fn update_message_mutates_and_returns() {
1477 let mut state = ChatState::new();
1478 state.create_dm_chat("npub1peer");
1479
1480 let msg = make_message(1, "original", 1700000000000, false);
1481 let msg_id = msg.id.clone();
1482 state.add_message_to_chat("npub1peer", msg);
1483
1484 let result = state.update_message(&msg_id, |cm| {
1485 cm.content = "updated content".to_string().into_boxed_str();
1486 });
1487
1488 let (chat_id, updated) = result.expect("update should return Some");
1489 assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1490 assert_eq!(updated.content, "updated content", "content should be updated");
1491 }
1492
1493 #[test]
1494 fn update_message_returns_none_for_missing() {
1495 let mut state = ChatState::new();
1496 let result = state.update_message(&make_hex_id(99), |_cm| {});
1497 assert!(result.is_none(), "updating nonexistent message should return None");
1498 }
1499
1500 #[test]
1501 fn finalize_pending_message_changes_id() {
1502 let mut state = ChatState::new();
1503 state.create_dm_chat("npub1peer");
1504
1505 let mut msg = make_message(1, "pending msg", 1700000000000, true);
1506 msg.pending = true;
1507 let pending_id = msg.id.clone();
1508 state.add_message_to_chat("npub1peer", msg);
1509
1510 let real_id = make_hex_id(2);
1511 let result = state.finalize_pending_message("npub1peer", &pending_id, &real_id);
1512
1513 let (old_id, finalized) = result.expect("finalize should succeed");
1514 assert_eq!(old_id, pending_id, "should return old pending id");
1515 assert_eq!(finalized.id, real_id, "message id should now be the real id");
1516 assert!(!finalized.pending, "message should no longer be pending");
1517
1518 assert!(
1520 state.find_message(&pending_id).is_none(),
1521 "pending id should no longer resolve"
1522 );
1523 assert!(
1525 state.find_message(&real_id).is_some(),
1526 "real id should now resolve"
1527 );
1528 }
1529
1530 #[test]
1531 fn remove_message_works() {
1532 let mut state = ChatState::new();
1533 state.create_dm_chat("npub1peer");
1534
1535 let msg = make_message(1, "deleteme", 1700000000000, false);
1536 let msg_id = msg.id.clone();
1537 state.add_message_to_chat("npub1peer", msg);
1538
1539 let result = state.remove_message(&msg_id);
1540 assert!(result.is_some(), "remove should return the removed message");
1541
1542 let (chat_id, removed) = result.unwrap();
1543 assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1544 assert_eq!(removed.content, "deleteme", "content should match");
1545
1546 assert!(
1547 state.find_message(&msg_id).is_none(),
1548 "removed message should no longer be findable"
1549 );
1550 }
1551
1552 #[test]
1553 fn remove_message_retreats_last_read_marker() {
1554 let mut state = ChatState::new();
1555 state.create_dm_chat("npub1peer");
1556 let m1 = make_message(1, "one", 1_700_000_000_000, false);
1557 let m2 = make_message(2, "two", 1_700_000_001_000, false);
1558 let (m1_id, m2_id) = (m1.id.clone(), m2.id.clone());
1559 state.add_message_to_chat("npub1peer", m1);
1560 state.add_message_to_chat("npub1peer", m2);
1561
1562 state.chats.iter_mut().find(|c| c.id == "npub1peer").unwrap().last_read =
1564 crate::compact::encode_message_id(&m2_id);
1565
1566 state.remove_message(&m2_id);
1568 assert_eq!(state.get_chat("npub1peer").unwrap().last_read,
1569 crate::compact::encode_message_id(&m1_id), "marker retreats to m1");
1570
1571 state.remove_message(&m1_id);
1573 assert_eq!(state.get_chat("npub1peer").unwrap().last_read, [0u8; 32],
1574 "no predecessor → marker clears");
1575 }
1576
1577 #[test]
1578 fn remove_message_returns_none_for_missing() {
1579 let mut state = ChatState::new();
1580 assert!(
1581 state.remove_message(&make_hex_id(99)).is_none(),
1582 "removing nonexistent message should return None"
1583 );
1584 }
1585
1586 #[test]
1587 fn message_exists_check() {
1588 let mut state = ChatState::new();
1589 state.create_dm_chat("npub1peer");
1590
1591 let msg = make_message(1, "exists", 1700000000000, false);
1592 let msg_id = msg.id.clone();
1593 state.add_message_to_chat("npub1peer", msg);
1594
1595 assert!(state.message_exists(&msg_id), "added message should exist");
1596 assert!(!state.message_exists(&make_hex_id(99)), "unknown id should not exist");
1597 assert!(!state.message_exists(""), "empty id should not exist");
1598 }
1599
1600 #[test]
1601 fn chat_reordering_newest_first_after_message_add() {
1602 let mut state = ChatState::new();
1603 state.create_dm_chat("npub1old");
1604 state.create_dm_chat("npub1new");
1605
1606 let old_msg = make_message(1, "old", 1700000000000, false);
1608 state.add_message_to_chat("npub1old", old_msg);
1609
1610 let new_msg = make_message(2, "new", 1700000002000, false);
1612 state.add_message_to_chat("npub1new", new_msg);
1613
1614 assert_eq!(
1615 state.chats[0].id, "npub1new",
1616 "chat with newest message should be first"
1617 );
1618 assert_eq!(
1619 state.chats[1].id, "npub1old",
1620 "chat with older message should be second"
1621 );
1622 }
1623
1624 #[test]
1625 fn batch_add_does_not_reorder_for_old_messages() {
1626 let mut state = ChatState::new();
1627 state.create_dm_chat("npub1active");
1628 state.create_dm_chat("npub1history");
1629
1630 let recent = make_message(1, "recent", 1700000010000, false);
1632 state.add_message_to_chat("npub1active", recent);
1633
1634 let old_msgs: Vec<Message> = (10..15).map(|i| {
1636 make_message(i, &format!("old {}", i), 1700000000000 + i as u64 * 100, false)
1637 }).collect();
1638 state.add_messages_to_chat_batch("npub1history", old_msgs);
1639
1640 assert_eq!(
1641 state.chats[0].id, "npub1active",
1642 "active chat should remain first when batch has only old messages"
1643 );
1644 }
1645
1646 #[test]
1647 fn stress_test_50_messages_in_5_chats() {
1648 let mut state = ChatState::new();
1649
1650 for i in 0..5 {
1651 state.create_dm_chat(&format!("npub1chat{}", i));
1652 }
1653
1654 let mut total_added = 0;
1655 for i in 0..50u8 {
1656 let chat_idx = i as usize % 5;
1657 let chat_id = format!("npub1chat{}", chat_idx);
1658 let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, i % 3 == 0);
1659 if state.add_message_to_chat(&chat_id, msg) {
1660 total_added += 1;
1661 }
1662 }
1663
1664 assert_eq!(total_added, 50, "all 50 unique messages should be added");
1665
1666 let total_in_chats: usize = state.chats.iter().map(|c| c.message_count()).sum();
1667 assert_eq!(total_in_chats, 50, "total messages across all chats should be 50");
1668
1669 for i in 0..5 {
1671 let chat = state.get_chat(&format!("npub1chat{}", i)).unwrap();
1672 assert_eq!(
1673 chat.message_count(), 10,
1674 "chat {} should have 10 messages",
1675 i
1676 );
1677 }
1678
1679 for i in 0..50u8 {
1681 assert!(
1682 state.message_exists(&make_hex_id(i)),
1683 "message {} should exist",
1684 i
1685 );
1686 }
1687 }
1688
1689 #[test]
1690 fn add_message_auto_creates_dm_chat() {
1691 let mut state = ChatState::new();
1692
1693 let msg = make_message(1, "auto create", 1700000000000, false);
1695 let added = state.add_message_to_chat("npub1auto", msg);
1696
1697 assert!(added, "message should be added");
1698 assert!(state.get_chat("npub1auto").is_some(), "DM chat should be auto-created");
1699 }
1700
1701 #[test]
1702 fn add_message_auto_creates_community_chat() {
1703 let mut state = ChatState::new();
1704
1705 let msg = make_message(1, "group msg", 1700000000000, false);
1707 let added = state.add_message_to_chat("group_abc123", msg);
1708
1709 assert!(added, "message should be added");
1710 let chat = state.get_chat("group_abc123").expect("community chat should be auto-created");
1711 assert!(chat.is_community(), "auto-created non-npub chat should be a Community chat");
1712 }
1713
1714 #[test]
1719 fn count_unread_messages_basic() {
1720 let mut state = ChatState::new();
1721 state.create_dm_chat("npub1peer");
1722
1723 for i in 0..5u8 {
1724 let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false);
1725 state.add_message_to_chat("npub1peer", msg);
1726 }
1727
1728 assert_eq!(state.count_unread_messages(), 5, "all 5 non-mine messages should be unread");
1729 }
1730
1731 #[test]
1732 fn count_unread_muted_chat_skipped() {
1733 let mut state = ChatState::new();
1734 state.create_dm_chat("npub1muted");
1735
1736 let msg = make_message(1, "muted msg", 1700000000000, false);
1737 state.add_message_to_chat("npub1muted", msg);
1738
1739 state.get_chat_mut("npub1muted").unwrap().muted = true;
1740
1741 assert_eq!(state.count_unread_messages(), 0, "muted chat should not count toward unread");
1742 }
1743
1744 #[test]
1745 fn count_unread_blocked_user_skipped() {
1746 let mut state = ChatState::new();
1747
1748 let mut profile = Profile::new();
1749 profile.flags.set_blocked(true);
1750 state.insert_or_replace_profile("npub1blocked", profile);
1751 state.create_dm_chat("npub1blocked");
1752
1753 let msg = make_message(1, "blocked msg", 1700000000000, false);
1754 state.add_message_to_chat("npub1blocked", msg);
1755
1756 assert_eq!(state.count_unread_messages(), 0, "blocked user DM should not count");
1757 }
1758
1759 #[test]
1760 fn count_unread_own_messages_break_count() {
1761 let mut state = ChatState::new();
1762 state.create_dm_chat("npub1peer");
1763
1764 let msg1 = make_message(1, "them 1", 1700000001000, false);
1766 let msg2 = make_message(2, "them 2", 1700000002000, false);
1767 let msg3 = make_message(3, "them 3", 1700000003000, false);
1768 let msg_mine = make_message(4, "me", 1700000004000, true);
1769 let msg5 = make_message(5, "them 4", 1700000005000, false);
1770 let msg6 = make_message(6, "them 5", 1700000006000, false);
1771
1772 for m in [msg1, msg2, msg3, msg_mine, msg5, msg6] {
1773 state.add_message_to_chat("npub1peer", m);
1774 }
1775
1776 assert_eq!(
1778 state.count_unread_messages(), 2,
1779 "only messages after last 'mine' should count as unread"
1780 );
1781 }
1782
1783 #[test]
1784 fn count_unread_last_read_marker_breaks_count() {
1785 let mut state = ChatState::new();
1786 state.create_dm_chat("npub1peer");
1787
1788 let msg1 = make_message(1, "old", 1700000001000, false);
1789 let msg2 = make_message(2, "read up to here", 1700000002000, false);
1790 let msg3 = make_message(3, "new 1", 1700000003000, false);
1791 let msg4 = make_message(4, "new 2", 1700000004000, false);
1792 let read_marker_id = msg2.id.clone();
1793
1794 for m in [msg1, msg2, msg3, msg4] {
1795 state.add_message_to_chat("npub1peer", m);
1796 }
1797
1798 let chat = state.get_chat_mut("npub1peer").unwrap();
1800 chat.last_read = crate::simd::hex::hex_to_bytes_32(&read_marker_id);
1801
1802 assert_eq!(
1803 state.count_unread_messages(), 2,
1804 "only messages after last_read marker should count"
1805 );
1806 }
1807
1808 #[test]
1809 fn count_unread_empty_chats_is_zero() {
1810 let mut state = ChatState::new();
1811 state.create_dm_chat("npub1empty1");
1812 state.create_dm_chat("npub1empty2");
1813
1814 assert_eq!(state.count_unread_messages(), 0, "empty chats should have zero unread");
1815 }
1816
1817 #[test]
1818 fn count_unread_blocked_group_member_messages_skipped() {
1819 let mut state = ChatState::new();
1820
1821 let mut blocked_profile = Profile::new();
1823 blocked_profile.flags.set_blocked(true);
1824 state.insert_or_replace_profile("npub1blockedmember", blocked_profile);
1825
1826 let normal_profile = Profile::new();
1828 state.insert_or_replace_profile("npub1normal", normal_profile);
1829
1830 state.ensure_community_chat("grp1");
1831
1832 let msg_blocked = make_message_from(1, "blocked says hi", 1700000001000, "npub1blockedmember");
1834 state.add_message_to_chat("grp1", msg_blocked);
1835
1836 let msg_normal = make_message_from(2, "normal says hi", 1700000002000, "npub1normal");
1838 state.add_message_to_chat("grp1", msg_normal);
1839
1840 assert_eq!(
1841 state.count_unread_messages(), 1,
1842 "only the non-blocked member's message should count"
1843 );
1844 }
1845
1846 #[test]
1847 fn count_unread_multiple_chats_summed() {
1848 let mut state = ChatState::new();
1849
1850 for i in 0..3 {
1851 let npub = format!("npub1chat{}", i);
1852 state.create_dm_chat(&npub);
1853 for j in 0..3u8 {
1854 let msg = make_message(
1855 i * 10 + j,
1856 &format!("msg {}-{}", i, j),
1857 1700000000000 + j as u64 * 1000,
1858 false,
1859 );
1860 state.add_message_to_chat(&npub, msg);
1861 }
1862 }
1863
1864 assert_eq!(
1865 state.count_unread_messages(), 9,
1866 "3 chats x 3 unread each = 9 total"
1867 );
1868 }
1869
1870 #[test]
1875 fn update_typing_and_get_active_basic() {
1876 let mut state = ChatState::new();
1877 state.create_dm_chat("npub1peer");
1878
1879 let far_future = std::time::SystemTime::now()
1881 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1882
1883 let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
1884 assert_eq!(active.len(), 1, "should have one active typer");
1885 assert_eq!(active[0], "npub1typer", "typer npub should match");
1886 }
1887
1888 #[test]
1889 fn update_typing_expired_typers_filtered() {
1890 let mut state = ChatState::new();
1891 state.create_dm_chat("npub1peer");
1892
1893 let expired = 1000;
1895 let active = state.update_typing_and_get_active("npub1peer", "npub1expired", expired);
1896
1897 assert!(active.is_empty(), "expired typer should be filtered out");
1898 }
1899
1900 #[test]
1901 fn update_typing_multiple_typers() {
1902 let mut state = ChatState::new();
1903 state.create_dm_chat("npub1peer");
1904
1905 let far_future = std::time::SystemTime::now()
1906 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1907
1908 state.update_typing_and_get_active("npub1peer", "npub1typer1", far_future);
1909 let active = state.update_typing_and_get_active("npub1peer", "npub1typer2", far_future);
1910
1911 assert_eq!(active.len(), 2, "should have two active typers");
1912 }
1913
1914 #[test]
1915 fn update_typing_unknown_chat_returns_empty() {
1916 let mut state = ChatState::new();
1917 let far_future = std::time::SystemTime::now()
1918 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1919
1920 let active = state.update_typing_and_get_active("npub1nonexistent", "npub1typer", far_future);
1921 assert!(active.is_empty(), "unknown chat should return empty typers");
1922 }
1923
1924 #[test]
1925 fn update_typing_refreshes_existing_typer() {
1926 let mut state = ChatState::new();
1927 state.create_dm_chat("npub1peer");
1928
1929 let far_future = std::time::SystemTime::now()
1930 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1931
1932 state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
1933 let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future + 100);
1935
1936 assert_eq!(active.len(), 1, "should still have only one typer entry after refresh");
1937 }
1938
1939 #[test]
1944 fn wrapper_id_cache_historical_and_pending() {
1945 let mut cache = WrapperIdCache::new();
1946
1947 let id1 = [1u8; 32];
1948 let id2 = [2u8; 32];
1949 let id3 = [3u8; 32];
1950
1951 cache.load(vec![id1, id2]);
1952 cache.insert(id3);
1953
1954 assert!(cache.contains(&id1), "historical id should be found");
1955 assert!(cache.contains(&id2), "historical id should be found");
1956 assert!(cache.contains(&id3), "pending id should be found");
1957 assert!(!cache.contains(&[4u8; 32]), "unknown id should not be found");
1958 assert_eq!(cache.len(), 3, "total count should be 3");
1959 }
1960
1961 #[test]
1962 fn wrapper_id_cache_clear() {
1963 let mut cache = WrapperIdCache::new();
1964 cache.load(vec![[1u8; 32]]);
1965 cache.insert([2u8; 32]);
1966
1967 cache.clear();
1968
1969 assert_eq!(cache.len(), 0, "cache should be empty after clear");
1970 assert!(!cache.contains(&[1u8; 32]), "cleared historical should not be found");
1971 assert!(!cache.contains(&[2u8; 32]), "cleared pending should not be found");
1972 }
1973}