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 has_participant(&self, npub: &str, interner: &NpubInterner) -> bool {
240 interner.lookup(npub).map_or(false, |h| self.participants.contains(&h))
241 }
242
243 pub fn get_active_typers(&self, interner: &NpubInterner) -> Vec<String> {
244 let now = std::time::SystemTime::now()
245 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
246 self.typing_participants.iter()
247 .filter(|(_, exp)| *exp > now)
248 .filter_map(|(h, _)| interner.resolve(*h).map(|s| s.to_string()))
249 .collect()
250 }
251
252 pub fn update_typing_participant(&mut self, handle: u16, expires_at: u64) {
253 if let Some(entry) = self.typing_participants.iter_mut().find(|(h, _)| *h == handle) {
254 entry.1 = expires_at;
255 } else {
256 self.typing_participants.push((handle, expires_at));
257 }
258 let now = std::time::SystemTime::now()
259 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
260 self.typing_participants.retain(|(_, exp)| *exp > now);
261 }
262
263 pub fn id(&self) -> &String { &self.id }
264 pub fn chat_type(&self) -> &ChatType { &self.chat_type }
265 pub fn participants(&self) -> &[u16] { &self.participants }
266 pub fn last_read(&self) -> &[u8; 32] { &self.last_read }
267 pub fn created_at(&self) -> u64 { self.created_at }
268 pub fn metadata(&self) -> &ChatMetadata { &self.metadata }
269 pub fn muted(&self) -> bool { self.muted }
270}
271
272#[derive(Serialize, Deserialize, Clone, Debug)]
277pub struct SerializableChat {
278 pub id: String,
279 pub chat_type: ChatType,
280 pub participants: Vec<String>,
281 pub messages: Vec<Message>,
282 pub last_read: String,
283 pub created_at: u64,
284 pub metadata: ChatMetadata,
285 pub muted: bool,
286 #[serde(default)]
287 pub wallpaper_path: String,
288 #[serde(default)]
289 pub wallpaper_ts: u64,
290 #[serde(default)]
291 pub wallpaper_blur: u8,
292 #[serde(default = "default_wallpaper_dim")]
293 pub wallpaper_dim: u8,
294 #[serde(default)]
295 pub wallpaper_url: String,
296 #[serde(default)]
297 pub wallpaper_uploader: String,
298}
299
300fn default_wallpaper_dim() -> u8 { 50 }
301
302impl SerializableChat {
303 pub fn to_chat(self, interner: &mut NpubInterner) -> Chat {
304 let handles: Vec<u16> = self.participants.iter().map(|p| interner.intern(p)).collect();
305 let mut chat = Chat::new(self.id, self.chat_type, handles);
306 chat.last_read = if self.last_read.is_empty() { [0u8; 32] } else { encode_message_id(&self.last_read) };
307 chat.created_at = self.created_at;
308 chat.metadata = self.metadata;
309 chat.muted = self.muted;
310 chat.wallpaper_path = self.wallpaper_path;
311 chat.wallpaper_ts = self.wallpaper_ts;
312 chat.wallpaper_blur = self.wallpaper_blur;
313 chat.wallpaper_dim = self.wallpaper_dim;
314 chat.wallpaper_url = self.wallpaper_url;
315 chat.wallpaper_uploader = self.wallpaper_uploader;
316 for msg in self.messages {
317 chat.add_message(msg, interner);
318 }
319 chat
320 }
321}
322
323#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
328pub enum ChatType {
329 DirectMessage,
330 Community,
333}
334
335impl ChatType {
336 pub fn to_i32(&self) -> i32 {
339 match self {
340 ChatType::DirectMessage => 0,
341 ChatType::Community => 2,
342 }
343 }
344 pub fn from_i32(value: i32) -> Self {
345 match value {
346 2 => ChatType::Community,
347 _ => ChatType::DirectMessage,
348 }
349 }
350}
351
352#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
353pub struct ChatMetadata {
354 pub custom_fields: HashMap<String, String>,
355}
356
357impl ChatMetadata {
358 pub fn new() -> Self { Self { custom_fields: HashMap::new() } }
359
360 pub fn set_name(&mut self, name: String) { self.custom_fields.insert("name".to_string(), name); }
361 pub fn get_name(&self) -> Option<&str> { self.custom_fields.get("name").map(|s| s.as_str()) }
362 pub fn set_member_count(&mut self, count: usize) { self.custom_fields.insert("member_count".to_string(), count.to_string()); }
363 pub fn get_member_count(&self) -> Option<usize> { self.custom_fields.get("member_count").and_then(|s| s.parse().ok()) }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::types::Message;
370 use crate::compact::NpubInterner;
371 use crate::simd::hex::bytes_to_hex_32;
372
373 fn make_hex_id(seed: u8) -> String {
380 let mut bytes = [seed; 32];
381 bytes[0] = seed.wrapping_add(0x10) | 0x10; bytes[1] = seed.wrapping_mul(37);
383 bytes_to_hex_32(&bytes)
384 }
385
386 fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
388 Message {
389 id: make_hex_id(id_seed),
390 content: content.to_string(),
391 at: timestamp_ms,
392 mine,
393 ..Default::default()
394 }
395 }
396
397 #[test]
402 fn new_dm_creates_correct_type() {
403 let mut interner = NpubInterner::new();
404 let chat = Chat::new_dm("npub1alice".to_string(), &mut interner);
405
406 assert_eq!(chat.id, "npub1alice", "DM chat id should be the peer's npub");
407 assert_eq!(chat.chat_type, ChatType::DirectMessage, "should be DirectMessage type");
408 assert_eq!(chat.participants.len(), 1, "DM should have one participant");
409 assert!(chat.is_empty(), "new chat should have no messages");
410 assert!(!chat.muted, "new chat should not be muted");
411 assert_eq!(chat.last_read, [0u8; 32], "last_read should be zeroed");
412 }
413
414 #[test]
415 fn new_community_channel_with_participants() {
416 let mut interner = NpubInterner::new();
417 let participants = vec![
418 "npub1alice".to_string(),
419 "npub1bob".to_string(),
420 "npub1charlie".to_string(),
421 ];
422 let chat = Chat::new_community_channel("grp_abc".to_string(), participants, &mut interner);
423
424 assert_eq!(chat.id, "grp_abc", "group chat id should match");
425 assert_eq!(chat.chat_type, ChatType::Community, "should be Community type");
426 assert_eq!(chat.participants.len(), 3, "should have 3 participants");
427 assert!(chat.is_community(), "is_community() should return true");
428 }
429
430 #[test]
431 fn new_chat_has_creation_timestamp() {
432 let mut interner = NpubInterner::new();
433 let chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
434
435 let now = std::time::SystemTime::now()
437 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
438 assert!(
439 chat.created_at >= now - 5 && chat.created_at <= now + 1,
440 "created_at ({}) should be close to now ({})",
441 chat.created_at, now
442 );
443 }
444
445 #[test]
450 fn add_message_and_get_message_roundtrip() {
451 let mut interner = NpubInterner::new();
452 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
453
454 let msg = make_message(1, "hello world", 1700000000000, false);
455 let msg_id = msg.id.clone();
456
457 let added = chat.add_message(msg, &mut interner);
458 assert!(added, "message should be added successfully");
459
460 let retrieved = chat.get_message(&msg_id, &interner)
461 .expect("message should be retrievable");
462 assert_eq!(retrieved.content, "hello world", "content should roundtrip");
463 assert_eq!(retrieved.id, msg_id, "id should roundtrip");
464 }
465
466 #[test]
467 fn add_message_dedup() {
468 let mut interner = NpubInterner::new();
469 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
470
471 let msg1 = make_message(1, "first", 1700000000000, false);
472 let msg2 = make_message(1, "duplicate id", 1700000001000, false);
473
474 assert!(chat.add_message(msg1, &mut interner), "first add should succeed");
475 assert!(!chat.add_message(msg2, &mut interner), "duplicate ID should be rejected");
476 assert_eq!(chat.message_count(), 1, "should have only one message");
477 }
478
479 #[test]
480 fn message_count_and_is_empty() {
481 let mut interner = NpubInterner::new();
482 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
483
484 assert!(chat.is_empty(), "new chat should be empty");
485 assert_eq!(chat.message_count(), 0, "new chat should have 0 messages");
486
487 chat.add_message(make_message(1, "a", 1700000000000, false), &mut interner);
488 assert!(!chat.is_empty(), "chat with message should not be empty");
489 assert_eq!(chat.message_count(), 1, "should have 1 message");
490
491 chat.add_message(make_message(2, "b", 1700000001000, false), &mut interner);
492 assert_eq!(chat.message_count(), 2, "should have 2 messages");
493 }
494
495 #[test]
496 fn has_message_check() {
497 let mut interner = NpubInterner::new();
498 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
499
500 let msg = make_message(1, "test", 1700000000000, false);
501 let msg_id = msg.id.clone();
502 chat.add_message(msg, &mut interner);
503
504 assert!(chat.has_message(&msg_id), "added message should be found");
505 assert!(!chat.has_message(&make_hex_id(99)), "unknown id should not be found");
506 }
507
508 #[test]
509 fn get_all_messages_returns_all() {
510 let mut interner = NpubInterner::new();
511 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
512
513 for i in 0..5u8 {
514 chat.add_message(
515 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
516 &mut interner,
517 );
518 }
519
520 let all = chat.get_all_messages(&interner);
521 assert_eq!(all.len(), 5, "should return all 5 messages");
522 }
523
524 #[test]
525 fn get_last_messages_returns_tail() {
526 let mut interner = NpubInterner::new();
527 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
528
529 for i in 0..10u8 {
530 chat.add_message(
531 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
532 &mut interner,
533 );
534 }
535
536 let last3 = chat.get_last_messages(3, &interner);
537 assert_eq!(last3.len(), 3, "should return exactly 3 messages");
538 assert_eq!(last3[0].content, "msg 7", "first of last 3 should be msg 7");
539 assert_eq!(last3[2].content, "msg 9", "last should be msg 9");
540 }
541
542 #[test]
543 fn last_message_time_tracks_newest() {
544 let mut interner = NpubInterner::new();
545 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
546
547 assert!(chat.last_message_time().is_none(), "empty chat should have no last time");
548
549 chat.add_message(make_message(1, "a", 1700000001000, false), &mut interner);
550 let t1 = chat.last_message_time().expect("should have a timestamp");
551
552 chat.add_message(make_message(2, "b", 1700000005000, false), &mut interner);
553 let t2 = chat.last_message_time().expect("should have a timestamp");
554
555 assert!(t2 > t1, "last_message_time should increase with newer messages");
556 }
557
558 #[test]
563 fn set_as_read_marks_last_non_mine() {
564 let mut interner = NpubInterner::new();
565 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
566
567 let msg_theirs = make_message(1, "from them", 1700000001000, false);
568 let msg_mine = make_message(2, "from me", 1700000002000, true);
569 let msg_theirs2 = make_message(3, "from them again", 1700000003000, false);
570 let last_their_id = msg_theirs2.id.clone();
571
572 chat.add_message(msg_theirs, &mut interner);
573 chat.add_message(msg_mine, &mut interner);
574 chat.add_message(msg_theirs2, &mut interner);
575
576 let marked = chat.set_as_read();
577 assert!(marked, "set_as_read should succeed when there are non-mine messages");
578
579 let expected_bytes = crate::compact::encode_message_id(&last_their_id);
580 assert_eq!(
581 chat.last_read, expected_bytes,
582 "last_read should point to the last non-mine message"
583 );
584 }
585
586 #[test]
587 fn set_as_read_all_mine_returns_false() {
588 let mut interner = NpubInterner::new();
589 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
590
591 chat.add_message(make_message(1, "mine 1", 1700000001000, true), &mut interner);
592 chat.add_message(make_message(2, "mine 2", 1700000002000, true), &mut interner);
593
594 let marked = chat.set_as_read();
595 assert!(!marked, "set_as_read should return false when all messages are mine");
596 assert_eq!(chat.last_read, [0u8; 32], "last_read should remain zeroed");
597 }
598
599 #[test]
604 fn to_serializable_and_back_via_to_chat() {
605 let mut interner = NpubInterner::new();
606 let participants = vec!["npub1alice".to_string(), "npub1bob".to_string()];
607 let mut chat = Chat::new_community_channel("grp_test".to_string(), participants.clone(), &mut interner);
608
609 chat.metadata.set_name("Test Group".to_string());
610 chat.muted = true;
611
612 chat.add_message(make_message(1, "hello", 1700000001000, false), &mut interner);
614 chat.add_message(make_message(2, "world", 1700000002000, true), &mut interner);
615
616 chat.set_as_read();
618
619 let serializable = chat.to_serializable(&interner);
621 assert_eq!(serializable.id, "grp_test", "serialized id should match");
622 assert_eq!(serializable.chat_type, ChatType::Community, "serialized type should match");
623 assert_eq!(serializable.participants.len(), 2, "should have 2 participants");
624 assert_eq!(serializable.messages.len(), 2, "should have 2 messages");
625 assert!(serializable.muted, "muted should be preserved");
626 assert_eq!(
627 serializable.metadata.get_name(),
628 Some("Test Group"),
629 "metadata name should be preserved"
630 );
631
632 let mut interner2 = NpubInterner::new();
634 let restored = serializable.to_chat(&mut interner2);
635
636 assert_eq!(restored.id, "grp_test", "restored id should match");
637 assert_eq!(restored.chat_type, ChatType::Community, "restored type should match");
638 assert_eq!(restored.participants.len(), 2, "restored participants count should match");
639 assert_eq!(restored.message_count(), 2, "restored message count should match");
640 assert!(restored.muted, "restored muted should be true");
641 assert_ne!(restored.last_read, [0u8; 32], "restored last_read should be non-zero");
642 }
643
644 #[test]
645 fn to_serializable_with_last_n() {
646 let mut interner = NpubInterner::new();
647 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
648
649 for i in 0..10u8 {
650 chat.add_message(
651 make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
652 &mut interner,
653 );
654 }
655
656 let serialized = chat.to_serializable_with_last_n(3, &interner);
657 assert_eq!(serialized.messages.len(), 3, "should only include last 3 messages");
658 }
659
660 #[test]
665 fn has_participant_check() {
666 let mut interner = NpubInterner::new();
667 let chat = Chat::new_community_channel(
668 "grp1".to_string(),
669 vec!["npub1alice".to_string(), "npub1bob".to_string()],
670 &mut interner,
671 );
672
673 assert!(
674 chat.has_participant("npub1alice", &interner),
675 "alice should be a participant"
676 );
677 assert!(
678 chat.has_participant("npub1bob", &interner),
679 "bob should be a participant"
680 );
681 assert!(
682 !chat.has_participant("npub1charlie", &interner),
683 "charlie should not be a participant"
684 );
685 }
686
687 #[test]
688 fn is_dm_with_check() {
689 let mut interner = NpubInterner::new();
690 let chat = Chat::new_dm("npub1alice".to_string(), &mut interner);
691
692 assert!(
693 chat.is_dm_with("npub1alice", &interner),
694 "should be a DM with alice"
695 );
696 assert!(
697 !chat.is_dm_with("npub1bob", &interner),
698 "should not be a DM with bob"
699 );
700 }
701
702 #[test]
703 fn is_dm_with_returns_false_for_group() {
704 let mut interner = NpubInterner::new();
705 let chat = Chat::new_community_channel(
706 "grp1".to_string(),
707 vec!["npub1alice".to_string()],
708 &mut interner,
709 );
710
711 assert!(
712 !chat.is_dm_with("npub1alice", &interner),
713 "community channel should not match is_dm_with even if participant matches"
714 );
715 }
716
717 #[test]
718 fn get_other_participant_dm() {
719 let mut interner = NpubInterner::new();
720 let chat = Chat::new_dm("npub1bob".to_string(), &mut interner);
722
723 let other = chat.get_other_participant("npub1alice", &interner);
724 assert_eq!(
725 other, Some("npub1bob".to_string()),
726 "should return bob as the other participant"
727 );
728 }
729
730 #[test]
731 fn get_other_participant_returns_none_for_group() {
732 let mut interner = NpubInterner::new();
733 let chat = Chat::new_community_channel(
734 "grp1".to_string(),
735 vec!["npub1alice".to_string(), "npub1bob".to_string()],
736 &mut interner,
737 );
738
739 assert!(
740 chat.get_other_participant("npub1alice", &interner).is_none(),
741 "community channel should return None for get_other_participant"
742 );
743 }
744
745 #[test]
750 fn typing_participants_with_expiry() {
751 let mut interner = NpubInterner::new();
752 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
753
754 let now = std::time::SystemTime::now()
755 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
756
757 let active_handle = interner.intern("npub1active");
759 let expired_handle = interner.intern("npub1expired");
760
761 chat.update_typing_participant(active_handle, now + 300);
762 chat.update_typing_participant(expired_handle, now - 10);
763
764 let active = chat.get_active_typers(&interner);
765 assert_eq!(active.len(), 1, "only the active typer should be returned");
766 assert_eq!(active[0], "npub1active", "active typer should be npub1active");
767 }
768
769 #[test]
770 fn update_typing_participant_refreshes() {
771 let mut interner = NpubInterner::new();
772 let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
773
774 let now = std::time::SystemTime::now()
775 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
776
777 let handle = interner.intern("npub1typer");
778 chat.update_typing_participant(handle, now + 100);
779 chat.update_typing_participant(handle, now + 500);
780
781 let active = chat.get_active_typers(&interner);
783 assert_eq!(active.len(), 1, "refreshed typer should not duplicate");
784 }
785
786 #[test]
787 fn typing_participants_empty_initially() {
788 let interner = NpubInterner::new();
789 let chat = Chat::new("testchat".to_string(), ChatType::DirectMessage, vec![]);
790
791 let active = chat.get_active_typers(&interner);
792 assert!(active.is_empty(), "new chat should have no typers");
793 }
794
795 #[test]
800 fn chat_type_i32_roundtrip() {
801 assert_eq!(ChatType::from_i32(ChatType::DirectMessage.to_i32()), ChatType::DirectMessage);
802 assert_eq!(ChatType::from_i32(ChatType::Community.to_i32()), ChatType::Community);
803 assert_eq!(
804 ChatType::from_i32(999), ChatType::DirectMessage,
805 "unknown i32 should default to DirectMessage"
806 );
807 }
808
809 #[test]
814 fn chat_metadata_name_and_member_count() {
815 let mut meta = ChatMetadata::new();
816
817 assert!(meta.get_name().is_none(), "new metadata should have no name");
818 assert!(meta.get_member_count().is_none(), "new metadata should have no member count");
819
820 meta.set_name("My Group".to_string());
821 meta.set_member_count(42);
822
823 assert_eq!(meta.get_name(), Some("My Group"), "name should be set");
824 assert_eq!(meta.get_member_count(), Some(42), "member count should be set");
825 }
826
827 #[test]
832 fn accessor_methods_work() {
833 let mut interner = NpubInterner::new();
834 let mut chat = Chat::new_dm("npub1test".to_string(), &mut interner);
835 chat.muted = true;
836
837 assert_eq!(chat.id(), "npub1test");
838 assert_eq!(*chat.chat_type(), ChatType::DirectMessage);
839 assert_eq!(chat.participants().len(), 1);
840 assert_eq!(*chat.last_read(), [0u8; 32]);
841 assert!(chat.created_at() > 0);
842 assert!(chat.muted());
843 assert_eq!(*chat.metadata(), ChatMetadata::new());
844 }
845}