matrix_sdk_common/
serde_helpers.rs

1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A collection of serde helpers to avoid having to deserialize an entire event
16//! to access some fields.
17
18use ruma::{
19    OwnedEventId,
20    events::{
21        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
22        relation::BundledThread,
23    },
24    serde::Raw,
25};
26use serde::Deserialize;
27
28use crate::deserialized_responses::{ThreadSummary, ThreadSummaryStatus};
29
30#[derive(Deserialize)]
31enum RelationsType {
32    #[serde(rename = "m.thread")]
33    Thread,
34}
35
36#[derive(Deserialize)]
37struct RelatesTo {
38    #[serde(rename = "rel_type")]
39    rel_type: RelationsType,
40    #[serde(rename = "event_id")]
41    event_id: Option<OwnedEventId>,
42}
43
44#[allow(missing_debug_implementations)]
45#[derive(Deserialize)]
46struct SimplifiedContent {
47    #[serde(rename = "m.relates_to")]
48    relates_to: Option<RelatesTo>,
49}
50
51/// Try to extract the thread root from an event's content, if provided.
52///
53/// The thread root is the field located at `m.relates_to`.`event_id`,
54/// if the field at `m.relates_to`.`rel_type` is `m.thread`.
55///
56/// Returns `None` if we couldn't find a thread root, or if there was an issue
57/// during deserialization.
58pub fn extract_thread_root_from_content(
59    content: Raw<AnyMessageLikeEventContent>,
60) -> Option<OwnedEventId> {
61    let relates_to = content.deserialize_as_unchecked::<SimplifiedContent>().ok()?.relates_to?;
62    match relates_to.rel_type {
63        RelationsType::Thread => relates_to.event_id,
64    }
65}
66
67/// Try to extract the thread root from a timeline event, if provided.
68///
69/// The thread root is the field located at `content`.`m.relates_to`.`event_id`,
70/// if the field at `content`.`m.relates_to`.`rel_type` is `m.thread`.
71///
72/// Returns `None` if we couldn't find a thread root, or if there was an issue
73/// during deserialization.
74pub fn extract_thread_root(event: &Raw<AnySyncTimelineEvent>) -> Option<OwnedEventId> {
75    let relates_to = event.get_field::<SimplifiedContent>("content").ok().flatten()?.relates_to?;
76    match relates_to.rel_type {
77        RelationsType::Thread => relates_to.event_id,
78    }
79}
80
81#[allow(missing_debug_implementations)]
82#[derive(Deserialize)]
83struct Relations {
84    #[serde(rename = "m.thread")]
85    thread: Option<Box<BundledThread>>,
86}
87
88#[allow(missing_debug_implementations)]
89#[derive(Deserialize)]
90struct Unsigned {
91    #[serde(rename = "m.relations")]
92    relations: Option<Relations>,
93}
94
95/// Try to extract a bundled thread summary of a timeline event, if available.
96pub fn extract_bundled_thread_summary(
97    event: &Raw<AnySyncTimelineEvent>,
98) -> (ThreadSummaryStatus, Option<Raw<AnySyncMessageLikeEvent>>) {
99    match event.get_field::<Unsigned>("unsigned") {
100        Ok(Some(Unsigned { relations: Some(Relations { thread: Some(bundled_thread) }) })) => {
101            // Take the count from the bundled thread summary, if available. If it can't be
102            // converted to a `u32`, we use `u32::MAX` as a fallback, as this is unlikely
103            // to happen to have that many events in real-world threads.
104            let count = bundled_thread.count.try_into().unwrap_or(u32::MAX);
105
106            let latest_reply =
107                bundled_thread.latest_event.get_field::<OwnedEventId>("event_id").ok().flatten();
108
109            (
110                ThreadSummaryStatus::Some(ThreadSummary { num_replies: count, latest_reply }),
111                Some(bundled_thread.latest_event),
112            )
113        }
114        Ok(_) => (ThreadSummaryStatus::None, None),
115        Err(_) => (ThreadSummaryStatus::Unknown, None),
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use assert_matches::assert_matches;
122    use ruma::{event_id, serde::Raw};
123    use serde_json::json;
124
125    use super::extract_thread_root;
126    use crate::{
127        deserialized_responses::{ThreadSummary, ThreadSummaryStatus},
128        serde_helpers::extract_bundled_thread_summary,
129    };
130
131    #[test]
132    fn test_extract_thread_root() {
133        // No event factory in this crate :( There would be a dependency cycle with the
134        // `matrix-sdk-test` crate if we tried to use it here.
135
136        // We can extract the thread root from a regular message that contains one.
137        let thread_root = event_id!("$thread_root_event_id:example.com");
138        let event = Raw::new(&json!({
139            "event_id": "$eid:example.com",
140            "type": "m.room.message",
141            "sender": "@alice:example.com",
142            "origin_server_ts": 42,
143            "content": {
144                "body": "Hello, world!",
145                "m.relates_to": {
146                    "rel_type": "m.thread",
147                    "event_id": thread_root,
148                }
149            }
150        }))
151        .unwrap()
152        .cast_unchecked();
153
154        let observed_thread_root = extract_thread_root(&event);
155        assert_eq!(observed_thread_root.as_deref(), Some(thread_root));
156
157        // If the event doesn't have a content for some reason (redacted), it returns
158        // None.
159        let event = Raw::new(&json!({
160            "event_id": "$eid:example.com",
161            "type": "m.room.message",
162            "sender": "@alice:example.com",
163            "origin_server_ts": 42,
164        }))
165        .unwrap()
166        .cast_unchecked();
167
168        let observed_thread_root = extract_thread_root(&event);
169        assert_matches!(observed_thread_root, None);
170
171        // If the event has a content but with no `m.relates_to` field, it returns None.
172        let event = Raw::new(&json!({
173            "event_id": "$eid:example.com",
174            "type": "m.room.message",
175            "sender": "@alice:example.com",
176            "origin_server_ts": 42,
177            "content": {
178                "body": "Hello, world!",
179            }
180        }))
181        .unwrap()
182        .cast_unchecked();
183
184        let observed_thread_root = extract_thread_root(&event);
185        assert_matches!(observed_thread_root, None);
186
187        // If the event has a relation, but it's not a thread reply, it returns None.
188        let event = Raw::new(&json!({
189            "event_id": "$eid:example.com",
190            "type": "m.room.message",
191            "sender": "@alice:example.com",
192            "origin_server_ts": 42,
193            "content": {
194                "body": "Hello, world!",
195                "m.relates_to": {
196                    "rel_type": "m.reference",
197                    "event_id": "$referenced_event_id:example.com",
198                }
199            }
200        }))
201        .unwrap()
202        .cast_unchecked();
203
204        let observed_thread_root = extract_thread_root(&event);
205        assert_matches!(observed_thread_root, None);
206    }
207
208    #[test]
209    fn test_extract_bundled_thread_summary() {
210        // When there's a bundled thread summary, we can extract it.
211        let event = Raw::new(&json!({
212            "event_id": "$eid:example.com",
213            "type": "m.room.message",
214            "sender": "@alice:example.com",
215            "origin_server_ts": 42,
216            "content": {
217                "body": "Hello, world!",
218            },
219            "unsigned": {
220                "m.relations": {
221                    "m.thread": {
222                        "latest_event": {
223                            "event_id": "$latest_event:example.com",
224                            "type": "m.room.message",
225                            "sender": "@bob:example.com",
226                            "origin_server_ts": 42,
227                            "content": {
228                                "body": "Hello to you too!",
229                            }
230                        },
231                        "count": 2,
232                        "current_user_participated": true,
233                    }
234                }
235            }
236        }))
237        .unwrap()
238        .cast_unchecked();
239
240        assert_matches!(
241            extract_bundled_thread_summary(&event),
242            (ThreadSummaryStatus::Some(ThreadSummary { .. }), Some(..))
243        );
244
245        // When there's a bundled thread summary, we can assert it with certainty.
246        let event = Raw::new(&json!({
247            "event_id": "$eid:example.com",
248            "type": "m.room.message",
249            "sender": "@alice:example.com",
250            "origin_server_ts": 42,
251        }))
252        .unwrap()
253        .cast_unchecked();
254
255        assert_matches!(extract_bundled_thread_summary(&event), (ThreadSummaryStatus::None, None));
256
257        // When there's a bundled replace, we can assert there's no thread summary.
258        let event = Raw::new(&json!({
259            "event_id": "$eid:example.com",
260            "type": "m.room.message",
261            "sender": "@alice:example.com",
262            "origin_server_ts": 42,
263            "content": {
264                "body": "Bonjour, monde!",
265            },
266            "unsigned": {
267                "m.relations": {
268                    "m.replace":
269                    {
270                        "event_id": "$update:example.com",
271                        "type": "m.room.message",
272                        "sender": "@alice:example.com",
273                        "origin_server_ts": 43,
274                        "content": {
275                            "body": "* Hello, world!",
276                        }
277                    },
278                }
279            }
280        }))
281        .unwrap()
282        .cast_unchecked();
283
284        assert_matches!(extract_bundled_thread_summary(&event), (ThreadSummaryStatus::None, None));
285
286        // When the bundled thread summary is malformed, we return
287        // `ThreadSummaryStatus::Unknown`.
288        let event = Raw::new(&json!({
289            "event_id": "$eid:example.com",
290            "type": "m.room.message",
291            "sender": "@alice:example.com",
292            "origin_server_ts": 42,
293            "unsigned": {
294                "m.relations": {
295                    "m.thread": {
296                        // Missing `latest_event` field.
297                    }
298                }
299            }
300        }))
301        .unwrap()
302        .cast_unchecked();
303
304        assert_matches!(
305            extract_bundled_thread_summary(&event),
306            (ThreadSummaryStatus::Unknown, None)
307        );
308    }
309}