xmtp_content_types/
reply.rs1use std::collections::HashMap;
2
3use crate::{CodecError, ContentCodec, text::TextCodec, utils::get_param_or_default};
4use prost::Message;
5use serde::{Deserialize, Serialize};
6
7use xmtp_proto::xmtp::mls::message_contents::{ContentTypeId, EncodedContent};
8
9pub struct ReplyCodec;
10
11impl ReplyCodec {
13 const AUTHORITY_ID: &str = "xmtp.org";
14 pub const TYPE_ID: &str = "reply";
15 pub const MAJOR_VERSION: u32 = 1;
16 pub const MINOR_VERSION: u32 = 0;
17}
18
19impl ReplyCodec {
20 fn fallback(content: &Reply) -> Option<String> {
21 let is_text = content
22 .content
23 .r#type
24 .as_ref()
25 .is_some_and(|t| t.type_id == TextCodec::TYPE_ID);
26
27 if is_text && let Ok(text) = TextCodec::decode(content.content.clone()) {
28 return Some(format!("Replied with \"{text}\" to an earlier message"));
29 }
30
31 Some("Replied to an earlier message".to_string())
32 }
33}
34
35impl ContentCodec<Reply> for ReplyCodec {
36 fn content_type() -> ContentTypeId {
37 ContentTypeId {
38 authority_id: Self::AUTHORITY_ID.to_string(),
39 type_id: Self::TYPE_ID.to_string(),
40 version_major: Self::MAJOR_VERSION,
41 version_minor: Self::MINOR_VERSION,
42 }
43 }
44
45 fn encode(data: Reply) -> Result<EncodedContent, CodecError> {
46 let inner_type = data
47 .content
48 .r#type
49 .as_ref()
50 .filter(|type_id| !type_id.authority_id.is_empty() && !type_id.type_id.is_empty())
51 .ok_or(CodecError::InvalidContentType)?;
52 let fallback = Self::fallback(&data);
53 let mut parameters = HashMap::new();
55 parameters.insert("reference".to_string(), data.reference);
56 parameters.insert(
57 "contentType".to_string(),
58 format!(
59 "{}/{}:{}.{}",
60 inner_type.authority_id,
61 inner_type.type_id,
62 inner_type.version_major,
63 inner_type.version_minor
64 ),
65 );
66 if let Some(reference_inbox_id) = data.reference_inbox_id {
67 parameters.insert("referenceInboxId".to_string(), reference_inbox_id);
68 }
69
70 let content_bytes = data.content.encode_to_vec();
71
72 Ok(EncodedContent {
73 r#type: Some(Self::content_type()),
74 parameters,
75 content: content_bytes,
76 fallback,
77 ..Default::default()
78 })
79 }
80
81 fn decode(encoded: EncodedContent) -> Result<Reply, CodecError> {
82 let inner_content = EncodedContent::decode(encoded.content.as_slice())
83 .map_err(|e| CodecError::Decode(e.to_string()))?;
84
85 let reference = get_param_or_default(&encoded.parameters, "reference").to_string();
86
87 let reference_inbox_id = encoded
88 .parameters
89 .get("referenceInboxId")
90 .map(|id| id.to_string());
91
92 Ok(Reply {
93 reference,
94 reference_inbox_id,
95 content: inner_content,
96 })
97 }
98
99 fn should_push() -> bool {
100 true
101 }
102}
103
104#[derive(Debug, Serialize, Deserialize, Clone)]
106pub struct Reply {
107 pub reference: String,
109
110 #[serde(rename = "referenceInboxId", skip_serializing_if = "Option::is_none")]
112 pub reference_inbox_id: Option<String>,
113
114 pub content: EncodedContent,
116}
117
118#[cfg(test)]
119pub(crate) mod tests {
120 use crate::text::TextCodec;
121
122 use super::*;
123
124 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
125 #[cfg_attr(not(target_arch = "wasm32"), test)]
126 fn test_encode_decode_reply() {
128 let text_message = TextCodec::encode("This is a reply".to_string()).unwrap();
129 let reply = Reply {
130 reference: "msg_123".to_string(),
131 reference_inbox_id: Some("inbox_456".to_string()),
132 content: text_message,
133 };
134
135 let encoded = ReplyCodec::encode(reply.clone()).unwrap();
136 let decoded = ReplyCodec::decode(encoded).unwrap();
137
138 assert_eq!(decoded.reference, reply.reference);
139 assert_eq!(decoded.reference_inbox_id, reply.reference_inbox_id);
140 assert_eq!(decoded.content, reply.content);
141 }
142
143 #[xmtp_common::test(unwrap_try = true)]
145 fn reply_encoding_requires_a_complete_nested_type() {
146 let standard = TextCodec::encode("reply".into())?;
147 let make_reply = |content| Reply {
148 reference: "0102".into(),
149 reference_inbox_id: None,
150 content,
151 };
152 let encoded = ReplyCodec::encode(make_reply(standard.clone()))?;
153 assert_eq!(encoded.parameters["contentType"], "xmtp.org/text:1.0");
154
155 let mut unknown = standard.clone();
156 unknown.r#type = Some(ContentTypeId {
157 authority_id: "custom.example".into(),
158 type_id: "drawing".into(),
159 version_major: 9,
160 version_minor: 2,
161 });
162 let encoded = ReplyCodec::encode(make_reply(unknown))?;
163 assert_eq!(
164 encoded.parameters["contentType"],
165 "custom.example/drawing:9.2"
166 );
167
168 let mut missing = standard.clone();
169 missing.r#type = None;
170 let mut empty_authority = standard.clone();
171 empty_authority
172 .r#type
173 .as_mut()
174 .unwrap()
175 .authority_id
176 .clear();
177 let mut empty_type = standard;
178 empty_type.r#type.as_mut().unwrap().type_id.clear();
179 for content in [missing, empty_authority, empty_type] {
180 assert!(matches!(
181 ReplyCodec::encode(make_reply(content)),
182 Err(CodecError::InvalidContentType)
183 ));
184 }
185 }
186}