ruma_events/room/
topic.rs

1//! Types for the [`m.room.topic`] event.
2//!
3//! [`m.room.topic`]: https://spec.matrix.org/latest/client-server-api/#mroomtopic
4
5use ruma_macros::EventContent;
6use serde::{Deserialize, Serialize};
7
8use crate::{EmptyStateKey, message::TextContentBlock};
9
10/// The content of an `m.room.topic` event.
11///
12/// A topic is a short message detailing what is currently being discussed in the room.
13#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
14#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
15#[ruma_event(type = "m.room.topic", kind = State, state_key_type = EmptyStateKey)]
16pub struct RoomTopicEventContent {
17    /// The topic as plain text.
18    ///
19    /// This SHOULD duplicate the content of the `text/plain` representation in `topic_block` if
20    /// any exists.
21    pub topic: String,
22
23    /// Textual representation of the room topic in different mimetypes.
24    ///
25    /// With the `compat-lax-room-topic-deser` cargo feature, this field is ignored if its
26    /// deserialization fails.
27    #[serde(rename = "m.topic", default, skip_serializing_if = "TopicContentBlock::is_empty")]
28    #[cfg_attr(
29        feature = "compat-lax-room-topic-deser",
30        serde(deserialize_with = "ruma_common::serde::default_on_error")
31    )]
32    pub topic_block: TopicContentBlock,
33}
34
35impl RoomTopicEventContent {
36    /// Creates a new `RoomTopicEventContent` with the given plain text topic.
37    pub fn new(topic: String) -> Self {
38        Self { topic_block: TopicContentBlock::plain(topic.clone()), topic }
39    }
40
41    /// Convenience constructor to create a new HTML topic with a plain text fallback.
42    pub fn html(plain: impl Into<String>, html: impl Into<String>) -> Self {
43        let plain = plain.into();
44        Self { topic: plain.clone(), topic_block: TopicContentBlock::html(plain, html) }
45    }
46
47    /// Convenience constructor to create a topic from Markdown.
48    ///
49    /// The content includes an HTML topic if some Markdown formatting was detected, otherwise
50    /// only a plain text topic is included.
51    #[cfg(feature = "markdown")]
52    pub fn markdown(topic: impl AsRef<str> + Into<String>) -> Self {
53        let plain = topic.as_ref().to_owned();
54        Self { topic: plain, topic_block: TopicContentBlock::markdown(topic) }
55    }
56}
57
58/// A block for topic content.
59///
60/// To construct a `TopicContentBlock` with a custom [`TextContentBlock`], convert it with
61/// `TopicContentBlock::from()` / `.into()`.
62#[derive(Clone, Debug, Default, Serialize, Deserialize)]
63#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
64pub struct TopicContentBlock {
65    /// The text representations of the topic.
66    #[serde(rename = "m.text")]
67    pub text: TextContentBlock,
68}
69
70impl TopicContentBlock {
71    /// A convenience constructor to create a plain text `TopicContentBlock`.
72    pub fn plain(body: impl Into<String>) -> Self {
73        Self { text: TextContentBlock::plain(body) }
74    }
75
76    /// A convenience constructor to create an HTML `TopicContentBlock`.
77    pub fn html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
78        Self { text: TextContentBlock::html(body, html_body) }
79    }
80
81    /// A convenience constructor to create a `TopicContentBlock` from Markdown.
82    ///
83    /// The content includes an HTML topic if some Markdown formatting was detected, otherwise
84    /// only a plain text topic is included.
85    #[cfg(feature = "markdown")]
86    pub fn markdown(body: impl AsRef<str> + Into<String>) -> Self {
87        Self { text: TextContentBlock::markdown(body) }
88    }
89
90    /// Whether this content block is empty.
91    fn is_empty(&self) -> bool {
92        self.text.is_empty()
93    }
94}
95
96impl From<TextContentBlock> for TopicContentBlock {
97    fn from(text: TextContentBlock) -> Self {
98        Self { text }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use ruma_common::canonical_json::assert_to_canonical_json_eq;
105    use serde_json::{from_value as from_json_value, json};
106
107    use super::RoomTopicEventContent;
108    use crate::message::TextContentBlock;
109
110    #[test]
111    fn serialize_content() {
112        // Content with plain text block.
113        let mut content = RoomTopicEventContent::new("Hot Topic".to_owned());
114        assert_to_canonical_json_eq!(
115            content,
116            json!({
117                "topic": "Hot Topic",
118                "m.topic": {
119                   "m.text": [
120                        { "body": "Hot Topic" },
121                    ],
122                }
123            })
124        );
125
126        // Content without block.
127        content.topic_block.text = TextContentBlock::from(vec![]);
128        assert_to_canonical_json_eq!(
129            content,
130            json!({
131                "topic": "Hot Topic",
132            })
133        );
134
135        // Content with HTML block.
136        let content = RoomTopicEventContent::html("Hot Topic", "<strong>Hot</strong> Topic");
137        assert_to_canonical_json_eq!(
138            content,
139            json!({
140                "topic": "Hot Topic",
141                "m.topic": {
142                   "m.text": [
143                        { "body": "<strong>Hot</strong> Topic", "mimetype": "text/html" },
144                        { "body": "Hot Topic" },
145                    ],
146                }
147            })
148        );
149    }
150
151    #[test]
152    fn deserialize_content() {
153        let json = json!({
154            "topic": "Hot Topic",
155            "m.topic": {
156               "m.text": [
157                    { "body": "<strong>Hot</strong> Topic", "mimetype": "text/html" },
158                    { "body": "Hot Topic" },
159                ],
160            }
161        });
162
163        let content = from_json_value::<RoomTopicEventContent>(json).unwrap();
164        assert_eq!(content.topic, "Hot Topic");
165        assert_eq!(content.topic_block.text.find_html(), Some("<strong>Hot</strong> Topic"));
166        assert_eq!(content.topic_block.text.find_plain(), Some("Hot Topic"));
167
168        let content = serde_json::from_str::<RoomTopicEventContent>(
169            r#"{"topic":"Hot Topic","m.topic":{"m.text":[{"body":"Hot Topic"}]}}"#,
170        )
171        .unwrap();
172        assert_eq!(content.topic, "Hot Topic");
173        assert_eq!(content.topic_block.text.find_html(), None);
174        assert_eq!(content.topic_block.text.find_plain(), Some("Hot Topic"));
175    }
176
177    #[test]
178    fn deserialize_event() {
179        let json = json!({
180            "content": {
181                "topic": "Hot Topic",
182                "m.topic": {
183                    "m.text": [
184                        { "body": "<strong>Hot</strong> Topic", "mimetype": "text/html" },
185                        { "body": "Hot Topic" },
186                    ],
187                },
188            },
189            "type": "m.room.topic",
190            "state_key": "",
191            "event_id": "$lkioKdioukshnlDDz",
192            "sender": "@alice:localhost",
193            "origin_server_ts": 309_998_934,
194        });
195
196        from_json_value::<super::SyncRoomTopicEvent>(json).unwrap();
197    }
198
199    #[test]
200    #[cfg(feature = "compat-lax-room-topic-deser")]
201    fn deserialize_invalid_content() {
202        let json = json!({
203            "topic": "Hot Topic",
204            "m.topic": [
205                { "body": "<strong>Hot</strong> Topic", "mimetype": "text/html" },
206                { "body": "Hot Topic" },
207            ],
208        });
209
210        let content = from_json_value::<RoomTopicEventContent>(json).unwrap();
211        assert_eq!(content.topic, "Hot Topic");
212        assert_eq!(content.topic_block.text.find_html(), None);
213        assert_eq!(content.topic_block.text.find_plain(), None);
214
215        let content = serde_json::from_str::<RoomTopicEventContent>(
216            r#"{"topic":"Hot Topic","m.topic":[{"body":"Hot Topic"}]}"#,
217        )
218        .unwrap();
219        assert_eq!(content.topic, "Hot Topic");
220        assert_eq!(content.topic_block.text.find_html(), None);
221        assert_eq!(content.topic_block.text.find_plain(), None);
222    }
223
224    #[test]
225    #[cfg(feature = "compat-lax-room-topic-deser")]
226    fn deserialize_invalid_event() {
227        let json = json!({
228            "content": {
229                "topic": "Hot Topic",
230                "m.topic": [
231                    { "body": "<strong>Hot</strong> Topic", "mimetype": "text/html" },
232                    { "body": "Hot Topic" },
233                ],
234            },
235            "type": "m.room.topic",
236            "state_key": "",
237            "event_id": "$lkioKdioukshnlDDz",
238            "sender": "@alice:localhost",
239            "origin_server_ts": 309_998_934,
240        });
241
242        from_json_value::<super::SyncRoomTopicEvent>(json).unwrap();
243    }
244}