1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::compact::{CompactMessage, CompactMessageVec, NpubInterner, encode_message_id, decode_message_id};
10use crate::types::Message;
11
12#[derive(Clone, Debug)]
17pub struct Chat {
18 pub id: String,
19 pub chat_type: ChatType,
20 pub participants: Vec<u16>,
21 pub messages: CompactMessageVec,
22 pub last_read: [u8; 32],
23 pub created_at: u64,
24 pub metadata: ChatMetadata,
25 pub muted: bool,
26 pub typing_participants: Vec<(u16, u64)>,
27 pub wallpaper_path: String,
32 pub wallpaper_ts: u64,
35 pub wallpaper_blur: u8,
38 pub wallpaper_dim: u8,
42 pub wallpaper_url: String,
46 pub wallpaper_uploader: String,
50}
51
52impl Chat {
53 pub fn new(id: String, chat_type: ChatType, participants: Vec<u16>) -> Self {
54 Self {
55 id,
56 chat_type,
57 participants,
58 messages: CompactMessageVec::new(),
59 last_read: [0u8; 32],
60 created_at: std::time::SystemTime::now()
61 .duration_since(std::time::UNIX_EPOCH)
62 .unwrap()
63 .as_secs(),
64 metadata: ChatMetadata::new(),
65 muted: false,
66 typing_participants: Vec::new(),
67 wallpaper_path: String::new(),
68 wallpaper_ts: 0,
69 wallpaper_blur: 0,
70 wallpaper_dim: 50,
71 wallpaper_url: String::new(),
72 wallpaper_uploader: String::new(),
73 }
74 }
75
76 pub fn new_dm(their_npub: String, interner: &mut NpubInterner) -> Self {
77 let handle = interner.intern(&their_npub);
78 Self::new(their_npub, ChatType::DirectMessage, vec![handle])
79 }
80
81
82 pub fn new_community_channel(channel_id: String, participants: Vec<String>, interner: &mut NpubInterner) -> Self {
85 let handles: Vec<u16> = participants.iter().map(|p| interner.intern(p)).collect();
86 Self::new(channel_id, ChatType::Community, handles)
87 }
88
89 #[inline]
94 pub fn message_count(&self) -> usize { self.messages.len() }
95
96 #[inline]
97 pub fn is_empty(&self) -> bool { self.messages.is_empty() }
98
99 #[inline]
100 pub fn last_message_time(&self) -> Option<u64> { self.messages.last_timestamp() }
101
102 #[inline]
103 pub fn has_message(&self, id: &str) -> bool { self.messages.contains_hex_id(id) }
104
105 #[inline]
106 pub fn get_compact_message(&self, id: &str) -> Option<&CompactMessage> {
107 self.messages.find_by_hex_id(id)
108 }
109
110 #[inline]
111 pub fn get_compact_message_mut(&mut self, id: &str) -> Option<&mut CompactMessage> {
112 self.messages.find_by_hex_id_mut(id)
113 }
114
115 pub fn get_message(&self, id: &str, interner: &NpubInterner) -> Option<Message> {
116 self.messages.find_by_hex_id(id).map(|cm| cm.to_message(interner))
117 }
118
119 #[inline]
120 pub fn iter_compact(&self) -> std::slice::Iter<'_, CompactMessage> {
121 self.messages.iter()
122 }
123
124 pub fn get_all_messages(&self, interner: &NpubInterner) -> Vec<Message> {
125 self.messages.iter().map(|cm| cm.to_message(interner)).collect()
126 }
127
128 pub fn get_last_messages(&self, n: usize, interner: &NpubInterner) -> Vec<Message> {
129 let len = self.messages.len();
130 let start = len.saturating_sub(n);
131 self.messages.messages()[start..].iter().map(|cm| cm.to_message(interner)).collect()
132 }
133
134 pub fn add_message(&mut self, message: Message, interner: &mut NpubInterner) -> bool {
139 let compact = CompactMessage::from_message(&message, interner);
140 self.messages.insert(compact)
141 }
142
143 #[inline]
144 pub fn add_compact_message(&mut self, message: CompactMessage) -> bool {
145 self.messages.insert(message)
146 }
147
148 pub fn set_as_read(&mut self) -> bool {
149 for msg in self.messages.iter().rev() {
150 if !msg.flags.is_mine() {
151 self.last_read = msg.id;
152 return true;
153 }
154 }
155 false
156 }
157
158 pub fn internal_add_message(&mut self, message: Message, interner: &mut NpubInterner) -> bool {
159 self.add_message(message, interner)
160 }
161
162 #[inline]
163 pub fn get_message_mut(&mut self, id: &str) -> Option<&mut CompactMessage> {
164 self.get_compact_message_mut(id)
165 }
166
167 fn resolve_participants(&self, interner: &NpubInterner) -> Vec<String> {
172 self.participants.iter()
173 .filter_map(|&h| interner.resolve(h).map(|s| s.to_string()))
174 .collect()
175 }
176
177 pub fn to_serializable(&self, interner: &NpubInterner) -> SerializableChat {
178 SerializableChat {
179 id: self.id.clone(),
180 chat_type: self.chat_type.clone(),
181 participants: self.resolve_participants(interner),
182 messages: self.get_all_messages(interner),
183 last_read: if self.last_read == [0u8; 32] { String::new() } else { decode_message_id(&self.last_read) },
184 created_at: self.created_at,
185 metadata: self.metadata.clone(),
186 muted: self.muted,
187 wallpaper_path: self.wallpaper_path.clone(),
188 wallpaper_ts: self.wallpaper_ts,
189 wallpaper_blur: self.wallpaper_blur,
190 wallpaper_dim: self.wallpaper_dim,
191 wallpaper_url: self.wallpaper_url.clone(),
192 wallpaper_uploader: self.wallpaper_uploader.clone(),
193 }
194 }
195
196 pub fn to_serializable_with_last_n(&self, n: usize, interner: &NpubInterner) -> SerializableChat {
197 SerializableChat {
198 id: self.id.clone(),
199 chat_type: self.chat_type.clone(),
200 participants: self.resolve_participants(interner),
201 messages: self.get_last_messages(n, interner),
202 last_read: if self.last_read == [0u8; 32] { String::new() } else { decode_message_id(&self.last_read) },
203 created_at: self.created_at,
204 metadata: self.metadata.clone(),
205 muted: self.muted,
206 wallpaper_path: self.wallpaper_path.clone(),
207 wallpaper_ts: self.wallpaper_ts,
208 wallpaper_blur: self.wallpaper_blur,
209 wallpaper_dim: self.wallpaper_dim,
210 wallpaper_url: self.wallpaper_url.clone(),
211 wallpaper_uploader: self.wallpaper_uploader.clone(),
212 }
213 }
214
215 pub fn get_other_participant(&self, my_npub: &str, interner: &NpubInterner) -> Option<String> {
220 match self.chat_type {
221 ChatType::DirectMessage => {
222 let my_handle = interner.lookup(my_npub);
223 self.participants.iter()
224 .find(|&&h| Some(h) != my_handle)
225 .and_then(|&h| interner.resolve(h).map(|s| s.to_string()))
226 }
227 ChatType::Community => None,
229 }
230 }
231
232 pub fn is_dm_with(&self, npub: &str, interner: &NpubInterner) -> bool {
233 matches!(self.chat_type, ChatType::DirectMessage)
234 && interner.lookup(npub).map_or(false, |h| self.participants.contains(&h))
235 }
236
237 pub fn is_community(&self) -> bool { matches!(self.chat_type, ChatType::Community) }
238
239 pub fn is_surfaced_community_channel(&self) -> bool {
249 let cf = &self.metadata.custom_fields;
250 if !cf.contains_key("community_id") {
251 return false; }
253 match cf.get("primary_channel") {
254 Some(primary) => *primary == self.id,
255 None => true,
256 }
257 }
258
259 pub fn has_participant(&self, npub: &str, interner: &NpubInterner) -> bool {
260 interner.lookup(npub).map_or(false, |h| self.participants.contains(&h))
261 }
262
263 pub fn get_active_typers(&self, interner: &NpubInterner) -> Vec<String> {
264 let now = std::time::SystemTime::now()
265 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
266 self.typing_participants.iter()
267 .filter(|(_, exp)| *exp > now)
268 .filter_map(|(h, _)| interner.resolve(*h).map(|s| s.to_string()))
269 .collect()
270 }
271
272 pub fn update_typing_participant(&mut self, handle: u16, expires_at: u64) {
273 if let Some(entry) = self.typing_participants.iter_mut().find(|(h, _)| *h == handle) {
274 entry.1 = expires_at;
275 } else {
276 self.typing_participants.push((handle, expires_at));
277 }
278 let now = std::time::SystemTime::now()
279 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
280 self.typing_participants.retain(|(_, exp)| *exp > now);
281 }
282
283 pub fn id(&self) -> &String { &self.id }
284 pub fn chat_type(&self) -> &ChatType { &self.chat_type }
285 pub fn participants(&self) -> &[u16] { &self.participants }
286 pub fn last_read(&self) -> &[u8; 32] { &self.last_read }
287 pub fn created_at(&self) -> u64 { self.created_at }
288 pub fn metadata(&self) -> &ChatMetadata { &self.metadata }
289 pub fn muted(&self) -> bool { self.muted }
290}
291
292#[derive(Serialize, Deserialize, Clone, Debug)]
297pub struct SerializableChat {
298 pub id: String,
299 pub chat_type: ChatType,
300 pub participants: Vec<String>,
301 pub messages: Vec<Message>,
302 pub last_read: String,
303 pub created_at: u64,
304 pub metadata: ChatMetadata,
305 pub muted: bool,
306 #[serde(default)]
307 pub wallpaper_path: String,
308 #[serde(default)]
309 pub wallpaper_ts: u64,
310 #[serde(default)]
311 pub wallpaper_blur: u8,
312 #[serde(default = "default_wallpaper_dim")]
313 pub wallpaper_dim: u8,
314 #[serde(default)]
315 pub wallpaper_url: String,
316 #[serde(default)]
317 pub wallpaper_uploader: String,
318}
319
320fn default_wallpaper_dim() -> u8 { 50 }
321
322impl SerializableChat {
323 pub fn to_chat(self, interner: &mut NpubInterner) -> Chat {
324 let handles: Vec<u16> = self.participants.iter().map(|p| interner.intern(p)).collect();
325 let mut chat = Chat::new(self.id, self.chat_type, handles);
326 chat.last_read = if self.last_read.is_empty() { [0u8; 32] } else { encode_message_id(&self.last_read) };
327 chat.created_at = self.created_at;
328 chat.metadata = self.metadata;
329 chat.muted = self.muted;
330 chat.wallpaper_path = self.wallpaper_path;
331 chat.wallpaper_ts = self.wallpaper_ts;
332 chat.wallpaper_blur = self.wallpaper_blur;
333 chat.wallpaper_dim = self.wallpaper_dim;
334 chat.wallpaper_url = self.wallpaper_url;
335 chat.wallpaper_uploader = self.wallpaper_uploader;
336 for msg in self.messages {
337 chat.add_message(msg, interner);
338 }
339 chat
340 }
341}
342
343#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
348pub enum ChatType {
349 DirectMessage,
350 Community,
353}
354
355impl ChatType {
356 pub fn to_i32(&self) -> i32 {
359 match self {
360 ChatType::DirectMessage => 0,
361 ChatType::Community => 2,
362 }
363 }
364 pub fn from_i32(value: i32) -> Self {
365 match value {
366 2 => ChatType::Community,
367 _ => ChatType::DirectMessage,
368 }
369 }
370}
371
372#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
373pub struct ChatMetadata {
374 pub custom_fields: HashMap<String, String>,
375}
376
377impl ChatMetadata {
378 pub fn new() -> Self { Self { custom_fields: HashMap::new() } }
379
380 pub fn set_name(&mut self, name: String) { self.custom_fields.insert("name".to_string(), name); }
381 pub fn get_name(&self) -> Option<&str> { self.custom_fields.get("name").map(|s| s.as_str()) }
382 pub fn set_member_count(&mut self, count: usize) { self.custom_fields.insert("member_count".to_string(), count.to_string()); }
383 pub fn get_member_count(&self) -> Option<usize> { self.custom_fields.get("member_count").and_then(|s| s.parse().ok()) }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::types::Message;
390 use crate::compact::NpubInterner;
391 use crate::simd::hex::bytes_to_hex_32;
392
393 #[test]
397 fn only_the_primary_channel_of_a_community_is_surfaced() {
398 let mut interner = NpubInterner::new();
399 let primary_id = make_hex_id(0x21);
400 let sibling_id = make_hex_id(0x22);
401
402 let mut stamp = |id: &str, community: bool, primary: Option<&str>| {
403 let mut chat = Chat::new_community_channel(id.to_string(), Vec::new(), &mut interner);
404 if community {
405 chat.metadata.custom_fields.insert("community_id".into(), make_hex_id(0x99));
406 }
407 if let Some(p) = primary {
408 chat.metadata.custom_fields.insert("primary_channel".into(), p.to_string());
409 }
410 chat
411 };
412
413 assert!(!stamp(&sibling_id, false, None).is_surfaced_community_channel());
416
417 assert!(stamp(&primary_id, true, Some(&primary_id)).is_surfaced_community_channel());
419
420 assert!(!stamp(&sibling_id, true, Some(&primary_id)).is_surfaced_community_channel());
423
424 assert!(stamp(&primary_id, true, None).is_surfaced_community_channel());
428 }
429
430 fn make_hex_id(seed: u8) -> String {
437 let mut bytes = [seed; 32];
438 bytes[0] = seed.wrapping_add(0x10) | 0x10; bytes[1] = seed.wrapping_mul(37);
440 bytes_to_hex_32(&bytes)
441 }
442
443 fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
445 Message {
446 id: make_hex_id(id_seed),
447 content: content.to_string(),
448 at: timestamp_ms,
449 mine,
450 ..Default::default()
451 }
452 }
453
454 #[test]
459 fn new_dm_creates_correct_type() {
460 let mut interner = NpubInterner::new();
461 let chat = Chat::new_dm("npub1alice".to_string(), &mut interner);
462
463 assert_eq!(chat.id, "npub1alice", "DM chat id should be the peer's npub");
464 assert_eq!(chat.chat_type, ChatType::DirectMessage, "should be DirectMessage type");
465 assert_eq!(chat.participants.len(), 1, "DM should have one participant");
466 assert!(chat.is_empty(), "new chat should have no messages");
467 assert!(!chat.muted, "new chat should not be muted");
468 assert_eq!(chat.last_read, [0u8; 32], "last_read should be zeroed");
469 }
470
471 #[test]
472 fn new_community_channel_with_participants() {
473 let mut interner = NpubInterner::new();
474 let participants = vec![
475 "npub1alice".to_string(),
476 "npub1bob".to_string(),
477 "npub1charlie".to_string(),
478 ];
479 let chat = Chat::new_community_channel("grp_abc".to_string(), participants, &mut interner);
480
481 assert_eq!(chat.id, "grp_abc", "group chat id should match");
482 assert_eq!(chat.chat_type, ChatType::Community, "should be Community type");
483 assert_eq!(chat.participants.len(), 3, "should have 3 participants");
484 assert!(chat.is_community(), "is_community() should return true");
485 }
486
487 #[test]
488 fn new_chat_has_creation_timestamp() {
489 let mut interner = NpubInterner::new();
490 let chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
491
492 let now = std::time::SystemTime::now()
494 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
495 assert!(
496 chat.created_at >= now - 5 && chat.created_at <= now + 1,
497 "created_at ({}) should be close to now ({})",
498 chat.created_at, now
499 );
500 }
501
502 #[test]
507 fn add_message_and_get_message_roundtrip() {
508 let mut interner = NpubInterner::new();
509 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
510
511 let msg = make_message(1, "hello world", 1700000000000, false);
512 let msg_id = msg.id.clone();
513
514 let added = chat.add_message(msg, &mut interner);
515 assert!(added, "message should be added successfully");
516
517 let retrieved = chat.get_message(&msg_id, &interner)
518 .expect("message should be retrievable");
519 assert_eq!(retrieved.content, "hello world", "content should roundtrip");
520 assert_eq!(retrieved.id, msg_id, "id should roundtrip");
521 }
522
523 #[test]
524 fn add_message_dedup() {
525 let mut interner = NpubInterner::new();
526 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
527
528 let msg1 = make_message(1, "first", 1700000000000, false);
529 let msg2 = make_message(1, "duplicate id", 1700000001000, false);
530
531 assert!(chat.add_message(msg1, &mut interner), "first add should succeed");
532 assert!(!chat.add_message(msg2, &mut interner), "duplicate ID should be rejected");
533 assert_eq!(chat.message_count(), 1, "should have only one message");
534 }
535
536 #[test]
537 fn message_count_and_is_empty() {
538 let mut interner = NpubInterner::new();
539 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
540
541 assert!(chat.is_empty(), "new chat should be empty");
542 assert_eq!(chat.message_count(), 0, "new chat should have 0 messages");
543
544 chat.add_message(make_message(1, "a", 1700000000000, false), &mut interner);
545 assert!(!chat.is_empty(), "chat with message should not be empty");
546 assert_eq!(chat.message_count(), 1, "should have 1 message");
547
548 chat.add_message(make_message(2, "b", 1700000001000, false), &mut interner);
549 assert_eq!(chat.message_count(), 2, "should have 2 messages");
550 }
551
552 #[test]
553 fn has_message_check() {
554 let mut interner = NpubInterner::new();
555 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
556
557 let msg = make_message(1, "test", 1700000000000, false);
558 let msg_id = msg.id.clone();
559 chat.add_message(msg, &mut interner);
560
561 assert!(chat.has_message(&msg_id), "added message should be found");
562 assert!(!chat.has_message(&make_hex_id(99)), "unknown id should not be found");
563 }
564
565 #[test]
566 fn get_all_messages_returns_all() {
567 let mut interner = NpubInterner::new();
568 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
569
570 for i in 0..5u8 {
571 chat.add_message(
572 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
573 &mut interner,
574 );
575 }
576
577 let all = chat.get_all_messages(&interner);
578 assert_eq!(all.len(), 5, "should return all 5 messages");
579 }
580
581 #[test]
582 fn get_last_messages_returns_tail() {
583 let mut interner = NpubInterner::new();
584 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
585
586 for i in 0..10u8 {
587 chat.add_message(
588 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
589 &mut interner,
590 );
591 }
592
593 let last3 = chat.get_last_messages(3, &interner);
594 assert_eq!(last3.len(), 3, "should return exactly 3 messages");
595 assert_eq!(last3[0].content, "msg 7", "first of last 3 should be msg 7");
596 assert_eq!(last3[2].content, "msg 9", "last should be msg 9");
597 }
598
599 #[test]
600 fn last_message_time_tracks_newest() {
601 let mut interner = NpubInterner::new();
602 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
603
604 assert!(chat.last_message_time().is_none(), "empty chat should have no last time");
605
606 chat.add_message(make_message(1, "a", 1700000001000, false), &mut interner);
607 let t1 = chat.last_message_time().expect("should have a timestamp");
608
609 chat.add_message(make_message(2, "b", 1700000005000, false), &mut interner);
610 let t2 = chat.last_message_time().expect("should have a timestamp");
611
612 assert!(t2 > t1, "last_message_time should increase with newer messages");
613 }
614
615 #[test]
620 fn set_as_read_marks_last_non_mine() {
621 let mut interner = NpubInterner::new();
622 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
623
624 let msg_theirs = make_message(1, "from them", 1700000001000, false);
625 let msg_mine = make_message(2, "from me", 1700000002000, true);
626 let msg_theirs2 = make_message(3, "from them again", 1700000003000, false);
627 let last_their_id = msg_theirs2.id.clone();
628
629 chat.add_message(msg_theirs, &mut interner);
630 chat.add_message(msg_mine, &mut interner);
631 chat.add_message(msg_theirs2, &mut interner);
632
633 let marked = chat.set_as_read();
634 assert!(marked, "set_as_read should succeed when there are non-mine messages");
635
636 let expected_bytes = crate::compact::encode_message_id(&last_their_id);
637 assert_eq!(
638 chat.last_read, expected_bytes,
639 "last_read should point to the last non-mine message"
640 );
641 }
642
643 #[test]
644 fn set_as_read_all_mine_returns_false() {
645 let mut interner = NpubInterner::new();
646 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
647
648 chat.add_message(make_message(1, "mine 1", 1700000001000, true), &mut interner);
649 chat.add_message(make_message(2, "mine 2", 1700000002000, true), &mut interner);
650
651 let marked = chat.set_as_read();
652 assert!(!marked, "set_as_read should return false when all messages are mine");
653 assert_eq!(chat.last_read, [0u8; 32], "last_read should remain zeroed");
654 }
655
656 #[test]
661 fn to_serializable_and_back_via_to_chat() {
662 let mut interner = NpubInterner::new();
663 let participants = vec!["npub1alice".to_string(), "npub1bob".to_string()];
664 let mut chat = Chat::new_community_channel("grp_test".to_string(), participants.clone(), &mut interner);
665
666 chat.metadata.set_name("Test Group".to_string());
667 chat.muted = true;
668
669 chat.add_message(make_message(1, "hello", 1700000001000, false), &mut interner);
671 chat.add_message(make_message(2, "world", 1700000002000, true), &mut interner);
672
673 chat.set_as_read();
675
676 let serializable = chat.to_serializable(&interner);
678 assert_eq!(serializable.id, "grp_test", "serialized id should match");
679 assert_eq!(serializable.chat_type, ChatType::Community, "serialized type should match");
680 assert_eq!(serializable.participants.len(), 2, "should have 2 participants");
681 assert_eq!(serializable.messages.len(), 2, "should have 2 messages");
682 assert!(serializable.muted, "muted should be preserved");
683 assert_eq!(
684 serializable.metadata.get_name(),
685 Some("Test Group"),
686 "metadata name should be preserved"
687 );
688
689 let mut interner2 = NpubInterner::new();
691 let restored = serializable.to_chat(&mut interner2);
692
693 assert_eq!(restored.id, "grp_test", "restored id should match");
694 assert_eq!(restored.chat_type, ChatType::Community, "restored type should match");
695 assert_eq!(restored.participants.len(), 2, "restored participants count should match");
696 assert_eq!(restored.message_count(), 2, "restored message count should match");
697 assert!(restored.muted, "restored muted should be true");
698 assert_ne!(restored.last_read, [0u8; 32], "restored last_read should be non-zero");
699 }
700
701 #[test]
702 fn to_serializable_with_last_n() {
703 let mut interner = NpubInterner::new();
704 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
705
706 for i in 0..10u8 {
707 chat.add_message(
708 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
709 &mut interner,
710 );
711 }
712
713 let serialized = chat.to_serializable_with_last_n(3, &interner);
714 assert_eq!(serialized.messages.len(), 3, "should only include last 3 messages");
715 }
716
717 #[test]
722 fn has_participant_check() {
723 let mut interner = NpubInterner::new();
724 let chat = Chat::new_community_channel(
725 "grp1".to_string(),
726 vec!["npub1alice".to_string(), "npub1bob".to_string()],
727 &mut interner,
728 );
729
730 assert!(
731 chat.has_participant("npub1alice", &interner),
732 "alice should be a participant"
733 );
734 assert!(
735 chat.has_participant("npub1bob", &interner),
736 "bob should be a participant"
737 );
738 assert!(
739 !chat.has_participant("npub1charlie", &interner),
740 "charlie should not be a participant"
741 );
742 }
743
744 #[test]
745 fn is_dm_with_check() {
746 let mut interner = NpubInterner::new();
747 let chat = Chat::new_dm("npub1alice".to_string(), &mut interner);
748
749 assert!(
750 chat.is_dm_with("npub1alice", &interner),
751 "should be a DM with alice"
752 );
753 assert!(
754 !chat.is_dm_with("npub1bob", &interner),
755 "should not be a DM with bob"
756 );
757 }
758
759 #[test]
760 fn is_dm_with_returns_false_for_group() {
761 let mut interner = NpubInterner::new();
762 let chat = Chat::new_community_channel(
763 "grp1".to_string(),
764 vec!["npub1alice".to_string()],
765 &mut interner,
766 );
767
768 assert!(
769 !chat.is_dm_with("npub1alice", &interner),
770 "community channel should not match is_dm_with even if participant matches"
771 );
772 }
773
774 #[test]
775 fn get_other_participant_dm() {
776 let mut interner = NpubInterner::new();
777 let chat = Chat::new_dm("npub1bob".to_string(), &mut interner);
779
780 let other = chat.get_other_participant("npub1alice", &interner);
781 assert_eq!(
782 other, Some("npub1bob".to_string()),
783 "should return bob as the other participant"
784 );
785 }
786
787 #[test]
788 fn get_other_participant_returns_none_for_group() {
789 let mut interner = NpubInterner::new();
790 let chat = Chat::new_community_channel(
791 "grp1".to_string(),
792 vec!["npub1alice".to_string(), "npub1bob".to_string()],
793 &mut interner,
794 );
795
796 assert!(
797 chat.get_other_participant("npub1alice", &interner).is_none(),
798 "community channel should return None for get_other_participant"
799 );
800 }
801
802 #[test]
807 fn typing_participants_with_expiry() {
808 let mut interner = NpubInterner::new();
809 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
810
811 let now = std::time::SystemTime::now()
812 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
813
814 let active_handle = interner.intern("npub1active");
816 let expired_handle = interner.intern("npub1expired");
817
818 chat.update_typing_participant(active_handle, now + 300);
819 chat.update_typing_participant(expired_handle, now - 10);
820
821 let active = chat.get_active_typers(&interner);
822 assert_eq!(active.len(), 1, "only the active typer should be returned");
823 assert_eq!(active[0], "npub1active", "active typer should be npub1active");
824 }
825
826 #[test]
827 fn update_typing_participant_refreshes() {
828 let mut interner = NpubInterner::new();
829 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
830
831 let now = std::time::SystemTime::now()
832 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
833
834 let handle = interner.intern("npub1typer");
835 chat.update_typing_participant(handle, now + 100);
836 chat.update_typing_participant(handle, now + 500);
837
838 let active = chat.get_active_typers(&interner);
840 assert_eq!(active.len(), 1, "refreshed typer should not duplicate");
841 }
842
843 #[test]
844 fn typing_participants_empty_initially() {
845 let interner = NpubInterner::new();
846 let chat = Chat::new("testchat".to_string(), ChatType::DirectMessage, vec![]);
847
848 let active = chat.get_active_typers(&interner);
849 assert!(active.is_empty(), "new chat should have no typers");
850 }
851
852 #[test]
857 fn chat_type_i32_roundtrip() {
858 assert_eq!(ChatType::from_i32(ChatType::DirectMessage.to_i32()), ChatType::DirectMessage);
859 assert_eq!(ChatType::from_i32(ChatType::Community.to_i32()), ChatType::Community);
860 assert_eq!(
861 ChatType::from_i32(999), ChatType::DirectMessage,
862 "unknown i32 should default to DirectMessage"
863 );
864 }
865
866 #[test]
871 fn chat_metadata_name_and_member_count() {
872 let mut meta = ChatMetadata::new();
873
874 assert!(meta.get_name().is_none(), "new metadata should have no name");
875 assert!(meta.get_member_count().is_none(), "new metadata should have no member count");
876
877 meta.set_name("My Group".to_string());
878 meta.set_member_count(42);
879
880 assert_eq!(meta.get_name(), Some("My Group"), "name should be set");
881 assert_eq!(meta.get_member_count(), Some(42), "member count should be set");
882 }
883
884 #[test]
889 fn accessor_methods_work() {
890 let mut interner = NpubInterner::new();
891 let mut chat = Chat::new_dm("npub1test".to_string(), &mut interner);
892 chat.muted = true;
893
894 assert_eq!(chat.id(), "npub1test");
895 assert_eq!(*chat.chat_type(), ChatType::DirectMessage);
896 assert_eq!(chat.participants().len(), 1);
897 assert_eq!(*chat.last_read(), [0u8; 32]);
898 assert!(chat.created_at() > 0);
899 assert!(chat.muted());
900 assert_eq!(*chat.metadata(), ChatMetadata::new());
901 }
902}