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: web_time::SystemTime::now()
61 .duration_since(web_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 let resolved = crate::notify::resolve_chat(self);
179 SerializableChat {
180 id: self.id.clone(),
181 chat_type: self.chat_type.clone(),
182 participants: self.resolve_participants(interner),
183 messages: self.get_all_messages(interner),
184 last_read: if self.last_read == [0u8; 32] { String::new() } else { decode_message_id(&self.last_read) },
185 created_at: self.created_at,
186 metadata: self.metadata.clone(),
187 muted: resolved.muted,
188 notify: resolved.ring.as_u8(),
189 everyone_pings: crate::notify::everyone_pings_for_chat(self),
190 community_muted: crate::notify::community_muted_for_chat(self),
191 wallpaper_path: self.wallpaper_path.clone(),
192 wallpaper_ts: self.wallpaper_ts,
193 wallpaper_blur: self.wallpaper_blur,
194 wallpaper_dim: self.wallpaper_dim,
195 wallpaper_url: self.wallpaper_url.clone(),
196 wallpaper_uploader: self.wallpaper_uploader.clone(),
197 }
198 }
199
200 pub fn to_serializable_with_last_n(&self, n: usize, interner: &NpubInterner) -> SerializableChat {
201 let resolved = crate::notify::resolve_chat(self);
202 SerializableChat {
203 id: self.id.clone(),
204 chat_type: self.chat_type.clone(),
205 participants: self.resolve_participants(interner),
206 messages: self.get_last_messages(n, interner),
207 last_read: if self.last_read == [0u8; 32] { String::new() } else { decode_message_id(&self.last_read) },
208 created_at: self.created_at,
209 metadata: self.metadata.clone(),
210 muted: resolved.muted,
211 notify: resolved.ring.as_u8(),
212 everyone_pings: crate::notify::everyone_pings_for_chat(self),
213 community_muted: crate::notify::community_muted_for_chat(self),
214 wallpaper_path: self.wallpaper_path.clone(),
215 wallpaper_ts: self.wallpaper_ts,
216 wallpaper_blur: self.wallpaper_blur,
217 wallpaper_dim: self.wallpaper_dim,
218 wallpaper_url: self.wallpaper_url.clone(),
219 wallpaper_uploader: self.wallpaper_uploader.clone(),
220 }
221 }
222
223 pub fn get_other_participant(&self, my_npub: &str, interner: &NpubInterner) -> Option<String> {
228 match self.chat_type {
229 ChatType::DirectMessage => {
230 let my_handle = interner.lookup(my_npub);
231 self.participants.iter()
232 .find(|&&h| Some(h) != my_handle)
233 .and_then(|&h| interner.resolve(h).map(|s| s.to_string()))
234 }
235 ChatType::Community => None,
237 }
238 }
239
240 pub fn is_dm_with(&self, npub: &str, interner: &NpubInterner) -> bool {
241 matches!(self.chat_type, ChatType::DirectMessage)
242 && interner.lookup(npub).is_some_and(|h| self.participants.contains(&h))
243 }
244
245 pub fn is_community(&self) -> bool { matches!(self.chat_type, ChatType::Community) }
246
247 pub fn is_surfaced_community_channel(&self) -> bool {
257 let cf = &self.metadata.custom_fields;
258 if !cf.contains_key("community_id") {
259 return false; }
261 match cf.get("primary_channel") {
262 Some(primary) => *primary == self.id,
263 None => true,
264 }
265 }
266
267 pub fn has_participant(&self, npub: &str, interner: &NpubInterner) -> bool {
268 interner.lookup(npub).is_some_and(|h| self.participants.contains(&h))
269 }
270
271 pub fn get_active_typers(&self, interner: &NpubInterner) -> Vec<String> {
272 let now = web_time::SystemTime::now()
273 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
274 self.typing_participants.iter()
275 .filter(|(_, exp)| *exp > now)
276 .filter_map(|(h, _)| interner.resolve(*h).map(|s| s.to_string()))
277 .collect()
278 }
279
280 pub fn update_typing_participant(&mut self, handle: u16, expires_at: u64) {
281 if let Some(entry) = self.typing_participants.iter_mut().find(|(h, _)| *h == handle) {
282 entry.1 = expires_at;
283 } else {
284 self.typing_participants.push((handle, expires_at));
285 }
286 let now = web_time::SystemTime::now()
287 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
288 self.typing_participants.retain(|(_, exp)| *exp > now);
289 }
290
291 pub fn id(&self) -> &String { &self.id }
292 pub fn chat_type(&self) -> &ChatType { &self.chat_type }
293 pub fn participants(&self) -> &[u16] { &self.participants }
294 pub fn last_read(&self) -> &[u8; 32] { &self.last_read }
295 pub fn created_at(&self) -> u64 { self.created_at }
296 pub fn metadata(&self) -> &ChatMetadata { &self.metadata }
297 pub fn muted(&self) -> bool { self.muted }
298}
299
300#[derive(Serialize, Deserialize, Clone, Debug)]
305pub struct SerializableChat {
306 pub id: String,
307 pub chat_type: ChatType,
308 pub participants: Vec<String>,
309 pub messages: Vec<Message>,
310 pub last_read: String,
311 pub created_at: u64,
312 pub metadata: ChatMetadata,
313 pub muted: bool,
316 #[serde(default)]
319 pub notify: u8,
320 #[serde(default = "default_everyone_pings")]
323 pub everyone_pings: bool,
324 #[serde(default)]
327 pub community_muted: bool,
328 #[serde(default)]
329 pub wallpaper_path: String,
330 #[serde(default)]
331 pub wallpaper_ts: u64,
332 #[serde(default)]
333 pub wallpaper_blur: u8,
334 #[serde(default = "default_wallpaper_dim")]
335 pub wallpaper_dim: u8,
336 #[serde(default)]
337 pub wallpaper_url: String,
338 #[serde(default)]
339 pub wallpaper_uploader: String,
340}
341
342fn default_wallpaper_dim() -> u8 { 50 }
343fn default_everyone_pings() -> bool { true }
344
345impl SerializableChat {
346 pub fn to_chat(self, interner: &mut NpubInterner) -> Chat {
347 let handles: Vec<u16> = self.participants.iter().map(|p| interner.intern(p)).collect();
348 let mut chat = Chat::new(self.id, self.chat_type, handles);
349 chat.last_read = if self.last_read.is_empty() { [0u8; 32] } else { encode_message_id(&self.last_read) };
350 chat.created_at = self.created_at;
351 chat.metadata = self.metadata;
352 chat.muted = self.muted;
353 chat.wallpaper_path = self.wallpaper_path;
354 chat.wallpaper_ts = self.wallpaper_ts;
355 chat.wallpaper_blur = self.wallpaper_blur;
356 chat.wallpaper_dim = self.wallpaper_dim;
357 chat.wallpaper_url = self.wallpaper_url;
358 chat.wallpaper_uploader = self.wallpaper_uploader;
359 for msg in self.messages {
360 chat.add_message(msg, interner);
361 }
362 chat
363 }
364}
365
366#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
371pub enum ChatType {
372 DirectMessage,
373 Community,
376}
377
378impl ChatType {
379 pub fn to_i32(&self) -> i32 {
382 match self {
383 ChatType::DirectMessage => 0,
384 ChatType::Community => 2,
385 }
386 }
387 pub fn from_i32(value: i32) -> Self {
388 match value {
389 2 => ChatType::Community,
390 _ => ChatType::DirectMessage,
391 }
392 }
393}
394
395#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
396pub struct ChatMetadata {
397 pub custom_fields: HashMap<String, String>,
398}
399
400impl ChatMetadata {
401 pub fn new() -> Self { Self { custom_fields: HashMap::new() } }
402
403 pub fn set_name(&mut self, name: String) { self.custom_fields.insert("name".to_string(), name); }
404 pub fn get_name(&self) -> Option<&str> { self.custom_fields.get("name").map(|s| s.as_str()) }
405 pub fn set_member_count(&mut self, count: usize) { self.custom_fields.insert("member_count".to_string(), count.to_string()); }
406 pub fn get_member_count(&self) -> Option<usize> { self.custom_fields.get("member_count").and_then(|s| s.parse().ok()) }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::types::Message;
413 use crate::compact::NpubInterner;
414 use crate::simd::hex::bytes_to_hex_32;
415
416 #[test]
420 fn only_the_primary_channel_of_a_community_is_surfaced() {
421 let mut interner = NpubInterner::new();
422 let primary_id = make_hex_id(0x21);
423 let sibling_id = make_hex_id(0x22);
424
425 let mut stamp = |id: &str, community: bool, primary: Option<&str>| {
426 let mut chat = Chat::new_community_channel(id.to_string(), Vec::new(), &mut interner);
427 if community {
428 chat.metadata.custom_fields.insert("community_id".into(), make_hex_id(0x99));
429 }
430 if let Some(p) = primary {
431 chat.metadata.custom_fields.insert("primary_channel".into(), p.to_string());
432 }
433 chat
434 };
435
436 assert!(!stamp(&sibling_id, false, None).is_surfaced_community_channel());
439
440 assert!(stamp(&primary_id, true, Some(&primary_id)).is_surfaced_community_channel());
442
443 assert!(!stamp(&sibling_id, true, Some(&primary_id)).is_surfaced_community_channel());
446
447 assert!(stamp(&primary_id, true, None).is_surfaced_community_channel());
451 }
452
453 fn make_hex_id(seed: u8) -> String {
460 let mut bytes = [seed; 32];
461 bytes[0] = seed.wrapping_add(0x10) | 0x10; bytes[1] = seed.wrapping_mul(37);
463 bytes_to_hex_32(&bytes)
464 }
465
466 fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
468 Message {
469 id: make_hex_id(id_seed),
470 content: content.to_string(),
471 at: timestamp_ms,
472 mine,
473 ..Default::default()
474 }
475 }
476
477 #[test]
482 fn new_dm_creates_correct_type() {
483 let mut interner = NpubInterner::new();
484 let chat = Chat::new_dm("npub1alice".to_string(), &mut interner);
485
486 assert_eq!(chat.id, "npub1alice", "DM chat id should be the peer's npub");
487 assert_eq!(chat.chat_type, ChatType::DirectMessage, "should be DirectMessage type");
488 assert_eq!(chat.participants.len(), 1, "DM should have one participant");
489 assert!(chat.is_empty(), "new chat should have no messages");
490 assert!(!chat.muted, "new chat should not be muted");
491 assert_eq!(chat.last_read, [0u8; 32], "last_read should be zeroed");
492 }
493
494 #[test]
495 fn new_community_channel_with_participants() {
496 let mut interner = NpubInterner::new();
497 let participants = vec![
498 "npub1alice".to_string(),
499 "npub1bob".to_string(),
500 "npub1charlie".to_string(),
501 ];
502 let chat = Chat::new_community_channel("grp_abc".to_string(), participants, &mut interner);
503
504 assert_eq!(chat.id, "grp_abc", "group chat id should match");
505 assert_eq!(chat.chat_type, ChatType::Community, "should be Community type");
506 assert_eq!(chat.participants.len(), 3, "should have 3 participants");
507 assert!(chat.is_community(), "is_community() should return true");
508 }
509
510 #[test]
511 fn new_chat_has_creation_timestamp() {
512 let mut interner = NpubInterner::new();
513 let chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
514
515 let now = web_time::SystemTime::now()
517 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
518 assert!(
519 chat.created_at >= now - 5 && chat.created_at <= now + 1,
520 "created_at ({}) should be close to now ({})",
521 chat.created_at, now
522 );
523 }
524
525 #[test]
530 fn add_message_and_get_message_roundtrip() {
531 let mut interner = NpubInterner::new();
532 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
533
534 let msg = make_message(1, "hello world", 1700000000000, false);
535 let msg_id = msg.id.clone();
536
537 let added = chat.add_message(msg, &mut interner);
538 assert!(added, "message should be added successfully");
539
540 let retrieved = chat.get_message(&msg_id, &interner)
541 .expect("message should be retrievable");
542 assert_eq!(retrieved.content, "hello world", "content should roundtrip");
543 assert_eq!(retrieved.id, msg_id, "id should roundtrip");
544 }
545
546 #[test]
547 fn add_message_dedup() {
548 let mut interner = NpubInterner::new();
549 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
550
551 let msg1 = make_message(1, "first", 1700000000000, false);
552 let msg2 = make_message(1, "duplicate id", 1700000001000, false);
553
554 assert!(chat.add_message(msg1, &mut interner), "first add should succeed");
555 assert!(!chat.add_message(msg2, &mut interner), "duplicate ID should be rejected");
556 assert_eq!(chat.message_count(), 1, "should have only one message");
557 }
558
559 #[test]
560 fn message_count_and_is_empty() {
561 let mut interner = NpubInterner::new();
562 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
563
564 assert!(chat.is_empty(), "new chat should be empty");
565 assert_eq!(chat.message_count(), 0, "new chat should have 0 messages");
566
567 chat.add_message(make_message(1, "a", 1700000000000, false), &mut interner);
568 assert!(!chat.is_empty(), "chat with message should not be empty");
569 assert_eq!(chat.message_count(), 1, "should have 1 message");
570
571 chat.add_message(make_message(2, "b", 1700000001000, false), &mut interner);
572 assert_eq!(chat.message_count(), 2, "should have 2 messages");
573 }
574
575 #[test]
576 fn has_message_check() {
577 let mut interner = NpubInterner::new();
578 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
579
580 let msg = make_message(1, "test", 1700000000000, false);
581 let msg_id = msg.id.clone();
582 chat.add_message(msg, &mut interner);
583
584 assert!(chat.has_message(&msg_id), "added message should be found");
585 assert!(!chat.has_message(&make_hex_id(99)), "unknown id should not be found");
586 }
587
588 #[test]
589 fn get_all_messages_returns_all() {
590 let mut interner = NpubInterner::new();
591 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
592
593 for i in 0..5u8 {
594 chat.add_message(
595 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
596 &mut interner,
597 );
598 }
599
600 let all = chat.get_all_messages(&interner);
601 assert_eq!(all.len(), 5, "should return all 5 messages");
602 }
603
604 #[test]
605 fn get_last_messages_returns_tail() {
606 let mut interner = NpubInterner::new();
607 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
608
609 for i in 0..10u8 {
610 chat.add_message(
611 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
612 &mut interner,
613 );
614 }
615
616 let last3 = chat.get_last_messages(3, &interner);
617 assert_eq!(last3.len(), 3, "should return exactly 3 messages");
618 assert_eq!(last3[0].content, "msg 7", "first of last 3 should be msg 7");
619 assert_eq!(last3[2].content, "msg 9", "last should be msg 9");
620 }
621
622 #[test]
623 fn last_message_time_tracks_newest() {
624 let mut interner = NpubInterner::new();
625 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
626
627 assert!(chat.last_message_time().is_none(), "empty chat should have no last time");
628
629 chat.add_message(make_message(1, "a", 1700000001000, false), &mut interner);
630 let t1 = chat.last_message_time().expect("should have a timestamp");
631
632 chat.add_message(make_message(2, "b", 1700000005000, false), &mut interner);
633 let t2 = chat.last_message_time().expect("should have a timestamp");
634
635 assert!(t2 > t1, "last_message_time should increase with newer messages");
636 }
637
638 #[test]
643 fn set_as_read_marks_last_non_mine() {
644 let mut interner = NpubInterner::new();
645 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
646
647 let msg_theirs = make_message(1, "from them", 1700000001000, false);
648 let msg_mine = make_message(2, "from me", 1700000002000, true);
649 let msg_theirs2 = make_message(3, "from them again", 1700000003000, false);
650 let last_their_id = msg_theirs2.id.clone();
651
652 chat.add_message(msg_theirs, &mut interner);
653 chat.add_message(msg_mine, &mut interner);
654 chat.add_message(msg_theirs2, &mut interner);
655
656 let marked = chat.set_as_read();
657 assert!(marked, "set_as_read should succeed when there are non-mine messages");
658
659 let expected_bytes = crate::compact::encode_message_id(&last_their_id);
660 assert_eq!(
661 chat.last_read, expected_bytes,
662 "last_read should point to the last non-mine message"
663 );
664 }
665
666 #[test]
667 fn set_as_read_all_mine_returns_false() {
668 let mut interner = NpubInterner::new();
669 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
670
671 chat.add_message(make_message(1, "mine 1", 1700000001000, true), &mut interner);
672 chat.add_message(make_message(2, "mine 2", 1700000002000, true), &mut interner);
673
674 let marked = chat.set_as_read();
675 assert!(!marked, "set_as_read should return false when all messages are mine");
676 assert_eq!(chat.last_read, [0u8; 32], "last_read should remain zeroed");
677 }
678
679 #[test]
684 fn to_serializable_and_back_via_to_chat() {
685 let mut interner = NpubInterner::new();
686 let participants = vec!["npub1alice".to_string(), "npub1bob".to_string()];
687 let mut chat = Chat::new_community_channel("grp_test".to_string(), participants.clone(), &mut interner);
688
689 chat.metadata.set_name("Test Group".to_string());
690 chat.muted = true;
691
692 chat.add_message(make_message(1, "hello", 1700000001000, false), &mut interner);
694 chat.add_message(make_message(2, "world", 1700000002000, true), &mut interner);
695
696 chat.set_as_read();
698
699 let serializable = chat.to_serializable(&interner);
701 assert_eq!(serializable.id, "grp_test", "serialized id should match");
702 assert_eq!(serializable.chat_type, ChatType::Community, "serialized type should match");
703 assert_eq!(serializable.participants.len(), 2, "should have 2 participants");
704 assert_eq!(serializable.messages.len(), 2, "should have 2 messages");
705 assert!(serializable.muted, "muted should be preserved");
706 assert_eq!(
707 serializable.metadata.get_name(),
708 Some("Test Group"),
709 "metadata name should be preserved"
710 );
711
712 let mut interner2 = NpubInterner::new();
714 let restored = serializable.to_chat(&mut interner2);
715
716 assert_eq!(restored.id, "grp_test", "restored id should match");
717 assert_eq!(restored.chat_type, ChatType::Community, "restored type should match");
718 assert_eq!(restored.participants.len(), 2, "restored participants count should match");
719 assert_eq!(restored.message_count(), 2, "restored message count should match");
720 assert!(restored.muted, "restored muted should be true");
721 assert_ne!(restored.last_read, [0u8; 32], "restored last_read should be non-zero");
722 }
723
724 #[test]
725 fn to_serializable_with_last_n() {
726 let mut interner = NpubInterner::new();
727 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
728
729 for i in 0..10u8 {
730 chat.add_message(
731 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
732 &mut interner,
733 );
734 }
735
736 let serialized = chat.to_serializable_with_last_n(3, &interner);
737 assert_eq!(serialized.messages.len(), 3, "should only include last 3 messages");
738 }
739
740 #[test]
745 fn has_participant_check() {
746 let mut interner = NpubInterner::new();
747 let chat = Chat::new_community_channel(
748 "grp1".to_string(),
749 vec!["npub1alice".to_string(), "npub1bob".to_string()],
750 &mut interner,
751 );
752
753 assert!(
754 chat.has_participant("npub1alice", &interner),
755 "alice should be a participant"
756 );
757 assert!(
758 chat.has_participant("npub1bob", &interner),
759 "bob should be a participant"
760 );
761 assert!(
762 !chat.has_participant("npub1charlie", &interner),
763 "charlie should not be a participant"
764 );
765 }
766
767 #[test]
768 fn is_dm_with_check() {
769 let mut interner = NpubInterner::new();
770 let chat = Chat::new_dm("npub1alice".to_string(), &mut interner);
771
772 assert!(
773 chat.is_dm_with("npub1alice", &interner),
774 "should be a DM with alice"
775 );
776 assert!(
777 !chat.is_dm_with("npub1bob", &interner),
778 "should not be a DM with bob"
779 );
780 }
781
782 #[test]
783 fn is_dm_with_returns_false_for_group() {
784 let mut interner = NpubInterner::new();
785 let chat = Chat::new_community_channel(
786 "grp1".to_string(),
787 vec!["npub1alice".to_string()],
788 &mut interner,
789 );
790
791 assert!(
792 !chat.is_dm_with("npub1alice", &interner),
793 "community channel should not match is_dm_with even if participant matches"
794 );
795 }
796
797 #[test]
798 fn get_other_participant_dm() {
799 let mut interner = NpubInterner::new();
800 let chat = Chat::new_dm("npub1bob".to_string(), &mut interner);
802
803 let other = chat.get_other_participant("npub1alice", &interner);
804 assert_eq!(
805 other, Some("npub1bob".to_string()),
806 "should return bob as the other participant"
807 );
808 }
809
810 #[test]
811 fn get_other_participant_returns_none_for_group() {
812 let mut interner = NpubInterner::new();
813 let chat = Chat::new_community_channel(
814 "grp1".to_string(),
815 vec!["npub1alice".to_string(), "npub1bob".to_string()],
816 &mut interner,
817 );
818
819 assert!(
820 chat.get_other_participant("npub1alice", &interner).is_none(),
821 "community channel should return None for get_other_participant"
822 );
823 }
824
825 #[test]
830 fn typing_participants_with_expiry() {
831 let mut interner = NpubInterner::new();
832 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
833
834 let now = web_time::SystemTime::now()
835 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
836
837 let active_handle = interner.intern("npub1active");
839 let expired_handle = interner.intern("npub1expired");
840
841 chat.update_typing_participant(active_handle, now + 300);
842 chat.update_typing_participant(expired_handle, now - 10);
843
844 let active = chat.get_active_typers(&interner);
845 assert_eq!(active.len(), 1, "only the active typer should be returned");
846 assert_eq!(active[0], "npub1active", "active typer should be npub1active");
847 }
848
849 #[test]
850 fn update_typing_participant_refreshes() {
851 let mut interner = NpubInterner::new();
852 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
853
854 let now = web_time::SystemTime::now()
855 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
856
857 let handle = interner.intern("npub1typer");
858 chat.update_typing_participant(handle, now + 100);
859 chat.update_typing_participant(handle, now + 500);
860
861 let active = chat.get_active_typers(&interner);
863 assert_eq!(active.len(), 1, "refreshed typer should not duplicate");
864 }
865
866 #[test]
867 fn typing_participants_empty_initially() {
868 let interner = NpubInterner::new();
869 let chat = Chat::new("testchat".to_string(), ChatType::DirectMessage, vec![]);
870
871 let active = chat.get_active_typers(&interner);
872 assert!(active.is_empty(), "new chat should have no typers");
873 }
874
875 #[test]
880 fn chat_type_i32_roundtrip() {
881 assert_eq!(ChatType::from_i32(ChatType::DirectMessage.to_i32()), ChatType::DirectMessage);
882 assert_eq!(ChatType::from_i32(ChatType::Community.to_i32()), ChatType::Community);
883 assert_eq!(
884 ChatType::from_i32(999), ChatType::DirectMessage,
885 "unknown i32 should default to DirectMessage"
886 );
887 }
888
889 #[test]
894 fn chat_metadata_name_and_member_count() {
895 let mut meta = ChatMetadata::new();
896
897 assert!(meta.get_name().is_none(), "new metadata should have no name");
898 assert!(meta.get_member_count().is_none(), "new metadata should have no member count");
899
900 meta.set_name("My Group".to_string());
901 meta.set_member_count(42);
902
903 assert_eq!(meta.get_name(), Some("My Group"), "name should be set");
904 assert_eq!(meta.get_member_count(), Some(42), "member count should be set");
905 }
906
907 #[test]
912 fn accessor_methods_work() {
913 let mut interner = NpubInterner::new();
914 let mut chat = Chat::new_dm("npub1test".to_string(), &mut interner);
915 chat.muted = true;
916
917 assert_eq!(chat.id(), "npub1test");
918 assert_eq!(*chat.chat_type(), ChatType::DirectMessage);
919 assert_eq!(chat.participants().len(), 1);
920 assert_eq!(*chat.last_read(), [0u8; 32]);
921 assert!(chat.created_at() > 0);
922 assert!(chat.muted());
923 assert_eq!(*chat.metadata(), ChatMetadata::new());
924 }
925}