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: web_time::SystemTime::now()
61                .duration_since(web_time::UNIX_EPOCH)
62                .unwrap()
63                .as_secs(),
64            metadata: ChatMetadata::new(),
65            muted: false,
66            typing_participants: Vec::new(),
67            wallpaper_path: String::new(),
68            wallpaper_ts: 0,
69            wallpaper_blur: 0,
70            wallpaper_dim: 50,
71            wallpaper_url: String::new(),
72            wallpaper_uploader: String::new(),
73        }
74    }
75
76    pub fn new_dm(their_npub: String, interner: &mut NpubInterner) -> Self {
77        let handle = interner.intern(&their_npub);
78        Self::new(their_npub, ChatType::DirectMessage, vec![handle])
79    }
80
81
82    /// 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        let resolved = crate::notify::resolve_chat(self);
179        SerializableChat {
180            id: self.id.clone(),
181            chat_type: self.chat_type.clone(),
182            participants: self.resolve_participants(interner),
183            messages: self.get_all_messages(interner),
184            last_read: if self.last_read == [0u8; 32] { String::new() } else { decode_message_id(&self.last_read) },
185            created_at: self.created_at,
186            metadata: self.metadata.clone(),
187            muted: resolved.muted,
188            notify: resolved.ring.as_u8(),
189            everyone_pings: crate::notify::everyone_pings_for_chat(self),
190            community_muted: crate::notify::community_muted_for_chat(self),
191            wallpaper_path: self.wallpaper_path.clone(),
192            wallpaper_ts: self.wallpaper_ts,
193            wallpaper_blur: self.wallpaper_blur,
194            wallpaper_dim: self.wallpaper_dim,
195            wallpaper_url: self.wallpaper_url.clone(),
196            wallpaper_uploader: self.wallpaper_uploader.clone(),
197        }
198    }
199
200    pub fn to_serializable_with_last_n(&self, n: usize, interner: &NpubInterner) -> SerializableChat {
201        let resolved = crate::notify::resolve_chat(self);
202        SerializableChat {
203            id: self.id.clone(),
204            chat_type: self.chat_type.clone(),
205            participants: self.resolve_participants(interner),
206            messages: self.get_last_messages(n, interner),
207            last_read: if self.last_read == [0u8; 32] { String::new() } else { decode_message_id(&self.last_read) },
208            created_at: self.created_at,
209            metadata: self.metadata.clone(),
210            muted: resolved.muted,
211            notify: resolved.ring.as_u8(),
212            everyone_pings: crate::notify::everyone_pings_for_chat(self),
213            community_muted: crate::notify::community_muted_for_chat(self),
214            wallpaper_path: self.wallpaper_path.clone(),
215            wallpaper_ts: self.wallpaper_ts,
216            wallpaper_blur: self.wallpaper_blur,
217            wallpaper_dim: self.wallpaper_dim,
218            wallpaper_url: self.wallpaper_url.clone(),
219            wallpaper_uploader: self.wallpaper_uploader.clone(),
220        }
221    }
222
223    // ========================================================================
224    // Chat Metadata & Participants
225    // ========================================================================
226
227    pub fn get_other_participant(&self, my_npub: &str, interner: &NpubInterner) -> Option<String> {
228        match self.chat_type {
229            ChatType::DirectMessage => {
230                let my_handle = interner.lookup(my_npub);
231                self.participants.iter()
232                    .find(|&&h| Some(h) != my_handle)
233                    .and_then(|&h| interner.resolve(h).map(|s| s.to_string()))
234            }
235            // Community channels have no single "other" participant.
236            ChatType::Community => None,
237        }
238    }
239
240    pub fn is_dm_with(&self, npub: &str, interner: &NpubInterner) -> bool {
241        matches!(self.chat_type, ChatType::DirectMessage)
242            && interner.lookup(npub).is_some_and(|h| self.participants.contains(&h))
243    }
244
245    pub fn is_community(&self) -> bool { matches!(self.chat_type, ChatType::Community) }
246
247    /// Whether this Community channel is the row its community actually shows in the chat
248    /// list. Every channel is registered and synced, but a community surfaces as a SINGLE
249    /// row: the one whose id matches its `primary_channel` stamp.
250    ///
251    /// Anything deciding what the user can SEE or CLEAR must gate on this, not on the
252    /// presence of `community_id` — a sibling channel's unread would otherwise badge the
253    /// OS with no row to open. A row written before the stamp existed is assumed surfaced
254    /// (it re-stamps on the next registration), which matches the chat list's own
255    /// fallback so the two can't disagree.
256    pub fn is_surfaced_community_channel(&self) -> bool {
257        let cf = &self.metadata.custom_fields;
258        if !cf.contains_key("community_id") {
259            return false; // bare persistence anchor from the message-persist path
260        }
261        match cf.get("primary_channel") {
262            Some(primary) => *primary == self.id,
263            None => true,
264        }
265    }
266
267    pub fn has_participant(&self, npub: &str, interner: &NpubInterner) -> bool {
268        interner.lookup(npub).is_some_and(|h| self.participants.contains(&h))
269    }
270
271    pub fn get_active_typers(&self, interner: &NpubInterner) -> Vec<String> {
272        let now = web_time::SystemTime::now()
273            .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
274        self.typing_participants.iter()
275            .filter(|(_, exp)| *exp > now)
276            .filter_map(|(h, _)| interner.resolve(*h).map(|s| s.to_string()))
277            .collect()
278    }
279
280    pub fn update_typing_participant(&mut self, handle: u16, expires_at: u64) {
281        if let Some(entry) = self.typing_participants.iter_mut().find(|(h, _)| *h == handle) {
282            entry.1 = expires_at;
283        } else {
284            self.typing_participants.push((handle, expires_at));
285        }
286        let now = web_time::SystemTime::now()
287            .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
288        self.typing_participants.retain(|(_, exp)| *exp > now);
289    }
290
291    pub fn id(&self) -> &String { &self.id }
292    pub fn chat_type(&self) -> &ChatType { &self.chat_type }
293    pub fn participants(&self) -> &[u16] { &self.participants }
294    pub fn last_read(&self) -> &[u8; 32] { &self.last_read }
295    pub fn created_at(&self) -> u64 { self.created_at }
296    pub fn metadata(&self) -> &ChatMetadata { &self.metadata }
297    pub fn muted(&self) -> bool { self.muted }
298}
299
300// ============================================================================
301// SerializableChat (Frontend Communication)
302// ============================================================================
303
304#[derive(Serialize, Deserialize, Clone, Debug)]
305pub struct SerializableChat {
306    pub id: String,
307    pub chat_type: ChatType,
308    pub participants: Vec<String>,
309    pub messages: Vec<Message>,
310    pub last_read: String,
311    pub created_at: u64,
312    pub metadata: ChatMetadata,
313    /// Effective mute, after the community's own mute and any timer. The row
314    /// greys on this; `notify` is what it badges with.
315    pub muted: bool,
316    /// The resolved level AFTER the mute clamp, as `NotifyLevel::as_u8`. The
317    /// frontend badges from this alone rather than re-walking the chain.
318    #[serde(default)]
319    pub notify: u8,
320    /// Whether an authorized @everyone still counts as a ping here, i.e. the
321    /// community has not suppressed it.
322    #[serde(default = "default_everyone_pings")]
323    pub everyone_pings: bool,
324    /// Whether the COMMUNITY this chat belongs to is silenced, as opposed to the
325    /// chat itself. A surface standing for the whole space reads this one.
326    #[serde(default)]
327    pub community_muted: bool,
328    #[serde(default)]
329    pub wallpaper_path: String,
330    #[serde(default)]
331    pub wallpaper_ts: u64,
332    #[serde(default)]
333    pub wallpaper_blur: u8,
334    #[serde(default = "default_wallpaper_dim")]
335    pub wallpaper_dim: u8,
336    #[serde(default)]
337    pub wallpaper_url: String,
338    #[serde(default)]
339    pub wallpaper_uploader: String,
340}
341
342fn default_wallpaper_dim() -> u8 { 50 }
343fn default_everyone_pings() -> bool { true }
344
345impl SerializableChat {
346    pub fn to_chat(self, interner: &mut NpubInterner) -> Chat {
347        let handles: Vec<u16> = self.participants.iter().map(|p| interner.intern(p)).collect();
348        let mut chat = Chat::new(self.id, self.chat_type, handles);
349        chat.last_read = if self.last_read.is_empty() { [0u8; 32] } else { encode_message_id(&self.last_read) };
350        chat.created_at = self.created_at;
351        chat.metadata = self.metadata;
352        chat.muted = self.muted;
353        chat.wallpaper_path = self.wallpaper_path;
354        chat.wallpaper_ts = self.wallpaper_ts;
355        chat.wallpaper_blur = self.wallpaper_blur;
356        chat.wallpaper_dim = self.wallpaper_dim;
357        chat.wallpaper_url = self.wallpaper_url;
358        chat.wallpaper_uploader = self.wallpaper_uploader;
359        for msg in self.messages {
360            chat.add_message(msg, interner);
361        }
362        chat
363    }
364}
365
366// ============================================================================
367// Supporting Types
368// ============================================================================
369
370#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
371pub enum ChatType {
372    DirectMessage,
373    /// A Community channel (GROUP_PROTOCOL.md). The chat `id`
374    /// is the channel's stable random id.
375    Community,
376}
377
378impl ChatType {
379    // Discriminant 1 was the removed MlsGroup variant; Community keeps 2 for
380    // on-disk compatibility. Legacy chat_type=1 rows are dropped at the DB load layer.
381    pub fn to_i32(&self) -> i32 {
382        match self {
383            ChatType::DirectMessage => 0,
384            ChatType::Community => 2,
385        }
386    }
387    pub fn from_i32(value: i32) -> Self {
388        match value {
389            2 => ChatType::Community,
390            _ => ChatType::DirectMessage,
391        }
392    }
393}
394
395#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
396pub struct ChatMetadata {
397    pub custom_fields: HashMap<String, String>,
398}
399
400impl ChatMetadata {
401    pub fn new() -> Self { Self { custom_fields: HashMap::new() } }
402
403    pub fn set_name(&mut self, name: String) { self.custom_fields.insert("name".to_string(), name); }
404    pub fn get_name(&self) -> Option<&str> { self.custom_fields.get("name").map(|s| s.as_str()) }
405    pub fn set_member_count(&mut self, count: usize) { self.custom_fields.insert("member_count".to_string(), count.to_string()); }
406    pub fn get_member_count(&self) -> Option<usize> { self.custom_fields.get("member_count").and_then(|s| s.parse().ok()) }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::types::Message;
413    use crate::compact::NpubInterner;
414    use crate::simd::hex::bytes_to_hex_32;
415
416    /// The single discriminator behind three user-visible behaviours: whether a channel
417    /// gets a chat-list row, whether its unread badges the OS, and whether it rings a
418    /// notification. Every channel is registered and synced; only the primary surfaces.
419    #[test]
420    fn only_the_primary_channel_of_a_community_is_surfaced() {
421        let mut interner = NpubInterner::new();
422        let primary_id = make_hex_id(0x21);
423        let sibling_id = make_hex_id(0x22);
424
425        let mut stamp = |id: &str, community: bool, primary: Option<&str>| {
426            let mut chat = Chat::new_community_channel(id.to_string(), Vec::new(), &mut interner);
427            if community {
428                chat.metadata.custom_fields.insert("community_id".into(), make_hex_id(0x99));
429            }
430            if let Some(p) = primary {
431                chat.metadata.custom_fields.insert("primary_channel".into(), p.to_string());
432            }
433            chat
434        };
435
436        // A bare persistence anchor (auto-created by the message persist) has no community
437        // metadata at all and has never been surfaced.
438        assert!(!stamp(&sibling_id, false, None).is_surfaced_community_channel());
439
440        // The community's primary row: the one row the list renders.
441        assert!(stamp(&primary_id, true, Some(&primary_id)).is_surfaced_community_channel());
442
443        // A sibling channel: registered, synced, addressable — but no row, so its unread
444        // must not badge and its messages must not ring.
445        assert!(!stamp(&sibling_id, true, Some(&primary_id)).is_surfaced_community_channel());
446
447        // Written before the stamp existed: assumed surfaced, so a community can't silently
448        // vanish from the list (and it re-stamps on the next registration). Matches the chat
449        // list's own fallback — the two must not disagree.
450        assert!(stamp(&primary_id, true, None).is_surfaced_community_channel());
451    }
452
453    // ========================================================================
454    // Helpers
455    // ========================================================================
456
457    /// Create a deterministic 64-char hex ID from a u8 seed.
458    /// First byte is always >= 0x10 to avoid the pending ID marker (0x01).
459    fn make_hex_id(seed: u8) -> String {
460        let mut bytes = [seed; 32];
461        bytes[0] = seed.wrapping_add(0x10) | 0x10; // never 0x00 or 0x01
462        bytes[1] = seed.wrapping_mul(37);
463        bytes_to_hex_32(&bytes)
464    }
465
466    /// Build a test Message with the given parameters.
467    fn make_message(id_seed: u8, content: &str, timestamp_ms: u64, mine: bool) -> Message {
468        Message {
469            id: make_hex_id(id_seed),
470            content: content.to_string(),
471            at: timestamp_ms,
472            mine,
473            ..Default::default()
474        }
475    }
476
477    // ========================================================================
478    // Chat Construction
479    // ========================================================================
480
481    #[test]
482    fn new_dm_creates_correct_type() {
483        let mut interner = NpubInterner::new();
484        let chat = Chat::new_dm("npub1alice".to_string(), &mut interner);
485
486        assert_eq!(chat.id, "npub1alice", "DM chat id should be the peer's npub");
487        assert_eq!(chat.chat_type, ChatType::DirectMessage, "should be DirectMessage type");
488        assert_eq!(chat.participants.len(), 1, "DM should have one participant");
489        assert!(chat.is_empty(), "new chat should have no messages");
490        assert!(!chat.muted, "new chat should not be muted");
491        assert_eq!(chat.last_read, [0u8; 32], "last_read should be zeroed");
492    }
493
494    #[test]
495    fn new_community_channel_with_participants() {
496        let mut interner = NpubInterner::new();
497        let participants = vec![
498            "npub1alice".to_string(),
499            "npub1bob".to_string(),
500            "npub1charlie".to_string(),
501        ];
502        let chat = Chat::new_community_channel("grp_abc".to_string(), participants, &mut interner);
503
504        assert_eq!(chat.id, "grp_abc", "group chat id should match");
505        assert_eq!(chat.chat_type, ChatType::Community, "should be Community type");
506        assert_eq!(chat.participants.len(), 3, "should have 3 participants");
507        assert!(chat.is_community(), "is_community() should return true");
508    }
509
510    #[test]
511    fn new_chat_has_creation_timestamp() {
512        let mut interner = NpubInterner::new();
513        let chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
514
515        // created_at should be recent (within last 5 seconds)
516        let now = web_time::SystemTime::now()
517            .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
518        assert!(
519            chat.created_at >= now - 5 && chat.created_at <= now + 1,
520            "created_at ({}) should be close to now ({})",
521            chat.created_at, now
522        );
523    }
524
525    // ========================================================================
526    // Message Operations
527    // ========================================================================
528
529    #[test]
530    fn add_message_and_get_message_roundtrip() {
531        let mut interner = NpubInterner::new();
532        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
533
534        let msg = make_message(1, "hello world", 1700000000000, false);
535        let msg_id = msg.id.clone();
536
537        let added = chat.add_message(msg, &mut interner);
538        assert!(added, "message should be added successfully");
539
540        let retrieved = chat.get_message(&msg_id, &interner)
541            .expect("message should be retrievable");
542        assert_eq!(retrieved.content, "hello world", "content should roundtrip");
543        assert_eq!(retrieved.id, msg_id, "id should roundtrip");
544    }
545
546    #[test]
547    fn add_message_dedup() {
548        let mut interner = NpubInterner::new();
549        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
550
551        let msg1 = make_message(1, "first", 1700000000000, false);
552        let msg2 = make_message(1, "duplicate id", 1700000001000, false);
553
554        assert!(chat.add_message(msg1, &mut interner), "first add should succeed");
555        assert!(!chat.add_message(msg2, &mut interner), "duplicate ID should be rejected");
556        assert_eq!(chat.message_count(), 1, "should have only one message");
557    }
558
559    #[test]
560    fn message_count_and_is_empty() {
561        let mut interner = NpubInterner::new();
562        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
563
564        assert!(chat.is_empty(), "new chat should be empty");
565        assert_eq!(chat.message_count(), 0, "new chat should have 0 messages");
566
567        chat.add_message(make_message(1, "a", 1700000000000, false), &mut interner);
568        assert!(!chat.is_empty(), "chat with message should not be empty");
569        assert_eq!(chat.message_count(), 1, "should have 1 message");
570
571        chat.add_message(make_message(2, "b", 1700000001000, false), &mut interner);
572        assert_eq!(chat.message_count(), 2, "should have 2 messages");
573    }
574
575    #[test]
576    fn has_message_check() {
577        let mut interner = NpubInterner::new();
578        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
579
580        let msg = make_message(1, "test", 1700000000000, false);
581        let msg_id = msg.id.clone();
582        chat.add_message(msg, &mut interner);
583
584        assert!(chat.has_message(&msg_id), "added message should be found");
585        assert!(!chat.has_message(&make_hex_id(99)), "unknown id should not be found");
586    }
587
588    #[test]
589    fn get_all_messages_returns_all() {
590        let mut interner = NpubInterner::new();
591        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
592
593        for i in 0..5u8 {
594            chat.add_message(
595                make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
596                &mut interner,
597            );
598        }
599
600        let all = chat.get_all_messages(&interner);
601        assert_eq!(all.len(), 5, "should return all 5 messages");
602    }
603
604    #[test]
605    fn get_last_messages_returns_tail() {
606        let mut interner = NpubInterner::new();
607        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
608
609        for i in 0..10u8 {
610            chat.add_message(
611                make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
612                &mut interner,
613            );
614        }
615
616        let last3 = chat.get_last_messages(3, &interner);
617        assert_eq!(last3.len(), 3, "should return exactly 3 messages");
618        assert_eq!(last3[0].content, "msg 7", "first of last 3 should be msg 7");
619        assert_eq!(last3[2].content, "msg 9", "last should be msg 9");
620    }
621
622    #[test]
623    fn last_message_time_tracks_newest() {
624        let mut interner = NpubInterner::new();
625        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
626
627        assert!(chat.last_message_time().is_none(), "empty chat should have no last time");
628
629        chat.add_message(make_message(1, "a", 1700000001000, false), &mut interner);
630        let t1 = chat.last_message_time().expect("should have a timestamp");
631
632        chat.add_message(make_message(2, "b", 1700000005000, false), &mut interner);
633        let t2 = chat.last_message_time().expect("should have a timestamp");
634
635        assert!(t2 > t1, "last_message_time should increase with newer messages");
636    }
637
638    // ========================================================================
639    // set_as_read
640    // ========================================================================
641
642    #[test]
643    fn set_as_read_marks_last_non_mine() {
644        let mut interner = NpubInterner::new();
645        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
646
647        let msg_theirs = make_message(1, "from them", 1700000001000, false);
648        let msg_mine = make_message(2, "from me", 1700000002000, true);
649        let msg_theirs2 = make_message(3, "from them again", 1700000003000, false);
650        let last_their_id = msg_theirs2.id.clone();
651
652        chat.add_message(msg_theirs, &mut interner);
653        chat.add_message(msg_mine, &mut interner);
654        chat.add_message(msg_theirs2, &mut interner);
655
656        let marked = chat.set_as_read();
657        assert!(marked, "set_as_read should succeed when there are non-mine messages");
658
659        let expected_bytes = crate::compact::encode_message_id(&last_their_id);
660        assert_eq!(
661            chat.last_read, expected_bytes,
662            "last_read should point to the last non-mine message"
663        );
664    }
665
666    #[test]
667    fn set_as_read_all_mine_returns_false() {
668        let mut interner = NpubInterner::new();
669        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
670
671        chat.add_message(make_message(1, "mine 1", 1700000001000, true), &mut interner);
672        chat.add_message(make_message(2, "mine 2", 1700000002000, true), &mut interner);
673
674        let marked = chat.set_as_read();
675        assert!(!marked, "set_as_read should return false when all messages are mine");
676        assert_eq!(chat.last_read, [0u8; 32], "last_read should remain zeroed");
677    }
678
679    // ========================================================================
680    // Serialization Roundtrip
681    // ========================================================================
682
683    #[test]
684    fn to_serializable_and_back_via_to_chat() {
685        let mut interner = NpubInterner::new();
686        let participants = vec!["npub1alice".to_string(), "npub1bob".to_string()];
687        let mut chat = Chat::new_community_channel("grp_test".to_string(), participants.clone(), &mut interner);
688
689        chat.metadata.set_name("Test Group".to_string());
690        chat.muted = true;
691
692        // Add messages
693        chat.add_message(make_message(1, "hello", 1700000001000, false), &mut interner);
694        chat.add_message(make_message(2, "world", 1700000002000, true), &mut interner);
695
696        // Set last_read
697        chat.set_as_read();
698
699        // Serialize to SerializableChat
700        let serializable = chat.to_serializable(&interner);
701        assert_eq!(serializable.id, "grp_test", "serialized id should match");
702        assert_eq!(serializable.chat_type, ChatType::Community, "serialized type should match");
703        assert_eq!(serializable.participants.len(), 2, "should have 2 participants");
704        assert_eq!(serializable.messages.len(), 2, "should have 2 messages");
705        assert!(serializable.muted, "muted should be preserved");
706        assert_eq!(
707            serializable.metadata.get_name(),
708            Some("Test Group"),
709            "metadata name should be preserved"
710        );
711
712        // Convert back to Chat
713        let mut interner2 = NpubInterner::new();
714        let restored = serializable.to_chat(&mut interner2);
715
716        assert_eq!(restored.id, "grp_test", "restored id should match");
717        assert_eq!(restored.chat_type, ChatType::Community, "restored type should match");
718        assert_eq!(restored.participants.len(), 2, "restored participants count should match");
719        assert_eq!(restored.message_count(), 2, "restored message count should match");
720        assert!(restored.muted, "restored muted should be true");
721        assert_ne!(restored.last_read, [0u8; 32], "restored last_read should be non-zero");
722    }
723
724    #[test]
725    fn to_serializable_with_last_n() {
726        let mut interner = NpubInterner::new();
727        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
728
729        for i in 0..10u8 {
730            chat.add_message(
731                make_message(i, &format!("msg {}", i), 1700000000000 + i as u64 * 1000, false),
732                &mut interner,
733            );
734        }
735
736        let serialized = chat.to_serializable_with_last_n(3, &interner);
737        assert_eq!(serialized.messages.len(), 3, "should only include last 3 messages");
738    }
739
740    // ========================================================================
741    // Participants
742    // ========================================================================
743
744    #[test]
745    fn has_participant_check() {
746        let mut interner = NpubInterner::new();
747        let chat = Chat::new_community_channel(
748            "grp1".to_string(),
749            vec!["npub1alice".to_string(), "npub1bob".to_string()],
750            &mut interner,
751        );
752
753        assert!(
754            chat.has_participant("npub1alice", &interner),
755            "alice should be a participant"
756        );
757        assert!(
758            chat.has_participant("npub1bob", &interner),
759            "bob should be a participant"
760        );
761        assert!(
762            !chat.has_participant("npub1charlie", &interner),
763            "charlie should not be a participant"
764        );
765    }
766
767    #[test]
768    fn is_dm_with_check() {
769        let mut interner = NpubInterner::new();
770        let chat = Chat::new_dm("npub1alice".to_string(), &mut interner);
771
772        assert!(
773            chat.is_dm_with("npub1alice", &interner),
774            "should be a DM with alice"
775        );
776        assert!(
777            !chat.is_dm_with("npub1bob", &interner),
778            "should not be a DM with bob"
779        );
780    }
781
782    #[test]
783    fn is_dm_with_returns_false_for_group() {
784        let mut interner = NpubInterner::new();
785        let chat = Chat::new_community_channel(
786            "grp1".to_string(),
787            vec!["npub1alice".to_string()],
788            &mut interner,
789        );
790
791        assert!(
792            !chat.is_dm_with("npub1alice", &interner),
793            "community channel should not match is_dm_with even if participant matches"
794        );
795    }
796
797    #[test]
798    fn get_other_participant_dm() {
799        let mut interner = NpubInterner::new();
800        // In a DM, the participant list typically has the other person
801        let chat = Chat::new_dm("npub1bob".to_string(), &mut interner);
802
803        let other = chat.get_other_participant("npub1alice", &interner);
804        assert_eq!(
805            other, Some("npub1bob".to_string()),
806            "should return bob as the other participant"
807        );
808    }
809
810    #[test]
811    fn get_other_participant_returns_none_for_group() {
812        let mut interner = NpubInterner::new();
813        let chat = Chat::new_community_channel(
814            "grp1".to_string(),
815            vec!["npub1alice".to_string(), "npub1bob".to_string()],
816            &mut interner,
817        );
818
819        assert!(
820            chat.get_other_participant("npub1alice", &interner).is_none(),
821            "community channel should return None for get_other_participant"
822        );
823    }
824
825    // ========================================================================
826    // Typing Participants
827    // ========================================================================
828
829    #[test]
830    fn typing_participants_with_expiry() {
831        let mut interner = NpubInterner::new();
832        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
833
834        let now = web_time::SystemTime::now()
835            .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
836
837        // One active, one expired
838        let active_handle = interner.intern("npub1active");
839        let expired_handle = interner.intern("npub1expired");
840
841        chat.update_typing_participant(active_handle, now + 300);
842        chat.update_typing_participant(expired_handle, now - 10);
843
844        let active = chat.get_active_typers(&interner);
845        assert_eq!(active.len(), 1, "only the active typer should be returned");
846        assert_eq!(active[0], "npub1active", "active typer should be npub1active");
847    }
848
849    #[test]
850    fn update_typing_participant_refreshes() {
851        let mut interner = NpubInterner::new();
852        let mut chat = Chat::new_dm("npub1peer".to_string(), &mut interner);
853
854        let now = web_time::SystemTime::now()
855            .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
856
857        let handle = interner.intern("npub1typer");
858        chat.update_typing_participant(handle, now + 100);
859        chat.update_typing_participant(handle, now + 500);
860
861        // Should still be one entry, not duplicated
862        let active = chat.get_active_typers(&interner);
863        assert_eq!(active.len(), 1, "refreshed typer should not duplicate");
864    }
865
866    #[test]
867    fn typing_participants_empty_initially() {
868        let interner = NpubInterner::new();
869        let chat = Chat::new("testchat".to_string(), ChatType::DirectMessage, vec![]);
870
871        let active = chat.get_active_typers(&interner);
872        assert!(active.is_empty(), "new chat should have no typers");
873    }
874
875    // ========================================================================
876    // ChatType
877    // ========================================================================
878
879    #[test]
880    fn chat_type_i32_roundtrip() {
881        assert_eq!(ChatType::from_i32(ChatType::DirectMessage.to_i32()), ChatType::DirectMessage);
882        assert_eq!(ChatType::from_i32(ChatType::Community.to_i32()), ChatType::Community);
883        assert_eq!(
884            ChatType::from_i32(999), ChatType::DirectMessage,
885            "unknown i32 should default to DirectMessage"
886        );
887    }
888
889    // ========================================================================
890    // ChatMetadata
891    // ========================================================================
892
893    #[test]
894    fn chat_metadata_name_and_member_count() {
895        let mut meta = ChatMetadata::new();
896
897        assert!(meta.get_name().is_none(), "new metadata should have no name");
898        assert!(meta.get_member_count().is_none(), "new metadata should have no member count");
899
900        meta.set_name("My Group".to_string());
901        meta.set_member_count(42);
902
903        assert_eq!(meta.get_name(), Some("My Group"), "name should be set");
904        assert_eq!(meta.get_member_count(), Some(42), "member count should be set");
905    }
906
907    // ========================================================================
908    // Accessor Methods
909    // ========================================================================
910
911    #[test]
912    fn accessor_methods_work() {
913        let mut interner = NpubInterner::new();
914        let mut chat = Chat::new_dm("npub1test".to_string(), &mut interner);
915        chat.muted = true;
916
917        assert_eq!(chat.id(), "npub1test");
918        assert_eq!(*chat.chat_type(), ChatType::DirectMessage);
919        assert_eq!(chat.participants().len(), 1);
920        assert_eq!(*chat.last_read(), [0u8; 32]);
921        assert!(chat.created_at() > 0);
922        assert!(chat.muted());
923        assert_eq!(*chat.metadata(), ChatMetadata::new());
924    }
925}