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