Skip to main content

river_core/room_state/
content.rs

1//! Message content types for client-side interpretation.
2//!
3//! The contract treats message content as opaque bytes with type/version tags.
4//! This module defines the client-side content types that are encoded into those bytes.
5//!
6//! # Extensibility
7//!
8//! - New content types: Add new `CONTENT_TYPE_*` constant, no contract change needed
9//! - New action types: Add new `ACTION_TYPE_*` constant, no contract change needed
10//! - New fields on existing types: Just add them (old clients ignore unknown fields)
11//! - Breaking format changes: Bump the version constant for that type
12
13use crate::room_state::message::MessageId;
14use serde::{Deserialize, Serialize};
15
16/// Content type constants
17pub const CONTENT_TYPE_TEXT: u32 = 1;
18pub const CONTENT_TYPE_ACTION: u32 = 2;
19pub const CONTENT_TYPE_REPLY: u32 = 3;
20pub const CONTENT_TYPE_EVENT: u32 = 4;
21// Future: CONTENT_TYPE_BLOB = 5, CONTENT_TYPE_POLL = 6, etc.
22
23/// Current version for text content
24pub const TEXT_CONTENT_VERSION: u32 = 1;
25
26/// Current version for action content
27pub const ACTION_CONTENT_VERSION: u32 = 1;
28
29/// Current version for reply content
30pub const REPLY_CONTENT_VERSION: u32 = 1;
31
32/// Current version for event content
33pub const EVENT_CONTENT_VERSION: u32 = 1;
34
35/// Event type constants
36pub const EVENT_TYPE_JOIN: u32 = 1;
37// Future: EVENT_TYPE_LEAVE = 2, etc.
38
39/// Action type constants
40pub const ACTION_TYPE_EDIT: u32 = 1;
41pub const ACTION_TYPE_DELETE: u32 = 2;
42pub const ACTION_TYPE_REACTION: u32 = 3;
43pub const ACTION_TYPE_REMOVE_REACTION: u32 = 4;
44// Future: ACTION_TYPE_PIN = 5, ACTION_TYPE_REPLY = 6, etc.
45
46/// Text message content (content_type = 1)
47#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
48pub struct TextContentV1 {
49    pub text: String,
50}
51
52impl TextContentV1 {
53    pub fn new(text: String) -> Self {
54        Self { text }
55    }
56
57    /// Encode to CBOR bytes
58    pub fn encode(&self) -> Vec<u8> {
59        encode_cbor(self)
60    }
61
62    /// Decode from CBOR bytes
63    pub fn decode(data: &[u8]) -> Result<Self, String> {
64        decode_cbor(data, "TextContentV1")
65    }
66}
67
68/// Encode a value to CBOR bytes
69fn encode_cbor<T: Serialize>(value: &T) -> Vec<u8> {
70    let mut data = Vec::new();
71    ciborium::into_writer(value, &mut data).expect("CBOR serialization should not fail");
72    data
73}
74
75/// Decode a value from CBOR bytes
76fn decode_cbor<T: serde::de::DeserializeOwned>(data: &[u8], type_name: &str) -> Result<T, String> {
77    ciborium::from_reader(data).map_err(|e| format!("Failed to decode {}: {}", type_name, e))
78}
79
80/// Action message content (content_type = 2)
81#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
82pub struct ActionContentV1 {
83    /// Type of action (ACTION_TYPE_* constants)
84    pub action_type: u32,
85    /// Target message ID for the action
86    pub target: MessageId,
87    /// Action-specific payload (CBOR-encoded)
88    pub payload: Vec<u8>,
89}
90
91impl ActionContentV1 {
92    /// Create an edit action
93    pub fn edit(target: MessageId, new_text: String) -> Self {
94        Self {
95            action_type: ACTION_TYPE_EDIT,
96            target,
97            payload: encode_cbor(&EditPayload { new_text }),
98        }
99    }
100
101    /// Create a delete action
102    pub fn delete(target: MessageId) -> Self {
103        Self {
104            action_type: ACTION_TYPE_DELETE,
105            target,
106            payload: Vec::new(),
107        }
108    }
109
110    /// Create a reaction action
111    pub fn reaction(target: MessageId, emoji: String) -> Self {
112        Self {
113            action_type: ACTION_TYPE_REACTION,
114            target,
115            payload: encode_cbor(&ReactionPayload { emoji }),
116        }
117    }
118
119    /// Create a remove reaction action
120    pub fn remove_reaction(target: MessageId, emoji: String) -> Self {
121        Self {
122            action_type: ACTION_TYPE_REMOVE_REACTION,
123            target,
124            payload: encode_cbor(&ReactionPayload { emoji }),
125        }
126    }
127
128    /// Encode to CBOR bytes
129    pub fn encode(&self) -> Vec<u8> {
130        encode_cbor(self)
131    }
132
133    /// Decode from CBOR bytes
134    pub fn decode(data: &[u8]) -> Result<Self, String> {
135        decode_cbor(data, "ActionContentV1")
136    }
137
138    /// Get the edit payload if this is an edit action
139    pub fn edit_payload(&self) -> Option<EditPayload> {
140        if self.action_type == ACTION_TYPE_EDIT {
141            ciborium::from_reader(&self.payload[..]).ok()
142        } else {
143            None
144        }
145    }
146
147    /// Get the reaction payload if this is a reaction or remove_reaction action
148    pub fn reaction_payload(&self) -> Option<ReactionPayload> {
149        if self.action_type == ACTION_TYPE_REACTION
150            || self.action_type == ACTION_TYPE_REMOVE_REACTION
151        {
152            ciborium::from_reader(&self.payload[..]).ok()
153        } else {
154            None
155        }
156    }
157}
158
159/// Payload for edit actions
160#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
161pub struct EditPayload {
162    pub new_text: String,
163}
164
165/// Payload for reaction actions
166#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
167pub struct ReactionPayload {
168    pub emoji: String,
169}
170
171/// Reply message content (content_type = 3)
172///
173/// A reply references a target message and includes a snapshot of the target's
174/// author name and content preview so that the reply remains meaningful even if
175/// the target message is later deleted or scrolled out of the recent window.
176#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
177pub struct ReplyContentV1 {
178    pub text: String,
179    pub target_message_id: MessageId,
180    pub target_author_name: String,
181    /// Snapshot of the target message content (~100 chars)
182    pub target_content_preview: String,
183}
184
185impl ReplyContentV1 {
186    pub fn new(
187        text: String,
188        target_message_id: MessageId,
189        target_author_name: String,
190        target_content_preview: String,
191    ) -> Self {
192        Self {
193            text,
194            target_message_id,
195            target_author_name,
196            target_content_preview,
197        }
198    }
199
200    pub fn encode(&self) -> Vec<u8> {
201        encode_cbor(self)
202    }
203
204    pub fn decode(data: &[u8]) -> Result<Self, String> {
205        decode_cbor(data, "ReplyContentV1")
206    }
207}
208
209/// Event message content (content_type = 4)
210///
211/// Represents room events like joins and leaves. These are authored by the
212/// member performing the action and count as messages for pruning purposes.
213#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
214pub struct EventContentV1 {
215    pub event_type: u32,
216}
217
218impl EventContentV1 {
219    pub fn join() -> Self {
220        Self {
221            event_type: EVENT_TYPE_JOIN,
222        }
223    }
224
225    pub fn encode(&self) -> Vec<u8> {
226        encode_cbor(self)
227    }
228
229    pub fn decode(data: &[u8]) -> Result<Self, String> {
230        decode_cbor(data, "EventContentV1")
231    }
232}
233
234/// Decoded message content for client-side processing
235#[derive(Clone, PartialEq, Debug)]
236pub enum DecodedContent {
237    /// Text message
238    Text(TextContentV1),
239    /// Action on another message
240    Action(ActionContentV1),
241    /// Reply to another message
242    Reply(ReplyContentV1),
243    /// Room event (join, leave, etc.)
244    Event(EventContentV1),
245    /// Unknown content type - preserved for round-tripping but displayed as placeholder
246    Unknown {
247        content_type: u32,
248        content_version: u32,
249    },
250}
251
252impl DecodedContent {
253    /// Check if this is an action
254    pub fn is_action(&self) -> bool {
255        matches!(self, Self::Action(_))
256    }
257
258    /// Check if this is an event
259    pub fn is_event(&self) -> bool {
260        matches!(self, Self::Event(_))
261    }
262
263    /// Get the target message ID if this is an action
264    pub fn target_id(&self) -> Option<&MessageId> {
265        match self {
266            Self::Action(action) => Some(&action.target),
267            _ => None,
268        }
269    }
270
271    /// Get the text content if this is a text or reply message
272    pub fn as_text(&self) -> Option<&str> {
273        match self {
274            Self::Text(text) => Some(&text.text),
275            Self::Reply(reply) => Some(&reply.text),
276            _ => None,
277        }
278    }
279
280    /// Get a display string for this content
281    pub fn to_display_string(&self) -> String {
282        match self {
283            Self::Text(text) => text.text.clone(),
284            Self::Reply(reply) => reply.text.clone(),
285            Self::Action(action) => match action.action_type {
286                ACTION_TYPE_EDIT => format!("[Edit of message {}]", action.target),
287                ACTION_TYPE_DELETE => format!("[Delete of message {}]", action.target),
288                ACTION_TYPE_REACTION => {
289                    let emoji = action
290                        .reaction_payload()
291                        .map(|p| p.emoji)
292                        .unwrap_or_else(|| "?".to_string());
293                    format!("[Reaction {} to {}]", emoji, action.target)
294                }
295                ACTION_TYPE_REMOVE_REACTION => {
296                    let emoji = action
297                        .reaction_payload()
298                        .map(|p| p.emoji)
299                        .unwrap_or_else(|| "?".to_string());
300                    format!("[Remove reaction {} from {}]", emoji, action.target)
301                }
302                _ => format!(
303                    "[Unknown action type {} on {}]",
304                    action.action_type, action.target
305                ),
306            },
307            Self::Event(event) => match event.event_type {
308                EVENT_TYPE_JOIN => "joined the room".to_string(),
309                _ => format!("[Unknown event type {}]", event.event_type),
310            },
311            Self::Unknown {
312                content_type,
313                content_version,
314            } => format!(
315                "[Unsupported message type {}.{} - please upgrade]",
316                content_type, content_version
317            ),
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use freenet_scaffold::util::fast_hash;
326
327    fn test_message_id() -> MessageId {
328        MessageId(fast_hash(&[1, 2, 3, 4]))
329    }
330
331    #[test]
332    fn test_text_content_roundtrip() {
333        let content = TextContentV1::new("Hello, world!".to_string());
334        let encoded = content.encode();
335        let decoded = TextContentV1::decode(&encoded).unwrap();
336        assert_eq!(content, decoded);
337    }
338
339    #[test]
340    fn test_edit_action_roundtrip() {
341        let action = ActionContentV1::edit(test_message_id(), "New text".to_string());
342        let encoded = action.encode();
343        let decoded = ActionContentV1::decode(&encoded).unwrap();
344        assert_eq!(action, decoded);
345
346        let payload = decoded.edit_payload().unwrap();
347        assert_eq!(payload.new_text, "New text");
348    }
349
350    #[test]
351    fn test_delete_action_roundtrip() {
352        let action = ActionContentV1::delete(test_message_id());
353        let encoded = action.encode();
354        let decoded = ActionContentV1::decode(&encoded).unwrap();
355        assert_eq!(action, decoded);
356        assert_eq!(decoded.action_type, ACTION_TYPE_DELETE);
357    }
358
359    #[test]
360    fn test_reaction_action_roundtrip() {
361        let action = ActionContentV1::reaction(test_message_id(), "👍".to_string());
362        let encoded = action.encode();
363        let decoded = ActionContentV1::decode(&encoded).unwrap();
364        assert_eq!(action, decoded);
365
366        let payload = decoded.reaction_payload().unwrap();
367        assert_eq!(payload.emoji, "👍");
368    }
369
370    #[test]
371    fn test_remove_reaction_action_roundtrip() {
372        let action = ActionContentV1::remove_reaction(test_message_id(), "❤️".to_string());
373        let encoded = action.encode();
374        let decoded = ActionContentV1::decode(&encoded).unwrap();
375        assert_eq!(action, decoded);
376
377        let payload = decoded.reaction_payload().unwrap();
378        assert_eq!(payload.emoji, "❤️");
379    }
380
381    #[test]
382    fn test_reply_content_roundtrip() {
383        let reply = ReplyContentV1::new(
384            "I agree!".to_string(),
385            test_message_id(),
386            "Alice".to_string(),
387            "The original message text here...".to_string(),
388        );
389        let encoded = reply.encode();
390        let decoded = ReplyContentV1::decode(&encoded).unwrap();
391        assert_eq!(reply, decoded);
392
393        // Verify DecodedContent::Reply returns text via as_text()
394        let dc = DecodedContent::Reply(reply.clone());
395        assert_eq!(dc.as_text(), Some("I agree!"));
396        assert_eq!(dc.to_display_string(), "I agree!");
397        assert!(!dc.is_action());
398    }
399
400    #[test]
401    fn test_decoded_content_display() {
402        let text = DecodedContent::Text(TextContentV1::new("Hello".to_string()));
403        assert_eq!(text.to_display_string(), "Hello");
404
405        let unknown = DecodedContent::Unknown {
406            content_type: 99,
407            content_version: 1,
408        };
409        assert!(unknown.to_display_string().contains("Unsupported"));
410    }
411
412    #[test]
413    fn test_event_content_roundtrip() {
414        let event = EventContentV1::join();
415        let encoded = event.encode();
416        let decoded = EventContentV1::decode(&encoded).unwrap();
417        assert_eq!(event, decoded);
418        assert_eq!(decoded.event_type, EVENT_TYPE_JOIN);
419
420        let dc = DecodedContent::Event(event);
421        assert!(dc.is_event());
422        assert!(!dc.is_action());
423        assert_eq!(dc.to_display_string(), "joined the room");
424    }
425
426    #[test]
427    fn test_join_event_message_body() {
428        let body = crate::room_state::message::RoomMessageBody::join_event();
429        assert!(body.is_event());
430        assert!(!body.is_action());
431        let decoded = body.decode_content().unwrap();
432        assert!(matches!(decoded, DecodedContent::Event(_)));
433    }
434}