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