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 CONTENT_TYPE_EVENT: u32 = 4;
21pub const TEXT_CONTENT_VERSION: u32 = 1;
25
26pub const ACTION_CONTENT_VERSION: u32 = 1;
28
29pub const REPLY_CONTENT_VERSION: u32 = 1;
31
32pub const EVENT_CONTENT_VERSION: u32 = 1;
34
35pub const EVENT_TYPE_JOIN: u32 = 1;
37pub 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#[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 pub fn encode(&self) -> Vec<u8> {
59 encode_cbor(self)
60 }
61
62 pub fn decode(data: &[u8]) -> Result<Self, String> {
64 decode_cbor(data, "TextContentV1")
65 }
66}
67
68fn 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
75fn 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#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
82pub struct ActionContentV1 {
83 pub action_type: u32,
85 pub target: MessageId,
87 pub payload: Vec<u8>,
89}
90
91impl ActionContentV1 {
92 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 pub fn delete(target: MessageId) -> Self {
103 Self {
104 action_type: ACTION_TYPE_DELETE,
105 target,
106 payload: Vec::new(),
107 }
108 }
109
110 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 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 pub fn encode(&self) -> Vec<u8> {
130 encode_cbor(self)
131 }
132
133 pub fn decode(data: &[u8]) -> Result<Self, String> {
135 decode_cbor(data, "ActionContentV1")
136 }
137
138 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 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#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
161pub struct EditPayload {
162 pub new_text: String,
163}
164
165#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
167pub struct ReactionPayload {
168 pub emoji: String,
169}
170
171#[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 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#[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#[derive(Clone, PartialEq, Debug)]
236pub enum DecodedContent {
237 Text(TextContentV1),
239 Action(ActionContentV1),
241 Reply(ReplyContentV1),
243 Event(EventContentV1),
245 Unknown {
247 content_type: u32,
248 content_version: u32,
249 },
250}
251
252impl DecodedContent {
253 pub fn is_action(&self) -> bool {
255 matches!(self, Self::Action(_))
256 }
257
258 pub fn is_event(&self) -> bool {
260 matches!(self, Self::Event(_))
261 }
262
263 pub fn target_id(&self) -> Option<&MessageId> {
265 match self {
266 Self::Action(action) => Some(&action.target),
267 _ => None,
268 }
269 }
270
271 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 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 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}