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 protocol: crate::community::ConcordProtocol,
667 ) {
668 self.ensure_community_chat(channel_id);
669 if let Some(chat) = self.chats.iter_mut().find(|c| c.id == channel_id) {
670 let cf = &mut chat.metadata.custom_fields;
671 cf.insert("name".to_string(), name.to_string());
672 cf.insert("description".to_string(), description.to_string());
673 cf.insert("community_id".to_string(), community_id.to_string());
674 cf.insert("is_owner".to_string(), is_owner.to_string());
675 let new_proto = protocol.as_i64();
682 let cur_proto = cf.get("proto_version").and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
683 cf.insert("proto_version".to_string(), new_proto.max(cur_proto).to_string());
684 cf.insert("dissolved".to_string(), dissolved.to_string());
686 if let Some(ms) = created_at_ms {
688 cf.insert("created_at".to_string(), ms.to_string());
689 }
690 match owner_npub {
692 Some(n) => { cf.insert("owner_npub".to_string(), n.to_string()); }
693 None => { cf.remove("owner_npub"); }
694 }
695 if has_icon {
696 cf.insert("icon".to_string(), "1".to_string());
697 } else {
698 cf.remove("icon");
699 }
700 }
701 }
702
703 pub fn add_message_to_chat(&mut self, chat_id: &str, message: Message) -> bool {
704 let compact = CompactMessage::from_message(&message, &mut self.interner);
705
706 let (is_msg_added, chat_idx) = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
707 let added = self.chats[idx].add_compact_message(compact);
708 (added, idx)
709 } else {
710 let mut chat = if chat_id.starts_with("npub1") {
711 Chat::new_dm(chat_id.to_string(), &mut self.interner)
712 } else {
713 Chat::new(chat_id.to_string(), ChatType::Community, vec![])
714 };
715 let was_added = chat.add_compact_message(compact);
716 self.chats.push(chat);
717 (was_added, self.chats.len() - 1)
718 };
719
720 if is_msg_added && chat_idx > 0 {
721 let this_time = self.chats[chat_idx].last_message_time();
722 let target = self.chats[..chat_idx].iter()
723 .position(|c| c.last_message_time() <= this_time)
724 .unwrap_or(chat_idx);
725 if target < chat_idx {
726 self.chats[target..=chat_idx].rotate_right(1);
727 }
728 }
729
730 is_msg_added
731 }
732
733 pub fn add_messages_to_chat_batch(&mut self, chat_id: &str, messages: Vec<Message>) -> usize {
734 if messages.is_empty() { return 0; }
735
736 let compact_messages: Vec<_> = messages.into_iter()
737 .map(|msg| CompactMessage::from_message_owned(msg, &mut self.interner))
738 .collect();
739
740 let chat_idx = if let Some(idx) = self.chats.iter().position(|c| c.id == chat_id) {
741 idx
742 } else {
743 let chat = if chat_id.starts_with("npub1") {
744 Chat::new_dm(chat_id.to_string(), &mut self.interner)
745 } else {
746 Chat::new(chat_id.to_string(), ChatType::Community, vec![])
747 };
748 self.chats.push(chat);
749 self.chats.len() - 1
750 };
751
752 let old_last_time = self.chats[chat_idx].messages.last_timestamp();
753 let added = self.chats[chat_idx].messages.insert_batch(compact_messages);
754
755 if added > 0 && self.chats[chat_idx].messages.last_timestamp() != old_last_time && chat_idx > 0 {
756 let this_time = self.chats[chat_idx].last_message_time();
757 let target = self.chats[..chat_idx].iter()
758 .position(|c| c.last_message_time() <= this_time)
759 .unwrap_or(chat_idx);
760 if target < chat_idx {
761 self.chats[target..=chat_idx].rotate_right(1);
762 }
763 }
764
765 added
766 }
767
768 pub fn add_message_to_participant(&mut self, their_npub: &str, message: Message) -> bool {
772 let id = self.interner.intern(their_npub);
773 if self.get_profile_by_id(id).is_none() {
774 let profile = Profile::new();
775 self.insert_or_replace_profile(their_npub, profile);
776
777 if let Some(slim) = self.serialize_profile(id) {
779 emit_event("profile_update", &slim);
780 }
781 }
782
783 let chat_id = self.create_dm_chat(their_npub);
784 self.add_message_to_chat(&chat_id, message)
785 }
786
787 pub fn find_message(&self, message_id: &str) -> Option<(&Chat, Message)> {
792 if message_id.is_empty() { return None; }
793 for chat in &self.chats {
794 if let Some(compact) = chat.get_compact_message(message_id) {
795 return Some((chat, compact.to_message(&self.interner)));
796 }
797 }
798 None
799 }
800
801 pub fn find_chat_for_message(&self, message_id: &str) -> Option<(usize, String)> {
802 if message_id.is_empty() { return None; }
803 for (idx, chat) in self.chats.iter().enumerate() {
804 if chat.has_message(message_id) { return Some((idx, chat.id.clone())); }
805 }
806 None
807 }
808
809 pub fn update_message<F>(&mut self, message_id: &str, f: F) -> Option<(String, Message)>
810 where F: FnOnce(&mut CompactMessage)
811 {
812 if message_id.is_empty() { return None; }
813 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
814 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
815 let chat_id = self.chats[chat_idx].id.clone();
816 self.chats[chat_idx].get_compact_message(message_id).map(|m| (chat_id, m.to_message(&self.interner)))
817 }
818
819 pub fn update_message_in_chat<F>(&mut self, chat_id: &str, message_id: &str, f: F) -> Option<Message>
820 where F: FnOnce(&mut CompactMessage)
821 {
822 let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
823 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(message_id) { f(msg); }
824 self.chats[chat_idx].get_compact_message(message_id).map(|m| m.to_message(&self.interner))
825 }
826
827 pub fn finalize_pending_message(&mut self, chat_id: &str, pending_id: &str, real_id: &str) -> Option<(String, Message)> {
828 let chat_idx = self.chats.iter().position(|c| c.id == chat_id)?;
829 if let Some(msg) = self.chats[chat_idx].get_compact_message_mut(pending_id) {
830 msg.id = crate::simd::hex::hex_to_bytes_32(real_id);
831 msg.set_pending(false);
832 }
833 self.chats[chat_idx].messages.rebuild_index();
834 self.chats[chat_idx].get_compact_message(real_id)
835 .map(|m| (pending_id.to_string(), m.to_message(&self.interner)))
836 }
837
838 pub fn update_attachment<F>(&mut self, chat_hint: &str, msg_id: &str, attachment_id: &str, f: F) -> bool
839 where F: FnOnce(&mut CompactAttachment)
840 {
841 for chat in &mut self.chats {
842 let is_target = match &chat.chat_type {
843 ChatType::Community => chat.id == chat_hint,
845 ChatType::DirectMessage => chat.has_participant(chat_hint, &self.interner),
846 };
847 if is_target {
848 if let Some(msg) = chat.messages.find_by_hex_id_mut(msg_id) {
849 if let Some(att) = msg.attachments.iter_mut().find(|a| a.id_eq(attachment_id)) {
850 f(att);
851 return true;
852 }
853 }
854 }
855 }
856 false
857 }
858
859 pub fn add_attachment_to_message(&mut self, chat_id: &str, msg_id: &str, attachment: CompactAttachment) -> bool {
860 let chat_idx = match self.chats.iter().position(|c| c.id == chat_id || c.has_participant(chat_id, &self.interner)) {
861 Some(idx) => idx,
862 None => return false,
863 };
864 if let Some(msg) = self.chats[chat_idx].messages.find_by_hex_id_mut(msg_id) {
865 msg.attachments.push(attachment);
866 true
867 } else { false }
868 }
869
870 pub fn add_reaction_to_message(&mut self, message_id: &str, reaction: Reaction) -> Option<(String, bool)> {
871 if message_id.is_empty() { return None; }
872 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
873 let chat_id = self.chats[chat_idx].id.clone();
874 let msg = self.chats[chat_idx].get_compact_message_mut(message_id)?;
875 let added = msg.add_reaction(reaction, &mut self.interner);
876 Some((chat_id, added))
877 }
878
879 pub fn find_reaction(&self, reaction_id: &str) -> Option<(String, String, String, bool)> {
882 if reaction_id.is_empty() { return None; }
883 let target = crate::simd::hex::hex_to_bytes_32(reaction_id);
884 for chat in &self.chats {
885 for msg in chat.iter_compact() {
886 if let Some(r) = msg.reactions.iter().find(|r| r.id == target) {
887 let author = self.interner.resolve(r.author_idx).unwrap_or("").to_string();
888 return Some((chat.id.clone(), msg.id_hex(), author, chat.is_community()));
889 }
890 }
891 }
892 None
893 }
894
895 pub fn remove_reaction_from_message(&mut self, message_id: &str, reaction_id: &str) -> Option<(String, Message)> {
898 if message_id.is_empty() { return None; }
899 let chat_idx = self.chats.iter().position(|chat| chat.has_message(message_id))?;
900 let removed = self.chats[chat_idx]
901 .get_compact_message_mut(message_id)
902 .map(|m| m.remove_reaction(reaction_id))
903 .unwrap_or(false);
904 if !removed { return None; }
905 let chat_id = self.chats[chat_idx].id.clone();
906 self.chats[chat_idx]
907 .get_compact_message(message_id)
908 .map(|m| (chat_id, m.to_message(&self.interner)))
909 }
910
911 pub fn remove_message(&mut self, message_id: &str) -> Option<(String, Message)> {
912 if message_id.is_empty() { return None; }
913 for chat in &mut self.chats {
914 if let Some(compact) = chat.messages.find_by_hex_id(message_id) {
915 let msg = compact.to_message(&self.interner);
916 let removed_id = compact.id;
917 let removed_at = compact.at;
918 let chat_id = chat.id.clone();
919 let was_marker = chat.last_read == removed_id;
920 chat.messages.remove_by_hex_id(message_id);
921 if was_marker {
925 chat.last_read = chat.messages.iter().rev()
926 .find(|m| m.at <= removed_at && !m.flags.is_mine())
927 .map(|m| m.id)
928 .unwrap_or([0u8; 32]);
929 }
930 return Some((chat_id, msg));
931 }
932 }
933 None
934 }
935
936 pub fn message_exists(&self, message_id: &str) -> bool {
937 !message_id.is_empty() && self.chats.iter().any(|chat| chat.has_message(message_id))
938 }
939
940 pub fn sum_unread_from(&self, counts: &std::collections::HashMap<String, u32>) -> u32 {
949 let mut total = 0u32;
950 for chat in &self.chats {
951 if chat.muted {
952 continue;
953 }
954 if !chat.is_community() {
955 if let Some(id) = self.interner.lookup(&chat.id) {
956 if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) {
957 continue;
958 }
959 }
960 } else if !chat.metadata.custom_fields.contains_key("community_id") {
961 continue;
965 }
966 total += counts.get(&chat.id).copied().unwrap_or(0);
967 }
968 total
969 }
970
971 pub fn count_unread_messages(&self) -> u32 {
972 let mut total_unread = 0;
973 for chat in &self.chats {
974 if chat.muted { continue; }
975 let is_group = chat.is_community();
976 if !is_group {
977 if let Some(id) = self.interner.lookup(&chat.id) {
978 if self.get_profile_by_id(id).map_or(false, |p| p.flags.is_blocked()) { continue; }
979 }
980 } else if !chat.metadata.custom_fields.contains_key("community_id") {
981 continue;
983 }
984 let mut unread_count = 0u32;
985 for msg in chat.iter_compact().rev() {
986 if msg.flags.is_mine() { break; }
987 if chat.last_read != [0u8; 32] && msg.id == chat.last_read { break; }
988 if is_group && msg.npub_idx != NO_NPUB {
989 if self.get_profile_by_id(msg.npub_idx).map_or(false, |p| p.flags.is_blocked()) { continue; }
990 }
991 unread_count += 1;
992 }
993 #[cfg(debug_assertions)]
995 if unread_count > 0 {
996 let last_read_hex = crate::compact::decode_message_id(&chat.last_read);
997 let last_msg_hex = chat.messages.last().map(|m| crate::compact::decode_message_id(&m.id)).unwrap_or_default();
998 let msg_count = chat.message_count();
999 eprintln!("[Unread] chat={} unread={} msgs_in_memory={} last_read={} last_msg={}",
1000 &chat.id[..20.min(chat.id.len())], unread_count, msg_count,
1001 &last_read_hex[..16.min(last_read_hex.len())], &last_msg_hex[..16.min(last_msg_hex.len())]);
1002 }
1003 total_unread += unread_count;
1004 }
1005 total_unread
1006 }
1007
1008 pub fn update_typing_and_get_active(&mut self, chat_id: &str, npub: &str, expires_at: u64) -> Vec<String> {
1013 let handle = self.interner.intern(npub);
1014 if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
1015 chat.update_typing_participant(handle, expires_at);
1016 chat.get_active_typers(&self.interner)
1017 } else {
1018 Vec::new()
1019 }
1020 }
1021}
1022
1023impl Default for ChatState {
1024 fn default() -> Self { Self::new() }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029 use super::*;
1030 use crate::types::Message;
1031 use crate::profile::{Profile, SlimProfile, Status};
1032 use crate::simd::hex::bytes_to_hex_32;
1033
1034 fn make_hex_id(seed: u8) -> String {
1041 let mut bytes = [seed; 32];
1042 bytes[0] = seed.wrapping_add(0x10) | 0x10; bytes[1] = seed.wrapping_mul(37);
1044 bytes_to_hex_32(&bytes)
1045 }
1046
1047 fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
1049 Message {
1050 id: make_hex_id(id_seed),
1051 content: content.to_string(),
1052 at: timestamp_ms,
1053 mine,
1054 ..Default::default()
1055 }
1056 }
1057
1058 fn make_message_from(id_seed: u8, content: &str, timestamp_ms: u64, npub: &str) -> Message {
1060 Message {
1061 id: make_hex_id(id_seed),
1062 content: content.to_string(),
1063 at: timestamp_ms,
1064 mine: false,
1065 npub: Some(npub.to_string()),
1066 ..Default::default()
1067 }
1068 }
1069
1070 fn make_slim_profile(id: &str, name: &str) -> SlimProfile {
1072 SlimProfile {
1073 id: id.to_string(),
1074 name: name.to_string(),
1075 display_name: String::new(),
1076 nickname: String::new(),
1077 lud06: String::new(),
1078 lud16: String::new(),
1079 banner: String::new(),
1080 avatar: String::new(),
1081 about: String::new(),
1082 website: String::new(),
1083 nip05: String::new(),
1084 status: Status::new(),
1085 last_updated: 0,
1086 mine: false,
1087 bot: false,
1088 is_blocked: false,
1089 avatar_cached: String::new(),
1090 banner_cached: String::new(),
1091 }
1092 }
1093
1094 #[test]
1099 fn insert_or_replace_profile_creates_new() {
1100 let mut state = ChatState::new();
1101 let profile = Profile::new();
1102 state.insert_or_replace_profile("npub1alice", profile);
1103
1104 assert!(
1105 state.get_profile("npub1alice").is_some(),
1106 "newly inserted profile should be retrievable"
1107 );
1108 assert_eq!(state.profiles.len(), 1, "should have exactly one profile");
1109 }
1110
1111 #[test]
1112 fn insert_or_replace_profile_updates_existing() {
1113 let mut state = ChatState::new();
1114 let mut p1 = Profile::new();
1115 p1.name = "Alice".to_string().into_boxed_str();
1116 state.insert_or_replace_profile("npub1alice", p1);
1117
1118 let mut p2 = Profile::new();
1119 p2.name = "Alice Updated".to_string().into_boxed_str();
1120 state.insert_or_replace_profile("npub1alice", p2);
1121
1122 let fetched = state.get_profile("npub1alice").expect("profile should exist");
1123 assert_eq!(
1124 &*fetched.name, "Alice Updated",
1125 "profile name should be updated after replace"
1126 );
1127 assert_eq!(state.profiles.len(), 1, "should still be one profile, not duplicated");
1128 }
1129
1130 #[test]
1131 fn get_profile_by_npub() {
1132 let mut state = ChatState::new();
1133 let mut profile = Profile::new();
1134 profile.name = "Bob".to_string().into_boxed_str();
1135 state.insert_or_replace_profile("npub1bob", profile);
1136
1137 let fetched = state.get_profile("npub1bob").expect("profile should be found");
1138 assert_eq!(&*fetched.name, "Bob", "fetched profile name should match");
1139 }
1140
1141 #[test]
1142 fn get_profile_returns_none_for_unknown() {
1143 let state = ChatState::new();
1144 assert!(
1145 state.get_profile("npub1unknown").is_none(),
1146 "unknown npub should return None"
1147 );
1148 }
1149
1150 #[test]
1151 fn get_profile_by_id_works() {
1152 let mut state = ChatState::new();
1153 let mut profile = Profile::new();
1154 profile.name = "Charlie".to_string().into_boxed_str();
1155 state.insert_or_replace_profile("npub1charlie", profile);
1156
1157 let id = state.interner.lookup("npub1charlie").expect("npub should be interned");
1158 let fetched = state.get_profile_by_id(id).expect("profile should be found by id");
1159 assert_eq!(&*fetched.name, "Charlie", "profile looked up by id should match");
1160 }
1161
1162 #[test]
1163 fn get_profile_by_id_returns_none_for_invalid() {
1164 let state = ChatState::new();
1165 assert!(
1166 state.get_profile_by_id(9999).is_none(),
1167 "invalid interner id should return None"
1168 );
1169 }
1170
1171 #[test]
1172 fn merge_db_profiles_sets_mine_flag() {
1173 let mut state = ChatState::new();
1174 let slim_mine = make_slim_profile("npub1me", "Me");
1175 let slim_other = make_slim_profile("npub1other", "Other");
1176
1177 state.merge_db_profiles(vec![slim_mine, slim_other], "npub1me");
1178
1179 let me = state.get_profile("npub1me").expect("my profile should exist");
1180 assert!(me.flags.is_mine(), "my profile should have mine flag set");
1181
1182 let other = state.get_profile("npub1other").expect("other profile should exist");
1183 assert!(!other.flags.is_mine(), "other profile should not have mine flag");
1184 }
1185
1186 #[test]
1187 fn serialize_profile_roundtrip() {
1188 let mut state = ChatState::new();
1189 let mut profile = Profile::new();
1190 profile.name = "Roundtrip".to_string().into_boxed_str();
1191 profile.about = "Test about".to_string().into_boxed_str();
1192 profile.flags.set_blocked(true);
1193 state.insert_or_replace_profile("npub1round", profile);
1194
1195 let id = state.interner.lookup("npub1round").unwrap();
1196 let slim = state.serialize_profile(id).expect("serialization should succeed");
1197
1198 assert_eq!(slim.id, "npub1round", "serialized id should match");
1199 assert_eq!(slim.name, "Roundtrip", "serialized name should match");
1200 assert_eq!(slim.about, "Test about", "serialized about should match");
1201 assert!(slim.is_blocked, "serialized blocked flag should be true");
1202
1203 let restored = slim.to_profile();
1205 assert_eq!(&*restored.name, "Roundtrip", "restored name should match");
1206 assert!(restored.flags.is_blocked(), "restored blocked flag should be true");
1207 }
1208
1209 #[test]
1210 fn binary_search_maintains_sorted_order_with_100_profiles() {
1211 let mut state = ChatState::new();
1212
1213 let npubs: Vec<String> = (0..100).map(|i| format!("npub1user{:04}", i)).collect();
1215 let mut shuffled = npubs.clone();
1216 for i in (1..shuffled.len()).rev() {
1218 let j = (i * 37 + 13) % (i + 1);
1219 shuffled.swap(i, j);
1220 }
1221
1222 for npub in &shuffled {
1223 let mut profile = Profile::new();
1224 profile.name = npub.clone().into_boxed_str();
1225 state.insert_or_replace_profile(npub, profile);
1226 }
1227
1228 for npub in &npubs {
1230 assert!(
1231 state.get_profile(npub).is_some(),
1232 "profile {} should be retrievable after bulk insert",
1233 npub
1234 );
1235 }
1236
1237 for window in state.profiles.windows(2) {
1239 assert!(
1240 window[0].id < window[1].id,
1241 "profiles should be sorted by interner id"
1242 );
1243 }
1244
1245 assert_eq!(state.profiles.len(), 100, "should have exactly 100 profiles");
1246 }
1247
1248 #[test]
1249 fn insert_same_npub_twice_updates_not_duplicates() {
1250 let mut state = ChatState::new();
1251
1252 for i in 0..5 {
1253 let mut profile = Profile::new();
1254 profile.name = format!("version_{}", i).into_boxed_str();
1255 state.insert_or_replace_profile("npub1repeated", profile);
1256 }
1257
1258 assert_eq!(state.profiles.len(), 1, "repeated inserts should not create duplicates");
1259 let p = state.get_profile("npub1repeated").unwrap();
1260 assert_eq!(&*p.name, "version_4", "should retain the last update");
1261 }
1262
1263 #[test]
1264 fn get_profile_mut_modifies_in_place() {
1265 let mut state = ChatState::new();
1266 let profile = Profile::new();
1267 state.insert_or_replace_profile("npub1mutable", profile);
1268
1269 let p = state.get_profile_mut("npub1mutable").expect("profile should exist");
1270 p.name = "Mutated".to_string().into_boxed_str();
1271
1272 let fetched = state.get_profile("npub1mutable").unwrap();
1273 assert_eq!(&*fetched.name, "Mutated", "mutation should persist");
1274 }
1275
1276 #[test]
1281 fn create_dm_chat_creates_new() {
1282 let mut state = ChatState::new();
1283 let id = state.create_dm_chat("npub1peer");
1284
1285 assert_eq!(id, "npub1peer", "returned id should match the npub");
1286 assert!(state.get_chat("npub1peer").is_some(), "chat should be created");
1287 assert_eq!(state.chats.len(), 1, "should have exactly one chat");
1288 }
1289
1290 #[test]
1291 fn create_dm_chat_is_idempotent() {
1292 let mut state = ChatState::new();
1293 state.create_dm_chat("npub1peer");
1294 state.create_dm_chat("npub1peer");
1295 state.create_dm_chat("npub1peer");
1296
1297 assert_eq!(state.chats.len(), 1, "repeated creates should not duplicate");
1298 }
1299
1300 #[test]
1301 fn ensure_community_chat_idempotent() {
1302 let mut state = ChatState::new();
1303 state.ensure_community_chat("grp1");
1304 state.ensure_community_chat("grp1");
1305
1306 assert_eq!(state.chats.len(), 1, "second call should not create a duplicate");
1307 let chat = state.get_chat("grp1").expect("community chat should exist");
1308 assert!(chat.is_community(), "should be a Community chat");
1309 }
1310
1311 #[test]
1312 fn get_chat_by_id() {
1313 let mut state = ChatState::new();
1314 state.create_dm_chat("npub1x");
1315
1316 let chat = state.get_chat("npub1x").expect("chat should exist");
1317 assert_eq!(chat.id, "npub1x", "chat id should match");
1318 }
1319
1320 #[test]
1321 fn get_chat_returns_none_for_missing() {
1322 let state = ChatState::new();
1323 assert!(state.get_chat("nonexistent").is_none(), "missing chat should return None");
1324 }
1325
1326 #[test]
1327 fn get_chat_mut_modifies_in_place() {
1328 let mut state = ChatState::new();
1329 state.create_dm_chat("npub1editable");
1330
1331 let chat = state.get_chat_mut("npub1editable").expect("chat should exist");
1332 chat.muted = true;
1333
1334 let refetched = state.get_chat("npub1editable").unwrap();
1335 assert!(refetched.muted, "muted flag should persist after mutation");
1336 }
1337
1338 #[test]
1339 fn multiple_different_chats() {
1340 let mut state = ChatState::new();
1341 state.create_dm_chat("npub1alice");
1342 state.create_dm_chat("npub1bob");
1343 state.ensure_community_chat("grp1");
1344
1345 assert_eq!(state.chats.len(), 3, "should have three distinct chats");
1346 }
1347
1348 #[test]
1353 fn add_message_to_chat_single() {
1354 let mut state = ChatState::new();
1355 state.create_dm_chat("npub1peer");
1356
1357 let msg = make_message(1, "hello", 1700000000000, false);
1358 let added = state.add_message_to_chat("npub1peer", msg);
1359
1360 assert!(added, "first message should be added successfully");
1361 let chat = state.get_chat("npub1peer").unwrap();
1362 assert_eq!(chat.message_count(), 1, "chat should have one message");
1363 }
1364
1365 #[test]
1366 fn add_message_to_chat_dedup_rejects_same_id() {
1367 let mut state = ChatState::new();
1368 state.create_dm_chat("npub1peer");
1369
1370 let msg1 = make_message(1, "hello", 1700000000000, false);
1371 let msg2 = make_message(1, "duplicate", 1700000001000, false);
1372
1373 let added1 = state.add_message_to_chat("npub1peer", msg1);
1374 let added2 = state.add_message_to_chat("npub1peer", msg2);
1375
1376 assert!(added1, "first insert should succeed");
1377 assert!(!added2, "duplicate ID should be rejected");
1378 assert_eq!(
1379 state.get_chat("npub1peer").unwrap().message_count(), 1,
1380 "should still have only one message"
1381 );
1382 }
1383
1384 #[test]
1385 fn add_messages_to_chat_batch_works() {
1386 let mut state = ChatState::new();
1387 state.create_dm_chat("npub1peer");
1388
1389 let msgs: Vec<Message> = (0..10).map(|i| {
1390 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1391 }).collect();
1392
1393 let added = state.add_messages_to_chat_batch("npub1peer", msgs);
1394 assert_eq!(added, 10, "all 10 messages should be added");
1395 assert_eq!(
1396 state.get_chat("npub1peer").unwrap().message_count(), 10,
1397 "chat should have 10 messages"
1398 );
1399 }
1400
1401 #[test]
1402 fn add_messages_to_chat_batch_dedup() {
1403 let mut state = ChatState::new();
1404 state.create_dm_chat("npub1peer");
1405
1406 let msgs1: Vec<Message> = (0..5).map(|i| {
1408 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1409 }).collect();
1410 state.add_messages_to_chat_batch("npub1peer", msgs1);
1411
1412 let msgs2: Vec<Message> = (3..8).map(|i| {
1414 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false)
1415 }).collect();
1416 let added = state.add_messages_to_chat_batch("npub1peer", msgs2);
1417
1418 assert_eq!(added, 3, "only 3 new messages should be added (5, 6, 7)");
1419 assert_eq!(
1420 state.get_chat("npub1peer").unwrap().message_count(), 8,
1421 "total should be 8 unique messages"
1422 );
1423 }
1424
1425 #[test]
1426 fn add_message_to_participant_creates_profile_and_chat() {
1427 let mut state = ChatState::new();
1428
1429 let msg = make_message(1, "hi there", 1700000000000, false);
1430 let added = state.add_message_to_participant("npub1stranger", msg);
1431
1432 assert!(added, "message should be added");
1433 assert!(
1434 state.get_profile("npub1stranger").is_some(),
1435 "profile should be auto-created for unknown participant"
1436 );
1437 assert!(
1438 state.get_chat("npub1stranger").is_some(),
1439 "DM chat should be auto-created"
1440 );
1441 }
1442
1443 #[test]
1444 fn add_message_to_participant_uses_existing_profile() {
1445 let mut state = ChatState::new();
1446
1447 let mut profile = Profile::new();
1449 profile.name = "Known User".to_string().into_boxed_str();
1450 state.insert_or_replace_profile("npub1known", profile);
1451
1452 let msg = make_message(1, "hello", 1700000000000, false);
1453 state.add_message_to_participant("npub1known", msg);
1454
1455 let p = state.get_profile("npub1known").unwrap();
1457 assert_eq!(&*p.name, "Known User", "existing profile should not be overwritten");
1458 }
1459
1460 #[test]
1461 fn find_message_across_chats() {
1462 let mut state = ChatState::new();
1463 state.create_dm_chat("npub1a");
1464 state.create_dm_chat("npub1b");
1465
1466 let msg_a = make_message(1, "in chat a", 1700000000000, false);
1467 let msg_b = make_message(2, "in chat b", 1700000001000, false);
1468 let msg_id_b = msg_b.id.clone();
1469
1470 state.add_message_to_chat("npub1a", msg_a);
1471 state.add_message_to_chat("npub1b", msg_b);
1472
1473 let (chat, found_msg) = state.find_message(&msg_id_b).expect("message should be found");
1474 assert_eq!(chat.id, "npub1b", "should find in correct chat");
1475 assert_eq!(found_msg.content, "in chat b", "content should match");
1476 }
1477
1478 #[test]
1479 fn find_message_returns_none_for_unknown() {
1480 let state = ChatState::new();
1481 assert!(
1482 state.find_message(&make_hex_id(99)).is_none(),
1483 "unknown message id should return None"
1484 );
1485 }
1486
1487 #[test]
1488 fn find_message_empty_id_returns_none() {
1489 let state = ChatState::new();
1490 assert!(state.find_message("").is_none(), "empty id should return None");
1491 }
1492
1493 #[test]
1494 fn update_message_mutates_and_returns() {
1495 let mut state = ChatState::new();
1496 state.create_dm_chat("npub1peer");
1497
1498 let msg = make_message(1, "original", 1700000000000, false);
1499 let msg_id = msg.id.clone();
1500 state.add_message_to_chat("npub1peer", msg);
1501
1502 let result = state.update_message(&msg_id, |cm| {
1503 cm.content = "updated content".to_string().into_boxed_str();
1504 });
1505
1506 let (chat_id, updated) = result.expect("update should return Some");
1507 assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1508 assert_eq!(updated.content, "updated content", "content should be updated");
1509 }
1510
1511 #[test]
1512 fn update_message_returns_none_for_missing() {
1513 let mut state = ChatState::new();
1514 let result = state.update_message(&make_hex_id(99), |_cm| {});
1515 assert!(result.is_none(), "updating nonexistent message should return None");
1516 }
1517
1518 #[test]
1519 fn finalize_pending_message_changes_id() {
1520 let mut state = ChatState::new();
1521 state.create_dm_chat("npub1peer");
1522
1523 let mut msg = make_message(1, "pending msg", 1700000000000, true);
1524 msg.pending = true;
1525 let pending_id = msg.id.clone();
1526 state.add_message_to_chat("npub1peer", msg);
1527
1528 let real_id = make_hex_id(2);
1529 let result = state.finalize_pending_message("npub1peer", &pending_id, &real_id);
1530
1531 let (old_id, finalized) = result.expect("finalize should succeed");
1532 assert_eq!(old_id, pending_id, "should return old pending id");
1533 assert_eq!(finalized.id, real_id, "message id should now be the real id");
1534 assert!(!finalized.pending, "message should no longer be pending");
1535
1536 assert!(
1538 state.find_message(&pending_id).is_none(),
1539 "pending id should no longer resolve"
1540 );
1541 assert!(
1543 state.find_message(&real_id).is_some(),
1544 "real id should now resolve"
1545 );
1546 }
1547
1548 #[test]
1549 fn remove_message_works() {
1550 let mut state = ChatState::new();
1551 state.create_dm_chat("npub1peer");
1552
1553 let msg = make_message(1, "deleteme", 1700000000000, false);
1554 let msg_id = msg.id.clone();
1555 state.add_message_to_chat("npub1peer", msg);
1556
1557 let result = state.remove_message(&msg_id);
1558 assert!(result.is_some(), "remove should return the removed message");
1559
1560 let (chat_id, removed) = result.unwrap();
1561 assert_eq!(chat_id, "npub1peer", "should return correct chat id");
1562 assert_eq!(removed.content, "deleteme", "content should match");
1563
1564 assert!(
1565 state.find_message(&msg_id).is_none(),
1566 "removed message should no longer be findable"
1567 );
1568 }
1569
1570 #[test]
1571 fn remove_message_retreats_last_read_marker() {
1572 let mut state = ChatState::new();
1573 state.create_dm_chat("npub1peer");
1574 let m1 = make_message(1, "one", 1_700_000_000_000, false);
1575 let m2 = make_message(2, "two", 1_700_000_001_000, false);
1576 let (m1_id, m2_id) = (m1.id.clone(), m2.id.clone());
1577 state.add_message_to_chat("npub1peer", m1);
1578 state.add_message_to_chat("npub1peer", m2);
1579
1580 state.chats.iter_mut().find(|c| c.id == "npub1peer").unwrap().last_read =
1582 crate::compact::encode_message_id(&m2_id);
1583
1584 state.remove_message(&m2_id);
1586 assert_eq!(state.get_chat("npub1peer").unwrap().last_read,
1587 crate::compact::encode_message_id(&m1_id), "marker retreats to m1");
1588
1589 state.remove_message(&m1_id);
1591 assert_eq!(state.get_chat("npub1peer").unwrap().last_read, [0u8; 32],
1592 "no predecessor → marker clears");
1593 }
1594
1595 #[test]
1596 fn remove_message_returns_none_for_missing() {
1597 let mut state = ChatState::new();
1598 assert!(
1599 state.remove_message(&make_hex_id(99)).is_none(),
1600 "removing nonexistent message should return None"
1601 );
1602 }
1603
1604 #[test]
1605 fn message_exists_check() {
1606 let mut state = ChatState::new();
1607 state.create_dm_chat("npub1peer");
1608
1609 let msg = make_message(1, "exists", 1700000000000, false);
1610 let msg_id = msg.id.clone();
1611 state.add_message_to_chat("npub1peer", msg);
1612
1613 assert!(state.message_exists(&msg_id), "added message should exist");
1614 assert!(!state.message_exists(&make_hex_id(99)), "unknown id should not exist");
1615 assert!(!state.message_exists(""), "empty id should not exist");
1616 }
1617
1618 #[test]
1619 fn chat_reordering_newest_first_after_message_add() {
1620 let mut state = ChatState::new();
1621 state.create_dm_chat("npub1old");
1622 state.create_dm_chat("npub1new");
1623
1624 let old_msg = make_message(1, "old", 1700000000000, false);
1626 state.add_message_to_chat("npub1old", old_msg);
1627
1628 let new_msg = make_message(2, "new", 1700000002000, false);
1630 state.add_message_to_chat("npub1new", new_msg);
1631
1632 assert_eq!(
1633 state.chats[0].id, "npub1new",
1634 "chat with newest message should be first"
1635 );
1636 assert_eq!(
1637 state.chats[1].id, "npub1old",
1638 "chat with older message should be second"
1639 );
1640 }
1641
1642 #[test]
1643 fn batch_add_does_not_reorder_for_old_messages() {
1644 let mut state = ChatState::new();
1645 state.create_dm_chat("npub1active");
1646 state.create_dm_chat("npub1history");
1647
1648 let recent = make_message(1, "recent", 1700000010000, false);
1650 state.add_message_to_chat("npub1active", recent);
1651
1652 let old_msgs: Vec<Message> = (10..15).map(|i| {
1654 make_message(i, &format!("old {}", i), 1700000000000 + i as u64 * 100, false)
1655 }).collect();
1656 state.add_messages_to_chat_batch("npub1history", old_msgs);
1657
1658 assert_eq!(
1659 state.chats[0].id, "npub1active",
1660 "active chat should remain first when batch has only old messages"
1661 );
1662 }
1663
1664 #[test]
1665 fn stress_test_50_messages_in_5_chats() {
1666 let mut state = ChatState::new();
1667
1668 for i in 0..5 {
1669 state.create_dm_chat(&format!("npub1chat{}", i));
1670 }
1671
1672 let mut total_added = 0;
1673 for i in 0..50u8 {
1674 let chat_idx = i as usize % 5;
1675 let chat_id = format!("npub1chat{}", chat_idx);
1676 let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, i % 3 == 0);
1677 if state.add_message_to_chat(&chat_id, msg) {
1678 total_added += 1;
1679 }
1680 }
1681
1682 assert_eq!(total_added, 50, "all 50 unique messages should be added");
1683
1684 let total_in_chats: usize = state.chats.iter().map(|c| c.message_count()).sum();
1685 assert_eq!(total_in_chats, 50, "total messages across all chats should be 50");
1686
1687 for i in 0..5 {
1689 let chat = state.get_chat(&format!("npub1chat{}", i)).unwrap();
1690 assert_eq!(
1691 chat.message_count(), 10,
1692 "chat {} should have 10 messages",
1693 i
1694 );
1695 }
1696
1697 for i in 0..50u8 {
1699 assert!(
1700 state.message_exists(&make_hex_id(i)),
1701 "message {} should exist",
1702 i
1703 );
1704 }
1705 }
1706
1707 #[test]
1708 fn add_message_auto_creates_dm_chat() {
1709 let mut state = ChatState::new();
1710
1711 let msg = make_message(1, "auto create", 1700000000000, false);
1713 let added = state.add_message_to_chat("npub1auto", msg);
1714
1715 assert!(added, "message should be added");
1716 assert!(state.get_chat("npub1auto").is_some(), "DM chat should be auto-created");
1717 }
1718
1719 #[test]
1720 fn add_message_auto_creates_community_chat() {
1721 let mut state = ChatState::new();
1722
1723 let msg = make_message(1, "group msg", 1700000000000, false);
1725 let added = state.add_message_to_chat("group_abc123", msg);
1726
1727 assert!(added, "message should be added");
1728 let chat = state.get_chat("group_abc123").expect("community chat should be auto-created");
1729 assert!(chat.is_community(), "auto-created non-npub chat should be a Community chat");
1730 }
1731
1732 #[test]
1737 fn count_unread_messages_basic() {
1738 let mut state = ChatState::new();
1739 state.create_dm_chat("npub1peer");
1740
1741 for i in 0..5u8 {
1742 let msg = make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false);
1743 state.add_message_to_chat("npub1peer", msg);
1744 }
1745
1746 assert_eq!(state.count_unread_messages(), 5, "all 5 non-mine messages should be unread");
1747 }
1748
1749 #[test]
1750 fn count_unread_muted_chat_skipped() {
1751 let mut state = ChatState::new();
1752 state.create_dm_chat("npub1muted");
1753
1754 let msg = make_message(1, "muted msg", 1700000000000, false);
1755 state.add_message_to_chat("npub1muted", msg);
1756
1757 state.get_chat_mut("npub1muted").unwrap().muted = true;
1758
1759 assert_eq!(state.count_unread_messages(), 0, "muted chat should not count toward unread");
1760 }
1761
1762 #[test]
1763 fn count_unread_blocked_user_skipped() {
1764 let mut state = ChatState::new();
1765
1766 let mut profile = Profile::new();
1767 profile.flags.set_blocked(true);
1768 state.insert_or_replace_profile("npub1blocked", profile);
1769 state.create_dm_chat("npub1blocked");
1770
1771 let msg = make_message(1, "blocked msg", 1700000000000, false);
1772 state.add_message_to_chat("npub1blocked", msg);
1773
1774 assert_eq!(state.count_unread_messages(), 0, "blocked user DM should not count");
1775 }
1776
1777 #[test]
1778 fn count_unread_own_messages_break_count() {
1779 let mut state = ChatState::new();
1780 state.create_dm_chat("npub1peer");
1781
1782 let msg1 = make_message(1, "them 1", 1700000001000, false);
1784 let msg2 = make_message(2, "them 2", 1700000002000, false);
1785 let msg3 = make_message(3, "them 3", 1700000003000, false);
1786 let msg_mine = make_message(4, "me", 1700000004000, true);
1787 let msg5 = make_message(5, "them 4", 1700000005000, false);
1788 let msg6 = make_message(6, "them 5", 1700000006000, false);
1789
1790 for m in [msg1, msg2, msg3, msg_mine, msg5, msg6] {
1791 state.add_message_to_chat("npub1peer", m);
1792 }
1793
1794 assert_eq!(
1796 state.count_unread_messages(), 2,
1797 "only messages after last 'mine' should count as unread"
1798 );
1799 }
1800
1801 #[test]
1802 fn count_unread_last_read_marker_breaks_count() {
1803 let mut state = ChatState::new();
1804 state.create_dm_chat("npub1peer");
1805
1806 let msg1 = make_message(1, "old", 1700000001000, false);
1807 let msg2 = make_message(2, "read up to here", 1700000002000, false);
1808 let msg3 = make_message(3, "new 1", 1700000003000, false);
1809 let msg4 = make_message(4, "new 2", 1700000004000, false);
1810 let read_marker_id = msg2.id.clone();
1811
1812 for m in [msg1, msg2, msg3, msg4] {
1813 state.add_message_to_chat("npub1peer", m);
1814 }
1815
1816 let chat = state.get_chat_mut("npub1peer").unwrap();
1818 chat.last_read = crate::simd::hex::hex_to_bytes_32(&read_marker_id);
1819
1820 assert_eq!(
1821 state.count_unread_messages(), 2,
1822 "only messages after last_read marker should count"
1823 );
1824 }
1825
1826 #[test]
1827 fn count_unread_empty_chats_is_zero() {
1828 let mut state = ChatState::new();
1829 state.create_dm_chat("npub1empty1");
1830 state.create_dm_chat("npub1empty2");
1831
1832 assert_eq!(state.count_unread_messages(), 0, "empty chats should have zero unread");
1833 }
1834
1835 #[test]
1836 fn count_unread_blocked_group_member_messages_skipped() {
1837 let mut state = ChatState::new();
1838
1839 let mut blocked_profile = Profile::new();
1841 blocked_profile.flags.set_blocked(true);
1842 state.insert_or_replace_profile("npub1blockedmember", blocked_profile);
1843
1844 let normal_profile = Profile::new();
1846 state.insert_or_replace_profile("npub1normal", normal_profile);
1847
1848 state.ensure_community_chat("grp1");
1851 if let Some(chat) = state.chats.iter_mut().find(|c| c.id == "grp1") {
1852 chat.metadata.custom_fields.insert("community_id".to_string(), "c".repeat(64));
1853 }
1854
1855 let msg_blocked = make_message_from(1, "blocked says hi", 1700000001000, "npub1blockedmember");
1857 state.add_message_to_chat("grp1", msg_blocked);
1858
1859 let msg_normal = make_message_from(2, "normal says hi", 1700000002000, "npub1normal");
1861 state.add_message_to_chat("grp1", msg_normal);
1862
1863 assert_eq!(
1864 state.count_unread_messages(), 1,
1865 "only the non-blocked member's message should count"
1866 );
1867 }
1868
1869 #[test]
1870 fn count_unread_multiple_chats_summed() {
1871 let mut state = ChatState::new();
1872
1873 for i in 0..3 {
1874 let npub = format!("npub1chat{}", i);
1875 state.create_dm_chat(&npub);
1876 for j in 0..3u8 {
1877 let msg = make_message(
1878 i * 10 + j,
1879 &format!("msg {}-{}", i, j),
1880 1700000000000 + j as u64 * 1000,
1881 false,
1882 );
1883 state.add_message_to_chat(&npub, msg);
1884 }
1885 }
1886
1887 assert_eq!(
1888 state.count_unread_messages(), 9,
1889 "3 chats x 3 unread each = 9 total"
1890 );
1891 }
1892
1893 #[test]
1898 fn update_typing_and_get_active_basic() {
1899 let mut state = ChatState::new();
1900 state.create_dm_chat("npub1peer");
1901
1902 let far_future = std::time::SystemTime::now()
1904 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1905
1906 let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
1907 assert_eq!(active.len(), 1, "should have one active typer");
1908 assert_eq!(active[0], "npub1typer", "typer npub should match");
1909 }
1910
1911 #[test]
1912 fn update_typing_expired_typers_filtered() {
1913 let mut state = ChatState::new();
1914 state.create_dm_chat("npub1peer");
1915
1916 let expired = 1000;
1918 let active = state.update_typing_and_get_active("npub1peer", "npub1expired", expired);
1919
1920 assert!(active.is_empty(), "expired typer should be filtered out");
1921 }
1922
1923 #[test]
1924 fn update_typing_multiple_typers() {
1925 let mut state = ChatState::new();
1926 state.create_dm_chat("npub1peer");
1927
1928 let far_future = std::time::SystemTime::now()
1929 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1930
1931 state.update_typing_and_get_active("npub1peer", "npub1typer1", far_future);
1932 let active = state.update_typing_and_get_active("npub1peer", "npub1typer2", far_future);
1933
1934 assert_eq!(active.len(), 2, "should have two active typers");
1935 }
1936
1937 #[test]
1938 fn update_typing_unknown_chat_returns_empty() {
1939 let mut state = ChatState::new();
1940 let far_future = std::time::SystemTime::now()
1941 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1942
1943 let active = state.update_typing_and_get_active("npub1nonexistent", "npub1typer", far_future);
1944 assert!(active.is_empty(), "unknown chat should return empty typers");
1945 }
1946
1947 #[test]
1948 fn update_typing_refreshes_existing_typer() {
1949 let mut state = ChatState::new();
1950 state.create_dm_chat("npub1peer");
1951
1952 let far_future = std::time::SystemTime::now()
1953 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300;
1954
1955 state.update_typing_and_get_active("npub1peer", "npub1typer", far_future);
1956 let active = state.update_typing_and_get_active("npub1peer", "npub1typer", far_future + 100);
1958
1959 assert_eq!(active.len(), 1, "should still have only one typer entry after refresh");
1960 }
1961
1962 #[test]
1967 fn wrapper_id_cache_historical_and_pending() {
1968 let mut cache = WrapperIdCache::new();
1969
1970 let id1 = [1u8; 32];
1971 let id2 = [2u8; 32];
1972 let id3 = [3u8; 32];
1973
1974 cache.load(vec![id1, id2]);
1975 cache.insert(id3);
1976
1977 assert!(cache.contains(&id1), "historical id should be found");
1978 assert!(cache.contains(&id2), "historical id should be found");
1979 assert!(cache.contains(&id3), "pending id should be found");
1980 assert!(!cache.contains(&[4u8; 32]), "unknown id should not be found");
1981 assert_eq!(cache.len(), 3, "total count should be 3");
1982 }
1983
1984 #[test]
1985 fn wrapper_id_cache_clear() {
1986 let mut cache = WrapperIdCache::new();
1987 cache.load(vec![[1u8; 32]]);
1988 cache.insert([2u8; 32]);
1989
1990 cache.clear();
1991
1992 assert_eq!(cache.len(), 0, "cache should be empty after clear");
1993 assert!(!cache.contains(&[1u8; 32]), "cleared historical should not be found");
1994 assert!(!cache.contains(&[2u8; 32]), "cleared pending should not be found");
1995 }
1996}