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 TEXT_CONTENT_VERSION: u32 = 1;
23
24pub const ACTION_CONTENT_VERSION: u32 = 1;
26
27pub 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#[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 pub fn encode(&self) -> Vec<u8> {
47 encode_cbor(self)
48 }
49
50 pub fn decode(data: &[u8]) -> Result<Self, String> {
52 decode_cbor(data, "TextContentV1")
53 }
54}
55
56fn 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
63fn 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#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
70pub struct ActionContentV1 {
71 pub action_type: u32,
73 pub target: MessageId,
75 pub payload: Vec<u8>,
77}
78
79impl ActionContentV1 {
80 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 pub fn delete(target: MessageId) -> Self {
91 Self {
92 action_type: ACTION_TYPE_DELETE,
93 target,
94 payload: Vec::new(),
95 }
96 }
97
98 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 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 pub fn encode(&self) -> Vec<u8> {
118 encode_cbor(self)
119 }
120
121 pub fn decode(data: &[u8]) -> Result<Self, String> {
123 decode_cbor(data, "ActionContentV1")
124 }
125
126 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 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#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
149pub struct EditPayload {
150 pub new_text: String,
151}
152
153#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
155pub struct ReactionPayload {
156 pub emoji: String,
157}
158
159#[derive(Clone, PartialEq, Debug)]
161pub enum DecodedContent {
162 Text(TextContentV1),
164 Action(ActionContentV1),
166 Unknown {
168 content_type: u32,
169 content_version: u32,
170 },
171}
172
173impl DecodedContent {
174 pub fn is_action(&self) -> bool {
176 matches!(self, Self::Action(_))
177 }
178
179 pub fn target_id(&self) -> Option<&MessageId> {
181 match self {
182 Self::Action(action) => Some(&action.target),
183 _ => None,
184 }
185 }
186
187 pub fn as_text(&self) -> Option<&str> {
189 match self {
190 Self::Text(text) => Some(&text.text),
191 _ => None,
192 }
193 }
194
195 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}