Skip to main content

matrix_sdk/room/
reply.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//! Facilities to reply to existing events.
16
17use as_variant::as_variant;
18use ruma::{
19    OwnedEventId, UserId,
20    events::{
21        AnySyncTimelineEvent,
22        room::{
23            encrypted::Relation as EncryptedRelation,
24            message::{
25                AddMentions, ForwardThread, ReplyMetadata, ReplyWithinThread,
26                RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
27            },
28        },
29    },
30};
31use thiserror::Error;
32use tracing::instrument;
33
34use super::{EventSource, Room};
35
36/// Information needed to reply to an event.
37#[derive(Debug)]
38pub struct Reply {
39    /// The event ID of the event to reply to.
40    pub event_id: OwnedEventId,
41    /// Whether to enforce a thread relation.
42    pub enforce_thread: EnforceThread,
43    /// Whether to add intentional Mentions. Might be ignored if the reply is
44    /// sent by the same user as the event that it replies to.
45    pub add_mentions: AddMentions,
46}
47
48/// Errors specific to unsupported replies.
49#[derive(Debug, Error)]
50pub enum ReplyError {
51    /// We couldn't fetch the remote event with /room/event.
52    #[error("Couldn't fetch the remote event: {0}")]
53    Fetch(Box<crate::Error>),
54    /// The event to reply to could not be deserialized.
55    #[error("failed to deserialize event to reply to")]
56    Deserialization,
57    /// State events cannot be replied to.
58    #[error("tried to reply to a state event")]
59    StateEvent,
60}
61
62/// Whether or not to enforce a [`Relation::Thread`] when sending a reply.
63///
64/// [`Relation::Thread`]: ruma::events::room::message::Relation::Thread
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum EnforceThread {
67    /// A thread relation is enforced. If the original message does not have a
68    /// thread relation itself, a new thread is started.
69    Threaded(ReplyWithinThread),
70
71    /// A thread relation is not enforced. If the original message has a thread
72    /// relation, it is forwarded.
73    MaybeThreaded,
74
75    /// A thread relation is not enforced. If the original message has a thread
76    /// relation, it is _not_ forwarded.
77    Unthreaded,
78}
79
80impl Room {
81    /// Create a new reply event for the target event id with the specified
82    /// content.
83    ///
84    /// The event can then be sent with [`Room::send`] or a
85    /// [`crate::send_queue::RoomSendQueue`].
86    ///
87    /// # Arguments
88    ///
89    /// - `content` - The content to reply with
90    /// - `event_id` - ID of the event to reply to
91    /// - `enforce_thread` - Whether to enforce a thread relation
92    #[instrument(skip(self, content), fields(room = %self.room_id()))]
93    pub async fn make_reply_event(
94        &self,
95        content: RoomMessageEventContentWithoutRelation,
96        reply: Reply,
97    ) -> Result<RoomMessageEventContent, ReplyError> {
98        make_reply_event(self, self.own_user_id(), content, reply).await
99    }
100}
101
102async fn make_reply_event<S: EventSource>(
103    source: S,
104    own_user_id: &UserId,
105    content: RoomMessageEventContentWithoutRelation,
106    reply: Reply,
107) -> Result<RoomMessageEventContent, ReplyError> {
108    let event =
109        source.get_event(&reply.event_id).await.map_err(|err| ReplyError::Fetch(Box::new(err)))?;
110
111    let raw_event = event.into_raw();
112    let event = raw_event.deserialize().map_err(|_| ReplyError::Deserialization)?;
113
114    let relation = as_variant!(&event, AnySyncTimelineEvent::MessageLike)
115        .ok_or(ReplyError::StateEvent)?
116        .original_content()
117        .and_then(|content| content.relation());
118    let thread =
119        relation.as_ref().and_then(|relation| as_variant!(relation, EncryptedRelation::Thread));
120
121    let reply_metadata = ReplyMetadata::new(event.event_id(), event.sender(), thread);
122
123    // [The specification](https://spec.matrix.org/v1.10/client-server-api/#user-and-room-mentions)
124    // says:
125    //
126    // > Users should not add their own Matrix ID to the `m.mentions` property
127    // > as outgoing messages cannot self-notify.
128    //
129    // If the replied to event has been written by the current user, let's
130    // toggle to `AddMentions::No`.
131    let mention_the_sender =
132        if own_user_id == event.sender() { AddMentions::No } else { reply.add_mentions };
133
134    let content = match reply.enforce_thread {
135        EnforceThread::Threaded(is_reply) => {
136            content.make_for_thread(reply_metadata, is_reply, mention_the_sender)
137        }
138        EnforceThread::MaybeThreaded => {
139            content.make_reply_to(reply_metadata, ForwardThread::Yes, mention_the_sender)
140        }
141        EnforceThread::Unthreaded => {
142            content.make_reply_to(reply_metadata, ForwardThread::No, mention_the_sender)
143        }
144    };
145
146    Ok(content)
147}
148
149#[cfg(test)]
150mod tests {
151    use std::{assert_matches, collections::BTreeMap};
152
153    use matrix_sdk_base::deserialized_responses::TimelineEvent;
154    use matrix_sdk_test::{async_test, event_factory::EventFactory};
155    use ruma::{
156        EventId, OwnedEventId, event_id,
157        events::{
158            AnySyncTimelineEvent,
159            room::message::{
160                AddMentions, Relation, ReplyWithinThread, RoomMessageEventContentWithoutRelation,
161            },
162        },
163        serde::Raw,
164        user_id,
165    };
166    use serde_json::json;
167    use strass::assert_let;
168
169    use super::{EnforceThread, EventSource, Reply, ReplyError, make_reply_event};
170    use crate::{Error, event_cache::EventCacheError};
171
172    #[derive(Default)]
173    struct TestEventCache {
174        events: BTreeMap<OwnedEventId, TimelineEvent>,
175    }
176
177    impl EventSource for TestEventCache {
178        async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, Error> {
179            self.events
180                .get(event_id)
181                .cloned()
182                .ok_or(Error::EventCache(Box::new(EventCacheError::ClientDropped)))
183        }
184    }
185
186    #[async_test]
187    async fn test_cannot_reply_to_unknown_event() {
188        let event_id = event_id!("$1");
189        let own_user_id = user_id!("@me:saucisse.bzh");
190
191        let mut cache = TestEventCache::default();
192        let f = EventFactory::new();
193        cache.events.insert(
194            event_id.to_owned(),
195            f.text_msg("hi").event_id(event_id).sender(own_user_id).into(),
196        );
197
198        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
199
200        assert_matches!(
201            make_reply_event(
202                cache,
203                own_user_id,
204                content,
205                Reply {
206                    event_id: event_id!("$2").into(),
207                    enforce_thread: EnforceThread::Unthreaded,
208                    add_mentions: AddMentions::Yes,
209                },
210            )
211            .await,
212            Err(ReplyError::Fetch(_))
213        );
214    }
215
216    #[async_test]
217    async fn test_cannot_reply_to_invalid_event() {
218        let event_id = event_id!("$1");
219        let own_user_id = user_id!("@me:saucisse.bzh");
220
221        let mut cache = TestEventCache::default();
222
223        cache.events.insert(
224            event_id.to_owned(),
225            TimelineEvent::from_plaintext(
226                Raw::<AnySyncTimelineEvent>::from_json_string(
227                    json!({
228                        "content": {
229                            "body": "hi"
230                        },
231                        "event_id": event_id,
232                        "origin_server_ts": 1,
233                        "type": "m.room.message",
234                        // Invalid because sender is missing
235                    })
236                    .to_string(),
237                )
238                .unwrap(),
239            ),
240        );
241
242        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
243
244        assert_matches!(
245            make_reply_event(
246                cache,
247                own_user_id,
248                content,
249                Reply {
250                    event_id: event_id.into(),
251                    enforce_thread: EnforceThread::Unthreaded,
252                    add_mentions: AddMentions::Yes,
253                },
254            )
255            .await,
256            Err(ReplyError::Deserialization)
257        );
258    }
259
260    #[async_test]
261    async fn test_cannot_reply_to_state_event() {
262        let event_id = event_id!("$1");
263        let own_user_id = user_id!("@me:saucisse.bzh");
264
265        let mut cache = TestEventCache::default();
266        let f = EventFactory::new();
267        cache.events.insert(
268            event_id.to_owned(),
269            f.room_name("lobby").event_id(event_id).sender(own_user_id).into(),
270        );
271
272        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
273
274        assert_matches!(
275            make_reply_event(
276                cache,
277                own_user_id,
278                content,
279                Reply {
280                    event_id: event_id.into(),
281                    enforce_thread: EnforceThread::Unthreaded,
282                    add_mentions: AddMentions::Yes,
283                },
284            )
285            .await,
286            Err(ReplyError::StateEvent)
287        );
288    }
289
290    #[async_test]
291    async fn test_reply_unthreaded() {
292        let event_id = event_id!("$1");
293        let own_user_id = user_id!("@me:saucisse.bzh");
294
295        let mut cache = TestEventCache::default();
296        let f = EventFactory::new();
297        cache.events.insert(
298            event_id.to_owned(),
299            f.text_msg("hi").event_id(event_id).sender(own_user_id).into(),
300        );
301
302        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
303
304        let reply_event = make_reply_event(
305            cache,
306            own_user_id,
307            content,
308            Reply {
309                event_id: event_id.into(),
310                enforce_thread: EnforceThread::Unthreaded,
311                add_mentions: AddMentions::Yes,
312            },
313        )
314        .await
315        .unwrap();
316
317        assert_let!(Some(Relation::Reply(reply)) = &reply_event.relates_to);
318
319        assert_eq!(reply.in_reply_to.event_id, event_id);
320    }
321
322    #[async_test]
323    async fn test_start_thread() {
324        let event_id = event_id!("$1");
325        let own_user_id = user_id!("@me:saucisse.bzh");
326
327        let mut cache = TestEventCache::default();
328        let f = EventFactory::new();
329        cache.events.insert(
330            event_id.to_owned(),
331            f.text_msg("hi").event_id(event_id).sender(own_user_id).into(),
332        );
333
334        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
335
336        let reply_event = make_reply_event(
337            cache,
338            own_user_id,
339            content,
340            Reply {
341                event_id: event_id.into(),
342                enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
343                add_mentions: AddMentions::Yes,
344            },
345        )
346        .await
347        .unwrap();
348
349        assert_let!(Some(Relation::Thread(thread)) = &reply_event.relates_to);
350
351        assert_eq!(thread.event_id, event_id);
352        assert_eq!(thread.in_reply_to.as_ref().unwrap().event_id, event_id);
353        assert!(thread.is_falling_back);
354    }
355
356    #[async_test]
357    async fn test_reply_on_thread() {
358        let thread_root = event_id!("$1");
359        let event_id = event_id!("$2");
360        let own_user_id = user_id!("@me:saucisse.bzh");
361
362        let mut cache = TestEventCache::default();
363        let f = EventFactory::new();
364        cache.events.insert(
365            thread_root.to_owned(),
366            f.text_msg("hi").event_id(thread_root).sender(own_user_id).into(),
367        );
368        cache.events.insert(
369            event_id.to_owned(),
370            f.text_msg("ho")
371                .in_thread(thread_root, thread_root)
372                .event_id(event_id)
373                .sender(own_user_id)
374                .into(),
375        );
376
377        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
378
379        let reply_event = make_reply_event(
380            cache,
381            own_user_id,
382            content,
383            Reply {
384                event_id: event_id.into(),
385                enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
386                add_mentions: AddMentions::Yes,
387            },
388        )
389        .await
390        .unwrap();
391
392        assert_let!(Some(Relation::Thread(thread)) = &reply_event.relates_to);
393
394        assert_eq!(thread.event_id, thread_root);
395        assert_eq!(thread.in_reply_to.as_ref().unwrap().event_id, event_id);
396        assert!(thread.is_falling_back);
397    }
398
399    #[async_test]
400    async fn test_reply_on_thread_as_reply() {
401        let thread_root = event_id!("$1");
402        let event_id = event_id!("$2");
403        let own_user_id = user_id!("@me:saucisse.bzh");
404
405        let mut cache = TestEventCache::default();
406        let f = EventFactory::new();
407        cache.events.insert(
408            thread_root.to_owned(),
409            f.text_msg("hi").event_id(thread_root).sender(own_user_id).into(),
410        );
411        cache.events.insert(
412            event_id.to_owned(),
413            f.text_msg("ho")
414                .in_thread(thread_root, thread_root)
415                .event_id(event_id)
416                .sender(own_user_id)
417                .into(),
418        );
419
420        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
421
422        let reply_event = make_reply_event(
423            cache,
424            own_user_id,
425            content,
426            Reply {
427                event_id: event_id.into(),
428                enforce_thread: EnforceThread::Threaded(ReplyWithinThread::Yes),
429                add_mentions: AddMentions::Yes,
430            },
431        )
432        .await
433        .unwrap();
434
435        assert_let!(Some(Relation::Thread(thread)) = &reply_event.relates_to);
436
437        assert_eq!(thread.event_id, thread_root);
438        assert_eq!(thread.in_reply_to.as_ref().unwrap().event_id, event_id);
439        assert!(!thread.is_falling_back);
440    }
441
442    #[async_test]
443    async fn test_reply_forwarding_thread() {
444        let thread_root = event_id!("$1");
445        let event_id = event_id!("$2");
446        let own_user_id = user_id!("@me:saucisse.bzh");
447
448        let mut cache = TestEventCache::default();
449        let f = EventFactory::new();
450        cache.events.insert(
451            thread_root.to_owned(),
452            f.text_msg("hi").event_id(thread_root).sender(own_user_id).into(),
453        );
454        cache.events.insert(
455            event_id.to_owned(),
456            f.text_msg("ho")
457                .in_thread(thread_root, thread_root)
458                .event_id(event_id)
459                .sender(own_user_id)
460                .into(),
461        );
462
463        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
464
465        let reply_event = make_reply_event(
466            cache,
467            own_user_id,
468            content,
469            Reply {
470                event_id: event_id.into(),
471                enforce_thread: EnforceThread::MaybeThreaded,
472                add_mentions: AddMentions::Yes,
473            },
474        )
475        .await
476        .unwrap();
477
478        assert_let!(Some(Relation::Thread(thread)) = &reply_event.relates_to);
479
480        assert_eq!(thread.event_id, thread_root);
481        assert_eq!(thread.in_reply_to.as_ref().unwrap().event_id, event_id);
482        assert!(thread.is_falling_back);
483    }
484
485    #[async_test]
486    async fn test_reply_forwarding_thread_for_poll_start() {
487        let thread_root = event_id!("$thread_root");
488        let event_id = event_id!("$thread_reply");
489        let own_user_id = user_id!("@me:saucisse.bzh");
490
491        let mut cache = TestEventCache::default();
492        let f = EventFactory::new();
493
494        cache.events.insert(
495            event_id.to_owned(),
496            f.poll_start(
497                "would you rather… A) eat a pineapple pizza, B) drink pickle juice",
498                "would you rather…",
499                vec!["eat a pineapple pizza", "drink pickle juice"],
500            )
501            .in_thread(thread_root, thread_root)
502            .event_id(event_id)
503            .sender(own_user_id)
504            .into(),
505        );
506
507        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
508
509        let reply_event = make_reply_event(
510            cache,
511            own_user_id,
512            content,
513            Reply {
514                event_id: event_id.into(),
515                enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
516                add_mentions: AddMentions::Yes,
517            },
518        )
519        .await
520        .unwrap();
521
522        assert_let!(Some(Relation::Thread(thread)) = &reply_event.relates_to);
523
524        assert_eq!(thread.event_id, thread_root);
525        assert_eq!(thread.in_reply_to.as_ref().unwrap().event_id, event_id);
526        assert!(thread.is_falling_back);
527    }
528
529    #[async_test]
530    async fn test_reply_without_add_mentions() {
531        let event_id = event_id!("$1");
532        let other_user_id = user_id!("@you:saucisse.bzh");
533        let own_user_id = user_id!("@me:saucisse.bzh");
534
535        let mut cache = TestEventCache::default();
536        let f = EventFactory::new();
537        cache.events.insert(
538            event_id.to_owned(),
539            f.text_msg("hi").event_id(event_id).sender(other_user_id).into(),
540        );
541
542        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
543
544        let reply_event = make_reply_event(
545            cache,
546            own_user_id,
547            content,
548            Reply {
549                event_id: event_id.into(),
550                enforce_thread: EnforceThread::Unthreaded,
551                add_mentions: AddMentions::No,
552            },
553        )
554        .await
555        .unwrap();
556
557        assert!(reply_event.mentions.is_none());
558    }
559
560    #[async_test]
561    async fn test_reply_with_add_mentions() {
562        let event_id = event_id!("$1");
563        let other_user_id = user_id!("@you:saucisse.bzh");
564        let own_user_id = user_id!("@me:saucisse.bzh");
565
566        let mut cache = TestEventCache::default();
567        let f = EventFactory::new();
568        cache.events.insert(
569            event_id.to_owned(),
570            f.text_msg("hi").event_id(event_id).sender(other_user_id).into(),
571        );
572
573        let content = RoomMessageEventContentWithoutRelation::text_plain("the reply");
574
575        let reply_event = make_reply_event(
576            cache,
577            own_user_id,
578            content,
579            Reply {
580                event_id: event_id.into(),
581                enforce_thread: EnforceThread::Unthreaded,
582                add_mentions: AddMentions::Yes,
583            },
584        )
585        .await
586        .unwrap();
587
588        assert!(reply_event.mentions.is_some());
589        assert!(reply_event.mentions.unwrap().user_ids.contains(user_id!("@you:saucisse.bzh")));
590    }
591}