palpo_core/events/
tag.rs

1//! Types for the [`m.tag`] event.
2//!
3//! [`m.tag`]: https://spec.matrix.org/latest/client-server-api/#mtag
4
5use std::{collections::BTreeMap, error::Error, fmt, str::FromStr};
6
7use palpo_macros::EventContent;
8use salvo::oapi::ToSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::PrivOwnedStr;
12use crate::serde::deserialize_cow_str;
13
14/// Map of tag names to tag info.
15pub type Tags = BTreeMap<TagName, TagInfo>;
16
17/// The content of an `m.tag` event.
18///
19/// Informs the client of tags on a room.
20#[derive(ToSchema, Deserialize, Serialize, Clone, Debug, EventContent)]
21#[palpo_event(type = "m.tag", kind = RoomAccountData)]
22pub struct TagEventContent {
23    /// A map of tag names to tag info.
24    pub tags: Tags,
25}
26
27impl TagEventContent {
28    /// Creates a new `TagEventContent` with the given `Tags`.
29    pub fn new(tags: Tags) -> Self {
30        Self { tags }
31    }
32}
33
34impl From<Tags> for TagEventContent {
35    fn from(tags: Tags) -> Self {
36        Self::new(tags)
37    }
38}
39
40/// A user-defined tag name.
41#[derive(ToSchema, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
42pub struct UserTagName {
43    name: String,
44}
45
46impl AsRef<str> for UserTagName {
47    fn as_ref(&self) -> &str {
48        &self.name
49    }
50}
51
52impl FromStr for UserTagName {
53    type Err = InvalidUserTagName;
54
55    fn from_str(s: &str) -> Result<Self, Self::Err> {
56        if s.starts_with("u.") {
57            Ok(Self { name: s.into() })
58        } else {
59            Err(InvalidUserTagName)
60        }
61    }
62}
63
64/// An error returned when attempting to create a UserTagName with a string that would make it
65/// invalid.
66#[derive(Debug)]
67#[allow(clippy::exhaustive_structs)]
68pub struct InvalidUserTagName;
69
70impl fmt::Display for InvalidUserTagName {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(f, "missing 'u.' prefix in UserTagName")
73    }
74}
75
76impl Error for InvalidUserTagName {}
77
78/// The name of a tag.
79#[derive(ToSchema, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
80pub enum TagName {
81    /// `m.favourite`: The user's favorite rooms.
82    ///
83    /// These should be shown with higher precedence than other rooms.
84    Favorite,
85
86    /// `m.lowpriority`: These should be shown with lower precedence than others.
87    LowPriority,
88
89    /// `m.server_notice`: Used to identify
90    /// [Server Notice Rooms](https://spec.matrix.org/latest/client-server-api/#server-notices).
91    ServerNotice,
92
93    /// `u.*`: User-defined tag
94    User(UserTagName),
95
96    /// A custom tag
97    #[doc(hidden)]
98    _Custom(PrivOwnedStr),
99}
100
101impl TagName {
102    /// Returns the display name of the tag.
103    ///
104    /// That means the string after `m.` or `u.` for spec- and user-defined tag names, and the
105    /// string after the last dot for custom tags. If no dot is found, returns the whole string.
106    pub fn display_name(&self) -> &str {
107        match self {
108            Self::_Custom(s) => {
109                let start = s.0.rfind('.').map(|p| p + 1).unwrap_or(0);
110                &self.as_ref()[start..]
111            }
112            _ => &self.as_ref()[2..],
113        }
114    }
115}
116
117impl AsRef<str> for TagName {
118    fn as_ref(&self) -> &str {
119        match self {
120            Self::Favorite => "m.favourite",
121            Self::LowPriority => "m.lowpriority",
122            Self::ServerNotice => "m.server_notice",
123            Self::User(tag) => tag.as_ref(),
124            Self::_Custom(s) => &s.0,
125        }
126    }
127}
128
129impl<T> From<T> for TagName
130where
131    T: AsRef<str> + Into<String>,
132{
133    fn from(s: T) -> TagName {
134        match s.as_ref() {
135            "m.favourite" => Self::Favorite,
136            "m.lowpriority" => Self::LowPriority,
137            "m.server_notice" => Self::ServerNotice,
138            s if s.starts_with("u.") => Self::User(UserTagName { name: s.into() }),
139            s => Self::_Custom(PrivOwnedStr(s.into())),
140        }
141    }
142}
143
144impl fmt::Display for TagName {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        self.as_ref().fmt(f)
147    }
148}
149
150impl<'de> Deserialize<'de> for TagName {
151    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
152    where
153        D: serde::Deserializer<'de>,
154    {
155        let cow = deserialize_cow_str(deserializer)?;
156        Ok(cow.into())
157    }
158}
159
160impl Serialize for TagName {
161    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
162    where
163        S: serde::Serializer,
164    {
165        serializer.serialize_str(self.as_ref())
166    }
167}
168
169/// Information about a tag.
170#[derive(ToSchema, Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
171pub struct TagInfo {
172    /// Value to use for lexicographically ordering rooms with this tag.
173    ///
174    /// If you activate the `compat-tag-info` feature, this field can be decoded as a stringified
175    /// floating-point value, instead of a number as it should be according to the specification.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub order: Option<f64>,
178}
179
180impl TagInfo {
181    /// Creates an empty `TagInfo`.
182    pub fn new() -> Self {
183        Default::default()
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use maplit::btreemap;
190    use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
191
192    use super::{TagEventContent, TagInfo, TagName};
193
194    #[test]
195    fn serialization() {
196        let tags = btreemap! {
197            TagName::Favorite => TagInfo::new(),
198            TagName::LowPriority => TagInfo::new(),
199            TagName::ServerNotice => TagInfo::new(),
200            "u.custom".to_owned().into() => TagInfo { order: Some(0.9) }
201        };
202
203        let content = TagEventContent { tags };
204
205        assert_eq!(
206            to_json_value(content).unwrap(),
207            json!({
208                "tags": {
209                    "m.favourite": {},
210                    "m.lowpriority": {},
211                    "m.server_notice": {},
212                    "u.custom": {
213                        "order": 0.9
214                    }
215                },
216            })
217        );
218    }
219
220    #[test]
221    fn deserialize_tag_info() {
222        let json = json!({});
223        assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo::default());
224
225        let json = json!({ "order": null });
226        assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo::default());
227
228        let json = json!({ "order": 0.42 });
229        assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(0.42) });
230
231        #[cfg(feature = "compat-tag-info")]
232        {
233            let json = json!({ "order": "0.5" });
234            assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(0.5) });
235
236            let json = json!({ "order": ".5" });
237            assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(0.5) });
238        }
239
240        #[cfg(not(feature = "compat-tag-info"))]
241        {
242            let json = json!({ "order": "0.5" });
243            assert!(from_json_value::<TagInfo>(json).is_err());
244        }
245    }
246
247    #[test]
248    fn display_name() {
249        assert_eq!(TagName::Favorite.display_name(), "favourite");
250        assert_eq!(TagName::LowPriority.display_name(), "lowpriority");
251        assert_eq!(TagName::ServerNotice.display_name(), "server_notice");
252        assert_eq!(TagName::from("u.Work").display_name(), "Work");
253        assert_eq!(TagName::from("rs.palpo.rules").display_name(), "rules");
254        assert_eq!(TagName::from("Play").display_name(), "Play");
255    }
256}