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 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}