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