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    /// Whether this Community channel is the row its community actually shows in the chat
240    /// list. Every channel is registered and synced, but a community surfaces as a SINGLE
241    /// row: the one whose id matches its `primary_channel` stamp.
242    ///
243    /// Anything deciding what the user can SEE or CLEAR must gate on this, not on the
244    /// presence of `community_id` — a sibling channel's unread would otherwise badge the
245    /// OS with no row to open. A row written before the stamp existed is assumed surfaced
246    /// (it re-stamps on the next registration), which matches the chat list's own
247    /// fallback so the two can't disagree.
248    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; // bare persistence anchor from the message-persist path
252        }
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// ============================================================================
293// SerializableChat (Frontend Communication)
294// ============================================================================
295
296#[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// ============================================================================
344// Supporting Types
345// ============================================================================
346
347#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
348pub enum ChatType {
349    DirectMessage,
350    /// A Community channel (GROUP_PROTOCOL.md). The chat `id`
351    /// is the channel's stable random id.
352    Community,
353}
354
355impl ChatType {
356    // Discriminant 1 was the removed MlsGroup variant; Community keeps 2 for
357    // on-disk compatibility. Legacy chat_type=1 rows are dropped at the DB load layer.
358    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    /// The single discriminator behind three user-visible behaviours: whether a channel
394    /// gets a chat-list row, whether its unread badges the OS, and whether it rings a
395    /// notification. Every channel is registered and synced; only the primary surfaces.
396    #[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        // A bare persistence anchor (auto-created by the message persist) has no community
414        // metadata at all and has never been surfaced.
415        assert!(!stamp(&sibling_id, false, None).is_surfaced_community_channel());
416
417        // The community's primary row: the one row the list renders.
418        assert!(stamp(&primary_id, true, Some(&primary_id)).is_surfaced_community_channel());
419
420        // A sibling channel: registered, synced, addressable — but no row, so its unread
421        // must not badge and its messages must not ring.
422        assert!(!stamp(&sibling_id, true, Some(&primary_id)).is_surfaced_community_channel());
423
424        // Written before the stamp existed: assumed surfaced, so a community can't silently
425        // vanish from the list (and it re-stamps on the next registration). Matches the chat
426        // list's own fallback — the two must not disagree.
427        assert!(stamp(&primary_id, true, None).is_surfaced_community_channel());
428    }
429
430    // ========================================================================
431    // Helpers
432    // ========================================================================
433
434    /// Create a deterministic 64-char hex ID from a u8 seed.
435    /// First byte is always >= 0x10 to avoid the pending ID marker (0x01).
436    fn make_hex_id(seed: u8) -> String {
437        let mut bytes = [seed; 32];
438        bytes[0] = seed.wrapping_add(0x10) | 0x10; // never 0x00 or 0x01
439        bytes[1] = seed.wrapping_mul(37);
440        bytes_to_hex_32(&bytes)
441    }
442
443    /// Build a test Message with the given parameters.
444    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    // ========================================================================
455    // Chat Construction
456    // ========================================================================
457
458    #[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        // created_at should be recent (within last 5 seconds)
493        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    // ========================================================================
503    // Message Operations
504    // ========================================================================
505
506    #[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    // ========================================================================
616    // set_as_read
617    // ========================================================================
618
619    #[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    // ========================================================================
657    // Serialization Roundtrip
658    // ========================================================================
659
660    #[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        // Add messages
670        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        // Set last_read
674        chat.set_as_read();
675
676        // Serialize to SerializableChat
677        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        // Convert back to Chat
690        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    // ========================================================================
718    // Participants
719    // ========================================================================
720
721    #[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        // In a DM, the participant list typically has the other person
778        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    // ========================================================================
803    // Typing Participants
804    // ========================================================================
805
806    #[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        // One active, one expired
815        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        // Should still be one entry, not duplicated
839        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    // ========================================================================
853    // ChatType
854    // ========================================================================
855
856    #[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    // ========================================================================
867    // ChatMetadata
868    // ========================================================================
869
870    #[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    // ========================================================================
885    // Accessor Methods
886    // ========================================================================
887
888    #[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}