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
14impl 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#[derive(Debug, Serialize, Deserialize)]
74pub struct LegacyReaction {
75 pub action: String,
77 pub reference: String,
79 #[serde(rename = "referenceInboxId", skip_serializing_if = "Option::is_none")]
81 pub reference_inbox_id: Option<String>,
82 pub schema: String,
84 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 if let Ok(decoded_content) = String::from_utf8(content.to_vec()) {
117 if let Ok(reaction) = serde_json::from_str::<LegacyReaction>(&decoded_content) {
119 return Some(reaction);
120 }
121 tracing::error!("legacy json deserialization failed");
122 } else {
123 tracing::error!("utf-8 deserialization failed");
124 }
125 None
126 }
127}
128
129pub struct LegacyReactionCodec {}
130
131impl LegacyReactionCodec {
133 const AUTHORITY_ID: &'static str = "xmtp.org";
134 pub const TYPE_ID: &'static str = "reaction";
135 pub const MAJOR_VERSION: u32 = 1;
136 pub const MINOR_VERSION: u32 = 0;
137}
138
139impl LegacyReactionCodec {
140 fn fallback(content: &LegacyReaction) -> Option<String> {
141 let (action, prepos) = if content.action == "removed" {
142 ("Removed", "from")
143 } else {
144 ("Reacted with", "to")
145 };
146 Some(format!(
147 "{} \"{}\" {} an earlier message",
148 action, content.content, prepos
149 ))
150 }
151}
152
153impl ContentCodec<LegacyReaction> for LegacyReactionCodec {
154 fn content_type() -> ContentTypeId {
155 ContentTypeId {
156 authority_id: LegacyReactionCodec::AUTHORITY_ID.to_string(),
157 type_id: LegacyReactionCodec::TYPE_ID.to_string(),
158 version_major: LegacyReactionCodec::MAJOR_VERSION,
159 version_minor: LegacyReactionCodec::MINOR_VERSION,
160 }
161 }
162
163 fn encode(data: LegacyReaction) -> Result<EncodedContent, CodecError> {
164 let json = serde_json::to_string(&data).map_err(|e| CodecError::Encode(e.to_string()))?;
165 Ok(EncodedContent {
166 r#type: Some(LegacyReactionCodec::content_type()),
167 parameters: std::collections::HashMap::new(),
168 fallback: Self::fallback(&data),
169 compression: None,
170 content: json.into_bytes(),
171 })
172 }
173
174 fn decode(content: EncodedContent) -> Result<LegacyReaction, CodecError> {
175 let decoded = serde_json::from_slice::<LegacyReaction>(&content.content)
176 .map_err(|e| CodecError::Decode(e.to_string()))?;
177
178 Ok(decoded)
179 }
180
181 fn should_push() -> bool {
182 false
183 }
184}
185
186#[cfg(test)]
187pub(crate) mod tests {
188 use xmtp_proto::xmtp::mls::message_contents::content_types::{
189 ReactionAction, ReactionSchema, ReactionV2,
190 };
191
192 use serde_json::json;
193 use xmtp_common::rand_string;
194
195 use super::*;
196
197 #[cfg(not(target_arch = "wasm32"))]
198 #[xmtp_common::test(unwrap_try = true)]
199 async fn legacy_reaction_does_not_log_content_at_info() {
200 use xmtp_logging::{Level, test_logging::LogCapture};
201
202 let marker = "private-reaction-content-marker";
203 let content = json!({
204 "reference": "message-id",
205 "action": "added",
206 "content": marker,
207 "schema": "custom"
208 })
209 .to_string();
210 let capture = LogCapture::new(Level::Info);
211 tracing::dispatcher::with_default(&capture.dispatch(), || {
212 tracing::info!(target: "xmtp_content_types::reaction", "capture active");
213 assert!(LegacyReaction::decode(content.as_bytes()).is_some());
214 });
215 let output = capture.output();
216 assert!(output.contains("capture active"));
217 assert!(
218 !output.contains(marker),
219 "reaction content reached Info logs"
220 );
221 }
222
223 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
224 #[cfg_attr(not(target_arch = "wasm32"), test)]
225 fn test_encode_decode() {
227 let new_reaction_data = ReactionV2 {
228 reference: rand_string::<24>(),
229 reference_inbox_id: rand_string::<24>(),
230 action: ReactionAction::Added as i32,
231 content: "👍".to_string(),
232 schema: ReactionSchema::Unicode as i32,
233 };
234
235 let encoded = ReactionCodec::encode(new_reaction_data).unwrap();
236 assert_eq!(encoded.clone().r#type.unwrap().type_id, "reaction");
237 assert!(!encoded.content.is_empty());
238
239 let decoded = ReactionCodec::decode(encoded).unwrap();
240 assert_eq!(decoded.action, ReactionAction::Added as i32);
241 assert_eq!(decoded.content, "👍".to_string());
242 assert_eq!(decoded.schema, ReactionSchema::Unicode as i32);
243 }
244
245 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
246 #[cfg_attr(not(target_arch = "wasm32"), test)]
247 fn test_legacy_reaction_deserialization() {
248 let reference = "0123456789abcdef";
249 let legacy_json = json!({
250 "reference": reference,
251 "referenceInboxId": "some_inbox_id",
252 "action": "added",
253 "content": "👍",
254 "schema": "unicode"
255 });
256
257 let content = legacy_json.to_string().into_bytes();
258 let decoded_reference: String = LegacyReaction::decode(&content).unwrap().reference;
259
260 assert_eq!(decoded_reference, reference);
261
262 let invalid_content = b"invalid json";
264 let failed_decode = LegacyReaction::decode(invalid_content);
265 assert!(failed_decode.is_none());
266 }
267}