1use crate::room_state::message::MessageId;
14use serde::{Deserialize, Serialize};
15
16pub const CONTENT_TYPE_TEXT: u32 = 1;
18pub const CONTENT_TYPE_ACTION: u32 = 2;
19pub const CONTENT_TYPE_REPLY: u32 = 3;
20pub const TEXT_CONTENT_VERSION: u32 = 1;
24
25pub const ACTION_CONTENT_VERSION: u32 = 1;
27
28pub const REPLY_CONTENT_VERSION: u32 = 1;
30
31pub 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#[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 pub fn encode(&self) -> Vec<u8> {
51 encode_cbor(self)
52 }
53
54 pub fn decode(data: &[u8]) -> Result<Self, String> {
56 decode_cbor(data, "TextContentV1")
57 }
58}
59
60fn 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
67fn 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#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
74pub struct ActionContentV1 {
75 pub action_type: u32,
77 pub target: MessageId,
79 pub payload: Vec<u8>,
81}
82
83impl ActionContentV1 {
84 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 pub fn delete(target: MessageId) -> Self {
95 Self {
96 action_type: ACTION_TYPE_DELETE,
97 target,
98 payload: Vec::new(),
99 }
100 }
101
102 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 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 pub fn encode(&self) -> Vec<u8> {
122 encode_cbor(self)
123 }
124
125 pub fn decode(data: &[u8]) -> Result<Self, String> {
127 decode_cbor(data, "ActionContentV1")
128 }
129
130 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 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#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
153pub struct EditPayload {
154 pub new_text: String,
155}
156
157#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
159pub struct ReactionPayload {
160 pub emoji: String,
161}
162
163#[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 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#[derive(Clone, PartialEq, Debug)]
203pub enum DecodedContent {
204 Text(TextContentV1),
206 Action(ActionContentV1),
208 Reply(ReplyContentV1),
210 Unknown {
212 content_type: u32,
213 content_version: u32,
214 },
215}
216
217impl DecodedContent {
218 pub fn is_action(&self) -> bool {
220 matches!(self, Self::Action(_))
221 }
222
223 pub fn target_id(&self) -> Option<&MessageId> {
225 match self {
226 Self::Action(action) => Some(&action.target),
227 _ => None,
228 }
229 }
230
231 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 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 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}