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