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;
20// Future: CONTENT_TYPE_BLOB = 4, CONTENT_TYPE_POLL = 5, etc.
21
22/// Current version for text content
23pub const TEXT_CONTENT_VERSION: u32 = 1;
24
25/// Current version for action content
26pub const ACTION_CONTENT_VERSION: u32 = 1;
27
28/// Current version for reply content
29pub const REPLY_CONTENT_VERSION: u32 = 1;
30
31/// Action type constants
32pub const ACTION_TYPE_EDIT: u32 = 1;
33pub const ACTION_TYPE_DELETE: u32 = 2;
34pub const ACTION_TYPE_REACTION: u32 = 3;
35pub const ACTION_TYPE_REMOVE_REACTION: u32 = 4;
36// Future: ACTION_TYPE_PIN = 5, ACTION_TYPE_REPLY = 6, etc.
37
38/// Text message content (content_type = 1)
39#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
40pub struct TextContentV1 {
41    pub text: String,
42}
43
44impl TextContentV1 {
45    pub fn new(text: String) -> Self {
46        Self { text }
47    }
48
49    /// Encode to CBOR bytes
50    pub fn encode(&self) -> Vec<u8> {
51        encode_cbor(self)
52    }
53
54    /// Decode from CBOR bytes
55    pub fn decode(data: &[u8]) -> Result<Self, String> {
56        decode_cbor(data, "TextContentV1")
57    }
58}
59
60/// Encode a value to CBOR bytes
61fn encode_cbor<T: Serialize>(value: &T) -> Vec<u8> {
62    let mut data = Vec::new();
63    ciborium::into_writer(value, &mut data).expect("CBOR serialization should not fail");
64    data
65}
66
67/// Decode a value from CBOR bytes
68fn decode_cbor<T: serde::de::DeserializeOwned>(data: &[u8], type_name: &str) -> Result<T, String> {
69    ciborium::from_reader(data).map_err(|e| format!("Failed to decode {}: {}", type_name, e))
70}
71
72/// Action message content (content_type = 2)
73#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
74pub struct ActionContentV1 {
75    /// Type of action (ACTION_TYPE_* constants)
76    pub action_type: u32,
77    /// Target message ID for the action
78    pub target: MessageId,
79    /// Action-specific payload (CBOR-encoded)
80    pub payload: Vec<u8>,
81}
82
83impl ActionContentV1 {
84    /// Create an edit action
85    pub fn edit(target: MessageId, new_text: String) -> Self {
86        Self {
87            action_type: ACTION_TYPE_EDIT,
88            target,
89            payload: encode_cbor(&EditPayload { new_text }),
90        }
91    }
92
93    /// Create a delete action
94    pub fn delete(target: MessageId) -> Self {
95        Self {
96            action_type: ACTION_TYPE_DELETE,
97            target,
98            payload: Vec::new(),
99        }
100    }
101
102    /// Create a reaction action
103    pub fn reaction(target: MessageId, emoji: String) -> Self {
104        Self {
105            action_type: ACTION_TYPE_REACTION,
106            target,
107            payload: encode_cbor(&ReactionPayload { emoji }),
108        }
109    }
110
111    /// Create a remove reaction action
112    pub fn remove_reaction(target: MessageId, emoji: String) -> Self {
113        Self {
114            action_type: ACTION_TYPE_REMOVE_REACTION,
115            target,
116            payload: encode_cbor(&ReactionPayload { emoji }),
117        }
118    }
119
120    /// Encode to CBOR bytes
121    pub fn encode(&self) -> Vec<u8> {
122        encode_cbor(self)
123    }
124
125    /// Decode from CBOR bytes
126    pub fn decode(data: &[u8]) -> Result<Self, String> {
127        decode_cbor(data, "ActionContentV1")
128    }
129
130    /// Get the edit payload if this is an edit action
131    pub fn edit_payload(&self) -> Option<EditPayload> {
132        if self.action_type == ACTION_TYPE_EDIT {
133            ciborium::from_reader(&self.payload[..]).ok()
134        } else {
135            None
136        }
137    }
138
139    /// Get the reaction payload if this is a reaction or remove_reaction action
140    pub fn reaction_payload(&self) -> Option<ReactionPayload> {
141        if self.action_type == ACTION_TYPE_REACTION
142            || self.action_type == ACTION_TYPE_REMOVE_REACTION
143        {
144            ciborium::from_reader(&self.payload[..]).ok()
145        } else {
146            None
147        }
148    }
149}
150
151/// Payload for edit actions
152#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
153pub struct EditPayload {
154    pub new_text: String,
155}
156
157/// Payload for reaction actions
158#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
159pub struct ReactionPayload {
160    pub emoji: String,
161}
162
163/// Reply message content (content_type = 3)
164///
165/// A reply references a target message and includes a snapshot of the target's
166/// author name and content preview so that the reply remains meaningful even if
167/// the target message is later deleted or scrolled out of the recent window.
168#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
169pub struct ReplyContentV1 {
170    pub text: String,
171    pub target_message_id: MessageId,
172    pub target_author_name: String,
173    /// Snapshot of the target message content (~100 chars)
174    pub target_content_preview: String,
175}
176
177impl ReplyContentV1 {
178    pub fn new(
179        text: String,
180        target_message_id: MessageId,
181        target_author_name: String,
182        target_content_preview: String,
183    ) -> Self {
184        Self {
185            text,
186            target_message_id,
187            target_author_name,
188            target_content_preview,
189        }
190    }
191
192    pub fn encode(&self) -> Vec<u8> {
193        encode_cbor(self)
194    }
195
196    pub fn decode(data: &[u8]) -> Result<Self, String> {
197        decode_cbor(data, "ReplyContentV1")
198    }
199}
200
201/// Decoded message content for client-side processing
202#[derive(Clone, PartialEq, Debug)]
203pub enum DecodedContent {
204    /// Text message
205    Text(TextContentV1),
206    /// Action on another message
207    Action(ActionContentV1),
208    /// Reply to another message
209    Reply(ReplyContentV1),
210    /// Unknown content type - preserved for round-tripping but displayed as placeholder
211    Unknown {
212        content_type: u32,
213        content_version: u32,
214    },
215}
216
217impl DecodedContent {
218    /// Check if this is an action
219    pub fn is_action(&self) -> bool {
220        matches!(self, Self::Action(_))
221    }
222
223    /// Get the target message ID if this is an action
224    pub fn target_id(&self) -> Option<&MessageId> {
225        match self {
226            Self::Action(action) => Some(&action.target),
227            _ => None,
228        }
229    }
230
231    /// Get the text content if this is a text or reply message
232    pub fn as_text(&self) -> Option<&str> {
233        match self {
234            Self::Text(text) => Some(&text.text),
235            Self::Reply(reply) => Some(&reply.text),
236            _ => None,
237        }
238    }
239
240    /// Get a display string for this content
241    pub fn to_display_string(&self) -> String {
242        match self {
243            Self::Text(text) => text.text.clone(),
244            Self::Reply(reply) => reply.text.clone(),
245            Self::Action(action) => match action.action_type {
246                ACTION_TYPE_EDIT => format!("[Edit of message {}]", action.target),
247                ACTION_TYPE_DELETE => format!("[Delete of message {}]", action.target),
248                ACTION_TYPE_REACTION => {
249                    let emoji = action
250                        .reaction_payload()
251                        .map(|p| p.emoji)
252                        .unwrap_or_else(|| "?".to_string());
253                    format!("[Reaction {} to {}]", emoji, action.target)
254                }
255                ACTION_TYPE_REMOVE_REACTION => {
256                    let emoji = action
257                        .reaction_payload()
258                        .map(|p| p.emoji)
259                        .unwrap_or_else(|| "?".to_string());
260                    format!("[Remove reaction {} from {}]", emoji, action.target)
261                }
262                _ => format!(
263                    "[Unknown action type {} on {}]",
264                    action.action_type, action.target
265                ),
266            },
267            Self::Unknown {
268                content_type,
269                content_version,
270            } => format!(
271                "[Unsupported message type {}.{} - please upgrade]",
272                content_type, content_version
273            ),
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use freenet_scaffold::util::fast_hash;
282
283    fn test_message_id() -> MessageId {
284        MessageId(fast_hash(&[1, 2, 3, 4]))
285    }
286
287    #[test]
288    fn test_text_content_roundtrip() {
289        let content = TextContentV1::new("Hello, world!".to_string());
290        let encoded = content.encode();
291        let decoded = TextContentV1::decode(&encoded).unwrap();
292        assert_eq!(content, decoded);
293    }
294
295    #[test]
296    fn test_edit_action_roundtrip() {
297        let action = ActionContentV1::edit(test_message_id(), "New text".to_string());
298        let encoded = action.encode();
299        let decoded = ActionContentV1::decode(&encoded).unwrap();
300        assert_eq!(action, decoded);
301
302        let payload = decoded.edit_payload().unwrap();
303        assert_eq!(payload.new_text, "New text");
304    }
305
306    #[test]
307    fn test_delete_action_roundtrip() {
308        let action = ActionContentV1::delete(test_message_id());
309        let encoded = action.encode();
310        let decoded = ActionContentV1::decode(&encoded).unwrap();
311        assert_eq!(action, decoded);
312        assert_eq!(decoded.action_type, ACTION_TYPE_DELETE);
313    }
314
315    #[test]
316    fn test_reaction_action_roundtrip() {
317        let action = ActionContentV1::reaction(test_message_id(), "👍".to_string());
318        let encoded = action.encode();
319        let decoded = ActionContentV1::decode(&encoded).unwrap();
320        assert_eq!(action, decoded);
321
322        let payload = decoded.reaction_payload().unwrap();
323        assert_eq!(payload.emoji, "👍");
324    }
325
326    #[test]
327    fn test_remove_reaction_action_roundtrip() {
328        let action = ActionContentV1::remove_reaction(test_message_id(), "❤️".to_string());
329        let encoded = action.encode();
330        let decoded = ActionContentV1::decode(&encoded).unwrap();
331        assert_eq!(action, decoded);
332
333        let payload = decoded.reaction_payload().unwrap();
334        assert_eq!(payload.emoji, "❤️");
335    }
336
337    #[test]
338    fn test_reply_content_roundtrip() {
339        let reply = ReplyContentV1::new(
340            "I agree!".to_string(),
341            test_message_id(),
342            "Alice".to_string(),
343            "The original message text here...".to_string(),
344        );
345        let encoded = reply.encode();
346        let decoded = ReplyContentV1::decode(&encoded).unwrap();
347        assert_eq!(reply, decoded);
348
349        // Verify DecodedContent::Reply returns text via as_text()
350        let dc = DecodedContent::Reply(reply.clone());
351        assert_eq!(dc.as_text(), Some("I agree!"));
352        assert_eq!(dc.to_display_string(), "I agree!");
353        assert!(!dc.is_action());
354    }
355
356    #[test]
357    fn test_decoded_content_display() {
358        let text = DecodedContent::Text(TextContentV1::new("Hello".to_string()));
359        assert_eq!(text.to_display_string(), "Hello");
360
361        let unknown = DecodedContent::Unknown {
362            content_type: 99,
363            content_version: 1,
364        };
365        assert!(unknown.to_display_string().contains("Unsupported"));
366    }
367}