Skip to main content

xmtp_content_types/
actions.rs

1use std::collections::HashSet;
2
3use crate::{CodecError, ContentCodec};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use xmtp_proto::xmtp::mls::message_contents::{ContentTypeId, EncodedContent};
7
8const UTC_MILLIS_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.3fZ";
9
10mod datetime_utc_millis_option {
11    use super::*;
12
13    pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
14    where
15        S: Serializer,
16    {
17        match date {
18            Some(dt) => serializer.serialize_some(&dt.format(UTC_MILLIS_FORMAT).to_string()),
19            None => serializer.serialize_none(),
20        }
21    }
22
23    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
24    where
25        D: Deserializer<'de>,
26    {
27        let opt: Option<String> = Option::deserialize(deserializer)?;
28        match opt {
29            Some(s) => DateTime::parse_from_rfc3339(&s)
30                .map(|dt| Some(dt.with_timezone(&Utc)))
31                .map_err(serde::de::Error::custom),
32            None => Ok(None),
33        }
34    }
35}
36
37pub struct ActionsCodec;
38impl ActionsCodec {
39    const AUTHORITY_ID: &str = "coinbase.com";
40    pub const TYPE_ID: &str = "actions";
41    pub const MAJOR_VERSION: u32 = 1;
42    pub const MINOR_VERSION: u32 = 0;
43}
44
45impl ActionsCodec {
46    fn fallback(content: &Actions) -> Option<String> {
47        let action_list = content
48            .actions
49            .iter()
50            .enumerate()
51            .map(|(i, a)| format!("[{}] {}", i + 1, a.label))
52            .collect::<Vec<_>>()
53            .join("\n");
54
55        Some(format!(
56            "{}\n\n{}\n\nReply with the number to select",
57            content.description, action_list
58        ))
59    }
60}
61
62impl ContentCodec<Actions> for ActionsCodec {
63    fn content_type() -> ContentTypeId {
64        ContentTypeId {
65            authority_id: Self::AUTHORITY_ID.to_string(),
66            type_id: Self::TYPE_ID.to_string(),
67            version_major: Self::MAJOR_VERSION,
68            version_minor: Self::MINOR_VERSION,
69        }
70    }
71
72    fn encode(actions: Actions) -> Result<EncodedContent, CodecError> {
73        if actions.actions.is_empty() {
74            return Err(CodecError::Encode(
75                "Actions must contain at least one action.".to_string(),
76            ));
77        }
78        if actions.actions.len() > 10 {
79            return Err(CodecError::Encode(
80                "Actions cannot exceed 10 actions for UX reasons.".to_string(),
81            ));
82        }
83
84        if actions
85            .actions
86            .iter()
87            .map(|a| &a.id)
88            .collect::<HashSet<_>>()
89            .len()
90            != actions.actions.len()
91        {
92            return Err(CodecError::Encode("Action ids must be unique.".to_string()));
93        }
94
95        Ok(EncodedContent {
96            r#type: Some(Self::content_type()),
97            content: serde_json::to_vec(&actions)
98                .map_err(|e| CodecError::Encode(format!("Unable to serialize actions. {e:?}")))?,
99            fallback: Self::fallback(&actions),
100            ..Default::default()
101        })
102    }
103
104    fn decode(actions: EncodedContent) -> Result<Actions, CodecError> {
105        let actions: Actions = serde_json::from_slice(&actions.content)
106            .map_err(|e| CodecError::Decode(format!("Unable to deserialize actions. {e:?}")))?;
107
108        Ok(actions)
109    }
110
111    fn should_push() -> bool {
112        true
113    }
114}
115
116#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
117pub struct Actions {
118    pub id: String,
119    pub description: String,
120    pub actions: Vec<Action>,
121    #[serde(
122        default,
123        alias = "expires_at",
124        rename = "expiresAt",
125        skip_serializing_if = "Option::is_none",
126        with = "datetime_utc_millis_option"
127    )]
128    pub expires_at: Option<DateTime<Utc>>,
129}
130
131#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
132pub struct Action {
133    pub id: String,
134    pub label: String,
135    #[serde(
136        alias = "image_url",
137        rename = "imageUrl",
138        skip_serializing_if = "Option::is_none"
139    )]
140    pub image_url: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub style: Option<ActionStyle>,
143    #[serde(
144        default,
145        alias = "expires_at",
146        rename = "expiresAt",
147        skip_serializing_if = "Option::is_none",
148        with = "datetime_utc_millis_option"
149    )]
150    pub expires_at: Option<DateTime<Utc>>,
151}
152
153#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
154#[serde(rename_all = "lowercase")]
155pub enum ActionStyle {
156    Primary,
157    Secondary,
158    Danger,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::{Action, ActionStyle, Actions, ActionsCodec};
164    use crate::{CodecError, ContentCodec};
165    use chrono::{TimeZone, Utc};
166
167    #[xmtp_common::test(unwrap_try = true)]
168    fn encode_decode_actions() {
169        let mut actions = Actions {
170            id: "thanksgiving_selection".to_string(),
171            description: "Grandma is asking for your input on Thanksgiving".to_string(),
172            actions: vec![
173                Action {
174                    id: "the_turkey_of_course".to_string(),
175                    label: "The Turkey (of course)".to_string(),
176                    image_url: Some("http://turkey-images.biz/the-one.jpg".to_string()),
177                    style: Some(ActionStyle::Primary),
178                    expires_at: None,
179                },
180                Action {
181                    id: "pork_loin".to_string(),
182                    label: "Pork Loin".to_string(),
183                    image_url: None,
184                    style: None,
185                    expires_at: Some(Utc.with_ymd_and_hms(2025, 1, 15, 12, 30, 45).unwrap()),
186                },
187            ],
188            expires_at: Some(Utc.with_ymd_and_hms(2025, 12, 31, 23, 59, 59).unwrap()),
189        };
190
191        let encoded = ActionsCodec::encode(actions.clone())?;
192        assert!(
193            encoded
194                .fallback()
195                .contains("[1] The Turkey (of course)\n[2] Pork Loin"),
196        );
197        let decoded = ActionsCodec::decode(encoded)?;
198
199        assert_eq!(decoded, actions);
200
201        actions.actions.push(Action {
202            id: "pork_loin".to_string(),
203            label: "More Pork Loin".to_string(),
204            image_url: None,
205            style: None,
206            expires_at: None,
207        });
208
209        let encoded_result = ActionsCodec::encode(actions);
210        let Err(CodecError::Encode(reason)) = encoded_result else {
211            panic!("Expected an uniqueness encoding error.");
212        };
213        assert!(reason.contains("unique"));
214    }
215
216    #[xmtp_common::test(unwrap_try = true)]
217    fn expires_at_serializes_as_utc_with_millis() {
218        let actions = Actions {
219            id: "test".to_string(),
220            description: "Test".to_string(),
221            actions: vec![Action {
222                id: "action1".to_string(),
223                label: "Action 1".to_string(),
224                image_url: None,
225                style: None,
226                expires_at: Some(Utc.with_ymd_and_hms(2025, 6, 15, 10, 30, 45).unwrap()),
227            }],
228            expires_at: Some(Utc.with_ymd_and_hms(2025, 12, 25, 23, 59, 59).unwrap()),
229        };
230
231        let encoded = ActionsCodec::encode(actions)?;
232        let json = String::from_utf8(encoded.content.clone())?;
233
234        // Verify format includes milliseconds (.000) and UTC suffix (Z)
235        assert!(
236            json.contains("2025-06-15T10:30:45.000Z"),
237            "Action expires_at should be formatted with milliseconds and UTC: {json}"
238        );
239        assert!(
240            json.contains("2025-12-25T23:59:59.000Z"),
241            "Actions expires_at should be formatted with milliseconds and UTC: {json}"
242        );
243    }
244}