Skip to main content

communitas_ui_api/
messaging.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Messaging and presence DTOs for thread lists, messages, and contact status.
4
5use crate::{SyncState, UnifiedContact, UnifiedEntityType};
6
7/// Thread summary shown in thread list sidebar.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ThreadSummary {
10    /// Unique thread identifier.
11    pub thread_id: String,
12    /// Entity ID if this is an entity thread (channel, group, etc.).
13    pub entity_id: Option<String>,
14    /// Entity type for entity threads.
15    pub entity_type: Option<UnifiedEntityType>,
16    /// Contact ID for direct message threads.
17    pub contact_id: Option<String>,
18    /// Display name shown in thread list.
19    pub display_name: String,
20    /// Preview of the last message (truncated).
21    pub last_message_preview: String,
22    /// Timestamp of last message in Unix milliseconds.
23    pub last_message_timestamp: u64,
24    /// Number of unread messages.
25    pub unread_count: u32,
26    /// Whether notifications are muted for this thread.
27    pub is_muted: bool,
28    /// Whether this is a direct message thread (1:1 conversation).
29    pub is_dm: bool,
30    /// Users currently typing in this thread.
31    pub typing_users: Vec<String>,
32    /// Whether this thread is pinned to the top of the list.
33    pub is_pinned: bool,
34    /// Presence status for DM threads (None for entity threads).
35    pub contact_presence: Option<PresenceStatus>,
36    /// Synchronization state for this thread.
37    pub sync_state: SyncState,
38}
39
40/// A message in a conversation thread.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Message {
43    /// Unique message identifier.
44    pub id: String,
45    /// Thread this message belongs to.
46    pub thread_id: String,
47    /// Sender's identity ID.
48    pub sender_id: String,
49    /// Sender's display name for UI rendering.
50    pub sender_name: String,
51    /// Message text content.
52    pub text: String,
53    /// Timestamp in Unix milliseconds.
54    pub timestamp: u64,
55    /// Whether the message has been edited.
56    pub edited: bool,
57    /// ID of the message being replied to, if any.
58    pub reply_to_id: Option<String>,
59    /// Reactions on this message.
60    pub reactions: Vec<MessageReaction>,
61    /// Whether this message has been pinned in the thread.
62    pub is_pinned: bool,
63}
64
65/// A reaction on a message (emoji + count).
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct MessageReaction {
68    /// Emoji character(s) for this reaction.
69    pub emoji: String,
70    /// Total number of users who reacted with this emoji.
71    pub count: u32,
72    /// Whether the current user has reacted with this emoji.
73    pub reacted_by_me: bool,
74}
75
76/// Presence status for contacts.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum PresenceStatus {
79    /// Status is unknown (not yet received from network).
80    #[default]
81    Unknown,
82    /// Contact is online and active.
83    Online,
84    /// Contact is online but idle.
85    Away,
86    /// Contact is online but busy/do-not-disturb.
87    Busy,
88    /// Contact is offline.
89    Offline,
90}
91
92/// Contact with presence information for UI rendering.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct ContactWithPresence {
95    /// The contact details.
96    pub contact: UnifiedContact,
97    /// Current presence status.
98    pub presence: PresenceStatus,
99    /// Last seen timestamp in Unix milliseconds (if offline).
100    pub last_seen: Option<u64>,
101    /// Whether the contact is currently in a call.
102    pub is_in_call: bool,
103    /// Entity name of the call (channel/group name) if in a call.
104    pub call_entity_name: Option<String>,
105    /// Whether the contact is currently screen sharing (presenting).
106    pub is_screen_sharing: bool,
107}
108
109/// Search result containing a matching message with context.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct SearchResult {
112    /// The matching message.
113    pub message: Message,
114    /// Thread ID where the message was found.
115    pub thread_id: String,
116    /// Display name of the thread for context.
117    pub thread_name: String,
118    /// Number of matches in this message.
119    pub match_count: usize,
120    /// Excerpt around the match for preview.
121    pub match_excerpt: String,
122}
123
124/// Status of a message being sent.
125#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
126pub enum MessageSendStatus {
127    /// Message is being sent right now.
128    Sending,
129    /// Message send failed, waiting for retry.
130    Pending,
131    /// Message failed after max retries.
132    Failed(String),
133}
134
135impl MessageSendStatus {
136    /// Whether the message is still attempting to send.
137    pub fn is_sending(&self) -> bool {
138        matches!(self, Self::Sending)
139    }
140
141    /// Whether the message is pending retry.
142    pub fn is_pending(&self) -> bool {
143        matches!(self, Self::Pending)
144    }
145
146    /// Whether the message has permanently failed.
147    pub fn is_failed(&self) -> bool {
148        matches!(self, Self::Failed(_))
149    }
150}
151
152/// A message queued for sending (offline queue).
153#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
154pub struct PendingMessage {
155    /// Unique identifier for this pending message.
156    pub id: String,
157    /// Target thread ID.
158    pub thread_id: String,
159    /// Message text content.
160    pub text: String,
161    /// Optional reply-to message ID.
162    pub reply_to_id: Option<String>,
163    /// When the message was queued (Unix milliseconds).
164    pub queued_at: u64,
165    /// Number of retry attempts so far.
166    pub retry_count: u32,
167    /// Current send status.
168    pub status: MessageSendStatus,
169    /// Last error message if failed.
170    pub last_error: Option<String>,
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn thread_summary_equality() {
179        let t1 = ThreadSummary {
180            thread_id: "t1".to_string(),
181            entity_id: Some("e1".to_string()),
182            entity_type: Some(UnifiedEntityType::Channel),
183            contact_id: None,
184            display_name: "General".to_string(),
185            last_message_preview: "Hello".to_string(),
186            last_message_timestamp: 1234567890,
187            unread_count: 5,
188            is_muted: false,
189            is_dm: false,
190            typing_users: vec![],
191            is_pinned: false,
192            contact_presence: None,
193            sync_state: SyncState::default(),
194        };
195        let t2 = t1.clone();
196        assert_eq!(t1, t2);
197    }
198
199    #[test]
200    fn thread_summary_dm_thread() {
201        let dm = ThreadSummary {
202            thread_id: "dm:alice-bob-cat-dog".to_string(),
203            entity_id: None,
204            entity_type: None,
205            contact_id: Some("alice-bob-cat-dog".to_string()),
206            display_name: "Alice".to_string(),
207            last_message_preview: "Hey!".to_string(),
208            last_message_timestamp: 1234567890,
209            unread_count: 1,
210            is_muted: false,
211            is_dm: true,
212            typing_users: vec![],
213            is_pinned: false,
214            contact_presence: Some(PresenceStatus::Online),
215            sync_state: SyncState::Synced,
216        };
217        assert!(dm.is_dm);
218        assert!(dm.contact_id.is_some());
219        assert!(dm.entity_id.is_none());
220        assert_eq!(dm.contact_presence, Some(PresenceStatus::Online));
221    }
222
223    #[test]
224    fn message_with_reactions() {
225        let msg = Message {
226            id: "m1".to_string(),
227            thread_id: "t1".to_string(),
228            sender_id: "u1".to_string(),
229            sender_name: "Alice".to_string(),
230            text: "Hello world".to_string(),
231            timestamp: 1234567890,
232            edited: false,
233            reply_to_id: None,
234            reactions: vec![MessageReaction {
235                emoji: "👍".to_string(),
236                count: 3,
237                reacted_by_me: true,
238            }],
239            is_pinned: false,
240        };
241        assert_eq!(msg.reactions.len(), 1);
242        assert!(msg.reactions[0].reacted_by_me);
243    }
244
245    #[test]
246    fn presence_status_default() {
247        let status = PresenceStatus::default();
248        assert_eq!(status, PresenceStatus::Unknown);
249    }
250
251    #[test]
252    fn contact_with_presence_construction() {
253        let contact = UnifiedContact {
254            id: "alice".to_string(),
255            display_name: "Alice".to_string(),
256            status: "available".to_string(),
257            presence: PresenceStatus::Online,
258        };
259        let cwp = ContactWithPresence {
260            contact: contact.clone(),
261            presence: PresenceStatus::Online,
262            last_seen: None,
263            is_in_call: false,
264            call_entity_name: None,
265            is_screen_sharing: false,
266        };
267        assert_eq!(cwp.contact.id, "alice");
268        assert_eq!(cwp.presence, PresenceStatus::Online);
269        assert!(!cwp.is_in_call);
270        assert!(cwp.call_entity_name.is_none());
271        assert!(!cwp.is_screen_sharing);
272    }
273
274    #[test]
275    fn contact_with_presence_in_call() {
276        let contact = UnifiedContact {
277            id: "bob".to_string(),
278            display_name: "Bob".to_string(),
279            status: "in_call".to_string(),
280            presence: PresenceStatus::Busy,
281        };
282        let cwp = ContactWithPresence {
283            contact,
284            presence: PresenceStatus::Busy,
285            last_seen: None,
286            is_in_call: true,
287            call_entity_name: Some("Team Standup".to_string()),
288            is_screen_sharing: false,
289        };
290        assert!(cwp.is_in_call);
291        assert_eq!(cwp.call_entity_name, Some("Team Standup".to_string()));
292        assert!(!cwp.is_screen_sharing);
293    }
294
295    #[test]
296    fn message_send_status_predicates() {
297        assert!(MessageSendStatus::Sending.is_sending());
298        assert!(!MessageSendStatus::Sending.is_pending());
299        assert!(!MessageSendStatus::Sending.is_failed());
300
301        assert!(!MessageSendStatus::Pending.is_sending());
302        assert!(MessageSendStatus::Pending.is_pending());
303        assert!(!MessageSendStatus::Pending.is_failed());
304
305        let failed = MessageSendStatus::Failed("Network error".to_string());
306        assert!(!failed.is_sending());
307        assert!(!failed.is_pending());
308        assert!(failed.is_failed());
309    }
310
311    #[test]
312    fn pending_message_construction() {
313        let pending = PendingMessage {
314            id: "pending-1".to_string(),
315            thread_id: "thread-1".to_string(),
316            text: "Hello offline".to_string(),
317            reply_to_id: None,
318            queued_at: 1234567890,
319            retry_count: 0,
320            status: MessageSendStatus::Pending,
321            last_error: None,
322        };
323        assert_eq!(pending.id, "pending-1");
324        assert_eq!(pending.thread_id, "thread-1");
325        assert!(pending.status.is_pending());
326        assert_eq!(pending.retry_count, 0);
327    }
328}