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::{message::TextContentBlock, EmptyStateKey};
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 serde_json::{from_value as from_json_value, json, to_value as to_json_value};
105
106    use super::RoomTopicEventContent;
107    use crate::message::TextContentBlock;
108
109    #[test]
110    fn serialize_content() {
111        // Content with plain text block.
112        let mut content = RoomTopicEventContent::new("Hot Topic".to_owned());
113        assert_eq!(
114            to_json_value(&content).unwrap(),
115            json!({
116                "topic": "Hot Topic",
117                "m.topic": {
118                   "m.text": [
119                        { "body": "Hot Topic" },
120                    ],
121                }
122            })
123        );
124
125        // Content without block.
126        content.topic_block.text = TextContentBlock::from(vec![]);
127        assert_eq!(
128            to_json_value(&content).unwrap(),
129            json!({
130                "topic": "Hot Topic",
131            })
132        );
133
134        // Content with HTML block.
135        let content = RoomTopicEventContent::html("Hot Topic", "<strong>Hot</strong> Topic");
136        assert_eq!(
137            to_json_value(&content).unwrap(),
138            json!({
139                "topic": "Hot Topic",
140                "m.topic": {
141                   "m.text": [
142                        { "body": "<strong>Hot</strong> Topic", "mimetype": "text/html" },
143                        { "body": "Hot Topic" },
144                    ],
145                }
146            })
147        );
148    }
149
150    #[test]
151    fn deserialize_content() {
152        let json = json!({
153            "topic": "Hot Topic",
154            "m.topic": {
155               "m.text": [
156                    { "body": "<strong>Hot</strong> Topic", "mimetype": "text/html" },
157                    { "body": "Hot Topic" },
158                ],
159            }
160        });
161
162        let content = from_json_value::<RoomTopicEventContent>(json).unwrap();
163        assert_eq!(content.topic, "Hot Topic");
164        assert_eq!(content.topic_block.text.find_html(), Some("<strong>Hot</strong> Topic"));
165        assert_eq!(content.topic_block.text.find_plain(), Some("Hot Topic"));
166
167        let content = serde_json::from_str::<RoomTopicEventContent>(
168            r#"{"topic":"Hot Topic","m.topic":{"m.text":[{"body":"Hot Topic"}]}}"#,
169        )
170        .unwrap();
171        assert_eq!(content.topic, "Hot Topic");
172        assert_eq!(content.topic_block.text.find_html(), None);
173        assert_eq!(content.topic_block.text.find_plain(), Some("Hot Topic"));
174    }
175
176    #[test]
177    fn deserialize_event() {
178        let json = json!({
179            "content": {
180                "topic": "Hot Topic",
181                "m.topic": {
182                    "m.text": [
183                        { "body": "<strong>Hot</strong> Topic", "mimetype": "text/html" },
184                        { "body": "Hot Topic" },
185                    ],
186                },
187            },
188            "type": "m.room.topic",
189            "state_key": "",
190            "event_id": "$lkioKdioukshnlDDz",
191            "sender": "@alice:localhost",
192            "origin_server_ts": 309_998_934,
193        });
194
195        from_json_value::<super::SyncRoomTopicEvent>(json).unwrap();
196    }
197
198    #[test]
199    #[cfg(feature = "compat-lax-room-topic-deser")]
200    fn deserialize_invalid_content() {
201        let json = json!({
202            "topic": "Hot Topic",
203            "m.topic": [
204                { "body": "<strong>Hot</strong> Topic", "mimetype": "text/html" },
205                { "body": "Hot Topic" },
206            ],
207        });
208
209        let content = from_json_value::<RoomTopicEventContent>(json).unwrap();
210        assert_eq!(content.topic, "Hot Topic");
211        assert_eq!(content.topic_block.text.find_html(), None);
212        assert_eq!(content.topic_block.text.find_plain(), None);
213
214        let content = serde_json::from_str::<RoomTopicEventContent>(
215            r#"{"topic":"Hot Topic","m.topic":[{"body":"Hot Topic"}]}"#,
216        )
217        .unwrap();
218        assert_eq!(content.topic, "Hot Topic");
219        assert_eq!(content.topic_block.text.find_html(), None);
220        assert_eq!(content.topic_block.text.find_plain(), None);
221    }
222
223    #[test]
224    #[cfg(feature = "compat-lax-room-topic-deser")]
225    fn deserialize_invalid_event() {
226        let json = json!({
227            "content": {
228                "topic": "Hot Topic",
229                "m.topic": [
230                    { "body": "<strong>Hot</strong> Topic", "mimetype": "text/html" },
231                    { "body": "Hot Topic" },
232                ],
233            },
234            "type": "m.room.topic",
235            "state_key": "",
236            "event_id": "$lkioKdioukshnlDDz",
237            "sender": "@alice:localhost",
238            "origin_server_ts": 309_998_934,
239        });
240
241        from_json_value::<super::SyncRoomTopicEvent>(json).unwrap();
242    }
243}