Skip to main content

vector_core/
chat.rs

1//! Chat types and management — compact message storage.
2//!
3//! `Chat` uses `CompactMessageVec` internally for memory efficiency.
4//! Use `to_serializable()` to convert to frontend-friendly `SerializableChat`.
5
6use 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// ============================================================================
13// Chat (Internal Storage)
14// ============================================================================
15
16#[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    /// Local cached file path for the per-DM wallpaper, or empty when unset.
28    /// Populated from the most recent kind-30078 d=vector-wallpaper rumor
29    /// received for this chat (the encrypted Blossom file is fetched and
30    /// decrypted to this path).
31    pub wallpaper_path: String,
32    /// Rumor created_at (Unix seconds) that produced the current wallpaper.
33    /// Used as the latest-write-wins tiebreaker on concurrent sets.
34    pub wallpaper_ts: u64,
35    /// Blur amount in pixels applied to the wallpaper background layer
36    /// (0..=30, clamped on read). 0 means no blur.
37    pub wallpaper_blur: u8,
38    /// Brightness percent applied to the wallpaper background layer
39    /// (0..=100, clamped on read). 100 means no darkening; 0 is fully
40    /// dim. Default 70 keeps text readable on photographic wallpapers.
41    pub wallpaper_dim: u8,
42    /// Blossom URL of the encrypted wallpaper blob currently in effect.
43    /// Used to DELETE the previous blob when we (or our other device)
44    /// replace the wallpaper, so stale uploads don't linger on servers.
45    pub wallpaper_url: String,
46    /// npub (bech32) of whichever account uploaded the current wallpaper.
47    /// Gate on this before issuing the Blossom DELETE — only the original
48    /// uploader's signature satisfies the server's auth challenge.
49    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    /// A Community channel chat (GROUP_PROTOCOL.md). `channel_id` is the channel's
83    /// stable random id (hex); `participants` are the members known so far.
84    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    // ========================================================================
90    // Message Access
91    // ========================================================================
92
93    #[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    // ========================================================================
135    // Message Mutation
136    // ========================================================================
137
138    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    // ========================================================================
168    // Serialization
169    // ========================================================================
170
171    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    // ========================================================================
216    // Chat Metadata & Participants
217    // ========================================================================
218
219    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            // Community channels have no single "other" participant.
228            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// ============================================================================
273// SerializableChat (Frontend Communication)
274// ============================================================================
275
276#[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// ============================================================================
324// Supporting Types
325// ============================================================================
326
327#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
328pub enum ChatType {
329    DirectMessage,
330    /// A Community channel (GROUP_PROTOCOL.md). The chat `id`
331    /// is the channel's stable random id.
332    Community,
333}
334
335impl ChatType {
336    // Discriminant 1 was the removed MlsGroup variant; Community keeps 2 for
337    // on-disk compatibility. Legacy chat_type=1 rows are dropped at the DB load layer.
338    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    // ========================================================================
374    // Helpers
375    // ========================================================================
376
377    /// Create a deterministic 64-char hex ID from a u8 seed.
378    /// First byte is always >= 0x10 to avoid the pending ID marker (0x01).
379    fn make_hex_id(seed: u8) -> String {
380        let mut bytes = [seed; 32];
381        bytes[0] = seed.wrapping_add(0x10) | 0x10; // never 0x00 or 0x01
382        bytes[1] = seed.wrapping_mul(37);
383        bytes_to_hex_32(&bytes)
384    }
385
386    /// Build a test Message with the given parameters.
387    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    // ========================================================================
398    // Chat Construction
399    // ========================================================================
400
401    #[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        // created_at should be recent (within last 5 seconds)
436        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    // ========================================================================
446    // Message Operations
447    // ========================================================================
448
449    #[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    // ========================================================================
559    // set_as_read
560    // ========================================================================
561
562    #[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    // ========================================================================
600    // Serialization Roundtrip
601    // ========================================================================
602
603    #[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        // Add messages
613        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        // Set last_read
617        chat.set_as_read();
618
619        // Serialize to SerializableChat
620        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        // Convert back to Chat
633        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    // ========================================================================
661    // Participants
662    // ========================================================================
663
664    #[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        // In a DM, the participant list typically has the other person
721        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    // ========================================================================
746    // Typing Participants
747    // ========================================================================
748
749    #[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        // One active, one expired
758        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        // Should still be one entry, not duplicated
782        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    // ========================================================================
796    // ChatType
797    // ========================================================================
798
799    #[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    // ========================================================================
810    // ChatMetadata
811    // ========================================================================
812
813    #[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    // ========================================================================
828    // Accessor Methods
829    // ========================================================================
830
831    #[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}