Skip to main content

xmtp_content_types/
reaction.rs

1use std::collections::HashMap;
2
3use crate::{CodecError, ContentCodec};
4use prost::Message;
5
6use serde::{Deserialize, Serialize};
7use xmtp_proto::xmtp::mls::message_contents::{
8    ContentTypeId, EncodedContent,
9    content_types::{ReactionAction, ReactionSchema, ReactionV2},
10};
11
12pub struct ReactionCodec {}
13
14/// Legacy content type id at <https://github.com/xmtp/xmtp-js/blob/main/content-types/content-type-reaction/src/Reaction.ts>
15impl ReactionCodec {
16    const AUTHORITY_ID: &'static str = "xmtp.org";
17    pub const TYPE_ID: &'static str = "reaction";
18    pub const MAJOR_VERSION: u32 = 2;
19    pub const MINOR_VERSION: u32 = 0;
20}
21
22impl ReactionCodec {
23    fn fallback(content: &ReactionV2) -> Option<String> {
24        let (action, prepos) = if content.action == ReactionAction::Removed as i32 {
25            ("Removed", "from")
26        } else {
27            ("Reacted with", "to")
28        };
29        Some(format!(
30            "{} \"{}\" {} an earlier message",
31            action, content.content, prepos
32        ))
33    }
34}
35
36impl ContentCodec<ReactionV2> for ReactionCodec {
37    fn content_type() -> ContentTypeId {
38        ContentTypeId {
39            authority_id: ReactionCodec::AUTHORITY_ID.to_string(),
40            type_id: ReactionCodec::TYPE_ID.to_string(),
41            version_major: 2,
42            version_minor: 0,
43        }
44    }
45
46    fn encode(data: ReactionV2) -> Result<EncodedContent, CodecError> {
47        let mut buf = Vec::new();
48        data.encode(&mut buf)
49            .map_err(|e| CodecError::Encode(e.to_string()))?;
50
51        Ok(EncodedContent {
52            r#type: Some(ReactionCodec::content_type()),
53            parameters: HashMap::new(),
54            fallback: Self::fallback(&data),
55            compression: None,
56            content: buf,
57        })
58    }
59
60    fn decode(content: EncodedContent) -> Result<ReactionV2, CodecError> {
61        let decoded = ReactionV2::decode(content.content.as_slice())
62            .map_err(|e| CodecError::Decode(e.to_string()))?;
63
64        Ok(decoded)
65    }
66
67    fn should_push() -> bool {
68        false
69    }
70}
71
72// JSON format for legacy reaction is defined here: https://github.com/xmtp/xmtp-js/blob/main/content-types/content-type-reaction/src/Reaction.ts
73#[derive(Debug, Serialize, Deserialize)]
74pub struct LegacyReaction {
75    /// The action of the reaction ("added" or "removed")
76    pub action: String,
77    /// The message ID for the message that is being reacted to
78    pub reference: String,
79    /// The inbox ID of the user who sent the message that is being reacted to
80    #[serde(rename = "referenceInboxId", skip_serializing_if = "Option::is_none")]
81    pub reference_inbox_id: Option<String>,
82    /// The schema of the content ("unicode", "shortcode", or "custom")
83    pub schema: String,
84    /// The content of the reaction
85    pub content: String,
86}
87
88impl From<LegacyReaction> for ReactionV2 {
89    fn from(legacy: LegacyReaction) -> Self {
90        let action = match legacy.action.as_str() {
91            "added" => ReactionAction::Added as i32,
92            "removed" => ReactionAction::Removed as i32,
93            _ => ReactionAction::Unspecified as i32,
94        };
95
96        let schema = match legacy.schema.as_str() {
97            "unicode" => ReactionSchema::Unicode as i32,
98            "shortcode" => ReactionSchema::Shortcode as i32,
99            "custom" => ReactionSchema::Custom as i32,
100            _ => ReactionSchema::Unspecified as i32,
101        };
102
103        ReactionV2 {
104            reference: legacy.reference,
105            reference_inbox_id: legacy.reference_inbox_id.unwrap_or_default(),
106            action,
107            content: legacy.content,
108            schema,
109        }
110    }
111}
112
113impl LegacyReaction {
114    pub fn decode(content: &[u8]) -> Option<LegacyReaction> {
115        // Try to decode the content as UTF-8 string first
116        if let Ok(decoded_content) = String::from_utf8(content.to_vec()) {
117            tracing::info!(
118                "attempting legacy json deserialization: {}",
119                decoded_content
120            );
121            // Try parsing as canonical JSON format
122            if let Ok(reaction) = serde_json::from_str::<LegacyReaction>(&decoded_content) {
123                return Some(reaction);
124            }
125            tracing::error!("legacy json deserialization failed");
126        } else {
127            tracing::error!("utf-8 deserialization failed");
128        }
129        None
130    }
131}
132
133pub struct LegacyReactionCodec {}
134
135/// Legacy content type id at <https://github.com/xmtp/xmtp-js/blob/main/content-types/content-type-reaction/src/Reaction.ts>
136impl LegacyReactionCodec {
137    const AUTHORITY_ID: &'static str = "xmtp.org";
138    pub const TYPE_ID: &'static str = "reaction";
139    pub const MAJOR_VERSION: u32 = 1;
140    pub const MINOR_VERSION: u32 = 0;
141}
142
143impl LegacyReactionCodec {
144    fn fallback(content: &LegacyReaction) -> Option<String> {
145        let (action, prepos) = if content.action == "removed" {
146            ("Removed", "from")
147        } else {
148            ("Reacted with", "to")
149        };
150        Some(format!(
151            "{} \"{}\" {} an earlier message",
152            action, content.content, prepos
153        ))
154    }
155}
156
157impl ContentCodec<LegacyReaction> for LegacyReactionCodec {
158    fn content_type() -> ContentTypeId {
159        ContentTypeId {
160            authority_id: LegacyReactionCodec::AUTHORITY_ID.to_string(),
161            type_id: LegacyReactionCodec::TYPE_ID.to_string(),
162            version_major: LegacyReactionCodec::MAJOR_VERSION,
163            version_minor: LegacyReactionCodec::MINOR_VERSION,
164        }
165    }
166
167    fn encode(data: LegacyReaction) -> Result<EncodedContent, CodecError> {
168        let json = serde_json::to_string(&data).map_err(|e| CodecError::Encode(e.to_string()))?;
169        Ok(EncodedContent {
170            r#type: Some(LegacyReactionCodec::content_type()),
171            parameters: std::collections::HashMap::new(),
172            fallback: Self::fallback(&data),
173            compression: None,
174            content: json.into_bytes(),
175        })
176    }
177
178    fn decode(content: EncodedContent) -> Result<LegacyReaction, CodecError> {
179        let decoded = serde_json::from_slice::<LegacyReaction>(&content.content)
180            .map_err(|e| CodecError::Decode(e.to_string()))?;
181
182        Ok(decoded)
183    }
184
185    fn should_push() -> bool {
186        false
187    }
188}
189
190#[cfg(test)]
191pub(crate) mod tests {
192    use xmtp_proto::xmtp::mls::message_contents::content_types::{
193        ReactionAction, ReactionSchema, ReactionV2,
194    };
195
196    use serde_json::json;
197    use xmtp_common::rand_string;
198
199    use super::*;
200
201    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
202    #[cfg_attr(not(target_arch = "wasm32"), test)]
203    fn test_encode_decode() {
204        let new_reaction_data = ReactionV2 {
205            reference: rand_string::<24>(),
206            reference_inbox_id: rand_string::<24>(),
207            action: ReactionAction::Added as i32,
208            content: "👍".to_string(),
209            schema: ReactionSchema::Unicode as i32,
210        };
211
212        let encoded = ReactionCodec::encode(new_reaction_data).unwrap();
213        assert_eq!(encoded.clone().r#type.unwrap().type_id, "reaction");
214        assert!(!encoded.content.is_empty());
215
216        let decoded = ReactionCodec::decode(encoded).unwrap();
217        assert_eq!(decoded.action, ReactionAction::Added as i32);
218        assert_eq!(decoded.content, "👍".to_string());
219        assert_eq!(decoded.schema, ReactionSchema::Unicode as i32);
220    }
221
222    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
223    #[cfg_attr(not(target_arch = "wasm32"), test)]
224    fn test_legacy_reaction_deserialization() {
225        let reference = "0123456789abcdef";
226        let legacy_json = json!({
227            "reference": reference,
228            "referenceInboxId": "some_inbox_id",
229            "action": "added",
230            "content": "👍",
231            "schema": "unicode"
232        });
233
234        let content = legacy_json.to_string().into_bytes();
235        let decoded_reference: String = LegacyReaction::decode(&content).unwrap().reference;
236
237        assert_eq!(decoded_reference, reference);
238
239        // Test invalid JSON
240        let invalid_content = b"invalid json";
241        let failed_decode = LegacyReaction::decode(invalid_content);
242        assert!(failed_decode.is_none());
243    }
244}