Skip to main content

xmtp_content_types/
reply.rs

1use 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
11/// Legacy content type id at <https://github.com/xmtp/xmtp-js/blob/main/content-types/content-type-reply/src/Reply.ts>
12impl 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        // Set the reference and reference inbox ID as parameters.
54        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/// The main content type for replies
105#[derive(Debug, Serialize, Deserialize, Clone)]
106pub struct Reply {
107    /// The message ID being replied to
108    pub reference: String,
109
110    /// The inbox ID of the user who sent the message being replied to
111    #[serde(rename = "referenceInboxId", skip_serializing_if = "Option::is_none")]
112    pub reference_inbox_id: Option<String>,
113
114    /// The content of the reply
115    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    // verifies: CTYPE-007
127    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    // verifies: CTYPE-011
144    #[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}