1mod receipt_thread_serde;
6
7use std::{
8 collections::{btree_map, BTreeMap},
9 ops::{Deref, DerefMut},
10};
11
12use ruma_common::{
13 EventId, IdParseError, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, UserId,
14};
15use ruma_macros::{EventContent, OrdAsRefStr, PartialEqAsRefStr, PartialOrdAsRefStr, StringEnum};
16use serde::{Deserialize, Serialize};
17
18use crate::PrivOwnedStr;
19
20#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
25#[allow(clippy::exhaustive_structs)]
26#[ruma_event(type = "m.receipt", kind = EphemeralRoom)]
27pub struct ReceiptEventContent(pub BTreeMap<OwnedEventId, Receipts>);
28
29impl ReceiptEventContent {
30 pub fn user_receipt(
32 &self,
33 user_id: &UserId,
34 receipt_type: ReceiptType,
35 ) -> Option<(&EventId, &Receipt)> {
36 self.iter().find_map(|(event_id, receipts)| {
37 let receipt = receipts.get(&receipt_type)?.get(user_id)?;
38 Some((event_id.as_ref(), receipt))
39 })
40 }
41}
42
43impl Deref for ReceiptEventContent {
44 type Target = BTreeMap<OwnedEventId, Receipts>;
45
46 fn deref(&self) -> &Self::Target {
47 &self.0
48 }
49}
50
51impl DerefMut for ReceiptEventContent {
52 fn deref_mut(&mut self) -> &mut Self::Target {
53 &mut self.0
54 }
55}
56
57impl IntoIterator for ReceiptEventContent {
58 type Item = (OwnedEventId, Receipts);
59 type IntoIter = btree_map::IntoIter<OwnedEventId, Receipts>;
60
61 fn into_iter(self) -> Self::IntoIter {
62 self.0.into_iter()
63 }
64}
65
66impl FromIterator<(OwnedEventId, Receipts)> for ReceiptEventContent {
67 fn from_iter<T>(iter: T) -> Self
68 where
69 T: IntoIterator<Item = (OwnedEventId, Receipts)>,
70 {
71 Self(BTreeMap::from_iter(iter))
72 }
73}
74
75pub type Receipts = BTreeMap<ReceiptType, UserReceipts>;
77
78#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
80#[derive(Clone, PartialOrdAsRefStr, OrdAsRefStr, PartialEqAsRefStr, Eq, StringEnum, Hash)]
81#[non_exhaustive]
82pub enum ReceiptType {
83 #[ruma_enum(rename = "m.read")]
95 Read,
96
97 #[ruma_enum(rename = "m.read.private")]
110 ReadPrivate,
111
112 #[doc(hidden)]
113 _Custom(PrivOwnedStr),
114}
115
116pub type UserReceipts = BTreeMap<OwnedUserId, Receipt>;
120
121#[derive(Clone, Debug, Default, Deserialize, Serialize)]
123#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
124pub struct Receipt {
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub ts: Option<MilliSecondsSinceUnixEpoch>,
128
129 #[serde(rename = "thread_id", default, skip_serializing_if = "ruma_common::serde::is_default")]
131 pub thread: ReceiptThread,
132}
133
134impl Receipt {
135 pub fn new(ts: MilliSecondsSinceUnixEpoch) -> Self {
139 Self { ts: Some(ts), thread: ReceiptThread::Unthreaded }
140 }
141}
142
143#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
154#[non_exhaustive]
155pub enum ReceiptThread {
156 #[default]
162 Unthreaded,
163
164 Main,
168
169 Thread(OwnedEventId),
173
174 #[doc(hidden)]
175 _Custom(PrivOwnedStr),
176}
177
178impl ReceiptThread {
179 pub fn as_str(&self) -> Option<&str> {
183 match self {
184 Self::Unthreaded => None,
185 Self::Main => Some("main"),
186 Self::Thread(event_id) => Some(event_id.as_str()),
187 Self::_Custom(s) => Some(&s.0),
188 }
189 }
190}
191
192impl<T> TryFrom<Option<T>> for ReceiptThread
193where
194 T: AsRef<str> + Into<Box<str>>,
195{
196 type Error = IdParseError;
197
198 fn try_from(s: Option<T>) -> Result<Self, Self::Error> {
199 let res = match s {
200 None => Self::Unthreaded,
201 Some(s) => match s.as_ref() {
202 "main" => Self::Main,
203 s_ref if s_ref.starts_with('$') => Self::Thread(EventId::parse(s_ref)?),
204 _ => Self::_Custom(PrivOwnedStr(s.into())),
205 },
206 };
207
208 Ok(res)
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use assert_matches2::assert_matches;
215 use ruma_common::{owned_event_id, MilliSecondsSinceUnixEpoch};
216 use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
217
218 use super::{Receipt, ReceiptThread};
219
220 #[test]
221 fn serialize_receipt() {
222 let mut receipt = Receipt::default();
223 assert_eq!(to_json_value(receipt.clone()).unwrap(), json!({}));
224
225 receipt.thread = ReceiptThread::Main;
226 assert_eq!(to_json_value(receipt.clone()).unwrap(), json!({ "thread_id": "main" }));
227
228 receipt.thread = ReceiptThread::Thread(owned_event_id!("$abcdef76543"));
229 assert_eq!(to_json_value(receipt).unwrap(), json!({ "thread_id": "$abcdef76543" }));
230
231 let mut receipt =
232 Receipt::new(MilliSecondsSinceUnixEpoch(1_664_702_144_365_u64.try_into().unwrap()));
233 assert_eq!(to_json_value(receipt.clone()).unwrap(), json!({ "ts": 1_664_702_144_365_u64 }));
234
235 receipt.thread = ReceiptThread::try_from(Some("io.ruma.unknown")).unwrap();
236 assert_eq!(
237 to_json_value(receipt).unwrap(),
238 json!({ "ts": 1_664_702_144_365_u64, "thread_id": "io.ruma.unknown" })
239 );
240 }
241
242 #[test]
243 fn deserialize_receipt() {
244 let receipt = from_json_value::<Receipt>(json!({})).unwrap();
245 assert_eq!(receipt.ts, None);
246 assert_eq!(receipt.thread, ReceiptThread::Unthreaded);
247
248 let receipt = from_json_value::<Receipt>(json!({ "thread_id": "main" })).unwrap();
249 assert_eq!(receipt.ts, None);
250 assert_eq!(receipt.thread, ReceiptThread::Main);
251
252 let receipt = from_json_value::<Receipt>(json!({ "thread_id": "$abcdef76543" })).unwrap();
253 assert_eq!(receipt.ts, None);
254 assert_matches!(receipt.thread, ReceiptThread::Thread(event_id));
255 assert_eq!(event_id, "$abcdef76543");
256
257 let receipt = from_json_value::<Receipt>(json!({ "ts": 1_664_702_144_365_u64 })).unwrap();
258 assert_eq!(
259 receipt.ts.unwrap(),
260 MilliSecondsSinceUnixEpoch(1_664_702_144_365_u64.try_into().unwrap())
261 );
262 assert_eq!(receipt.thread, ReceiptThread::Unthreaded);
263
264 let receipt = from_json_value::<Receipt>(
265 json!({ "ts": 1_664_702_144_365_u64, "thread_id": "io.ruma.unknown" }),
266 )
267 .unwrap();
268 assert_eq!(
269 receipt.ts.unwrap(),
270 MilliSecondsSinceUnixEpoch(1_664_702_144_365_u64.try_into().unwrap())
271 );
272 assert_matches!(&receipt.thread, ReceiptThread::_Custom(_));
273 assert_eq!(receipt.thread.as_str().unwrap(), "io.ruma.unknown");
274 }
275}