matrix_sdk/room/
edit.rs

1// Copyright 2024 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 edit existing events.
16
17use std::future::Future;
18
19use matrix_sdk_base::{deserialized_responses::TimelineEvent, SendOutsideWasm};
20use ruma::{
21    events::{
22        poll::unstable_start::{
23            ReplacementUnstablePollStartEventContent, UnstablePollStartContentBlock,
24            UnstablePollStartEventContent,
25        },
26        room::message::{
27            FormattedBody, MessageType, Relation, ReplacementMetadata, RoomMessageEventContent,
28            RoomMessageEventContentWithoutRelation,
29        },
30        AnyMessageLikeEvent, AnyMessageLikeEventContent, AnySyncMessageLikeEvent,
31        AnySyncTimelineEvent, AnyTimelineEvent, Mentions, MessageLikeEvent,
32        OriginalMessageLikeEvent, SyncMessageLikeEvent,
33    },
34    EventId, RoomId, UserId,
35};
36use thiserror::Error;
37use tracing::{debug, instrument, trace, warn};
38
39use crate::Room;
40
41/// The new content that will replace the previous event's content.
42pub enum EditedContent {
43    /// The content is a `m.room.message`.
44    RoomMessage(RoomMessageEventContentWithoutRelation),
45
46    /// Tweak a caption for a `m.room.message` that's a media.
47    MediaCaption {
48        /// New caption for the media.
49        ///
50        /// Set to `None` to remove an existing caption.
51        caption: Option<String>,
52
53        /// New formatted caption for the media.
54        ///
55        /// Set to `None` to remove an existing formatted caption.
56        formatted_caption: Option<FormattedBody>,
57
58        /// New set of intentional mentions to be included in the edited
59        /// caption.
60        mentions: Option<Mentions>,
61    },
62
63    /// The content is a new poll start.
64    PollStart {
65        /// New fallback text for the poll.
66        fallback_text: String,
67        /// New start block for the poll.
68        new_content: UnstablePollStartContentBlock,
69    },
70}
71
72#[cfg(not(tarpaulin_include))]
73impl std::fmt::Debug for EditedContent {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::RoomMessage(_) => f.debug_tuple("RoomMessage").finish(),
77            Self::MediaCaption { .. } => f.debug_tuple("MediaCaption").finish(),
78            Self::PollStart { .. } => f.debug_tuple("PollStart").finish(),
79        }
80    }
81}
82
83/// An error occurring while editing an event.
84#[derive(Debug, Error)]
85pub enum EditError {
86    /// We tried to edit a state event, which is not allowed, per spec.
87    #[error("State events can't be edited")]
88    StateEvent,
89
90    /// We tried to edit an event which sender isn't the current user, which is
91    /// forbidden, per spec.
92    #[error("You're not the author of the event you'd like to edit.")]
93    NotAuthor,
94
95    /// We couldn't fetch the remote event with /room/event.
96    #[error("Couldn't fetch the remote event: {0}")]
97    Fetch(Box<crate::Error>),
98
99    /// We couldn't properly deserialize the target event.
100    #[error(transparent)]
101    Deserialize(#[from] serde_json::Error),
102
103    /// We tried to edit an event of type A with content of type B.
104    #[error("The original event type ({target}) isn't the same as the parameter's new content type ({new_content})")]
105    IncompatibleEditType {
106        /// The type of the target event.
107        target: String,
108        /// The type of the new content.
109        new_content: &'static str,
110    },
111}
112
113impl Room {
114    /// Create a new edit event for the target event id with the new content.
115    ///
116    /// The event can then be sent with [`Room::send`] or a
117    /// [`crate::send_queue::RoomSendQueue`].
118    #[instrument(skip(self, new_content), fields(room = %self.room_id()))]
119    pub async fn make_edit_event(
120        &self,
121        event_id: &EventId,
122        new_content: EditedContent,
123    ) -> Result<AnyMessageLikeEventContent, EditError> {
124        make_edit_event(self, self.room_id(), self.own_user_id(), event_id, new_content).await
125    }
126}
127
128trait EventSource {
129    fn get_event(
130        &self,
131        event_id: &EventId,
132    ) -> impl Future<Output = Result<TimelineEvent, EditError>> + SendOutsideWasm;
133}
134
135impl EventSource for &Room {
136    async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, EditError> {
137        match self.event_cache().await {
138            Ok((event_cache, _drop_handles)) => {
139                if let Some(event) = event_cache.event(event_id).await {
140                    return Ok(event);
141                }
142                // Fallthrough: try with /event.
143            }
144
145            Err(err) => {
146                debug!("error when getting the event cache: {err}");
147            }
148        }
149
150        trace!("trying with /event now");
151        self.event(event_id, None).await.map_err(|err| EditError::Fetch(Box::new(err)))
152    }
153}
154
155async fn make_edit_event<S: EventSource>(
156    source: S,
157    room_id: &RoomId,
158    own_user_id: &UserId,
159    event_id: &EventId,
160    new_content: EditedContent,
161) -> Result<AnyMessageLikeEventContent, EditError> {
162    let target = source.get_event(event_id).await?;
163
164    let event = target.raw().deserialize().map_err(EditError::Deserialize)?;
165
166    // The event must be message-like.
167    let AnySyncTimelineEvent::MessageLike(message_like_event) = event else {
168        return Err(EditError::StateEvent);
169    };
170
171    // The event must have been sent by the current user.
172    if message_like_event.sender() != own_user_id {
173        return Err(EditError::NotAuthor);
174    }
175
176    match new_content {
177        EditedContent::RoomMessage(new_content) => {
178            // Handle edits of m.room.message.
179            let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(original)) =
180                message_like_event
181            else {
182                return Err(EditError::IncompatibleEditType {
183                    target: message_like_event.event_type().to_string(),
184                    new_content: "room message",
185                });
186            };
187
188            let mentions = original.content.mentions.clone();
189            let replied_to_original_room_msg =
190                extract_replied_to(source, room_id, original.content.relates_to).await;
191
192            let replacement = new_content.make_replacement(
193                ReplacementMetadata::new(event_id.to_owned(), mentions),
194                replied_to_original_room_msg.as_ref(),
195            );
196
197            Ok(replacement.into())
198        }
199
200        EditedContent::MediaCaption { caption, formatted_caption, mentions } => {
201            // Handle edits of m.room.message.
202            let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(original)) =
203                message_like_event
204            else {
205                return Err(EditError::IncompatibleEditType {
206                    target: message_like_event.event_type().to_string(),
207                    new_content: "caption for a media room message",
208                });
209            };
210
211            let original_mentions = original.content.mentions.clone();
212            let replied_to_original_room_msg =
213                extract_replied_to(source, room_id, original.content.relates_to.clone()).await;
214
215            let mut prev_content = original.content;
216
217            if !update_media_caption(&mut prev_content, caption, formatted_caption, mentions) {
218                return Err(EditError::IncompatibleEditType {
219                    target: prev_content.msgtype.msgtype().to_owned(),
220                    new_content: "caption for a media room message",
221                });
222            }
223
224            let replacement = prev_content.make_replacement(
225                ReplacementMetadata::new(event_id.to_owned(), original_mentions),
226                replied_to_original_room_msg.as_ref(),
227            );
228
229            Ok(replacement.into())
230        }
231
232        EditedContent::PollStart { fallback_text, new_content } => {
233            if !matches!(
234                message_like_event,
235                AnySyncMessageLikeEvent::UnstablePollStart(SyncMessageLikeEvent::Original(_))
236            ) {
237                return Err(EditError::IncompatibleEditType {
238                    target: message_like_event.event_type().to_string(),
239                    new_content: "poll start",
240                });
241            }
242
243            let replacement = UnstablePollStartEventContent::Replacement(
244                ReplacementUnstablePollStartEventContent::plain_text(
245                    fallback_text,
246                    new_content,
247                    event_id.to_owned(),
248                ),
249            );
250
251            Ok(replacement.into())
252        }
253    }
254}
255
256/// Sets the caption of a media event content.
257///
258/// Why a macro over a plain function: the event content types all differ from
259/// each other, and it would require adding a trait and implementing it for all
260/// event types instead of having this simple macro.
261macro_rules! set_caption {
262    ($event:expr, $caption:expr) => {
263        let filename = $event.filename().to_owned();
264        // As a reminder:
265        // - body and no filename set means the body is the filename
266        // - body and filename set means the body is the caption, and filename is the
267        //   filename.
268        if let Some(caption) = $caption {
269            $event.filename = Some(filename);
270            $event.body = caption;
271        } else {
272            $event.filename = None;
273            $event.body = filename;
274        }
275    };
276}
277
278/// Sets the caption of a [`RoomMessageEventContent`].
279///
280/// Returns true if the event represented a media event (and thus the captions
281/// could be updated), false otherwise.
282pub(crate) fn update_media_caption(
283    content: &mut RoomMessageEventContent,
284    caption: Option<String>,
285    formatted_caption: Option<FormattedBody>,
286    mentions: Option<Mentions>,
287) -> bool {
288    content.mentions = mentions;
289
290    match &mut content.msgtype {
291        MessageType::Audio(event) => {
292            set_caption!(event, caption);
293            event.formatted = formatted_caption;
294            true
295        }
296        MessageType::File(event) => {
297            set_caption!(event, caption);
298            event.formatted = formatted_caption;
299            true
300        }
301        MessageType::Image(event) => {
302            set_caption!(event, caption);
303            event.formatted = formatted_caption;
304            true
305        }
306        MessageType::Video(event) => {
307            set_caption!(event, caption);
308            event.formatted = formatted_caption;
309            true
310        }
311        _ => false,
312    }
313}
314
315/// Try to find the original replied-to event content, in a best-effort manner.
316async fn extract_replied_to<S: EventSource>(
317    source: S,
318    room_id: &RoomId,
319    relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
320) -> Option<OriginalMessageLikeEvent<RoomMessageEventContent>> {
321    let replied_to_sync_timeline_event = if let Some(Relation::Reply { in_reply_to }) = relates_to {
322        source
323            .get_event(&in_reply_to.event_id)
324            .await
325            .map_err(|err| {
326                warn!("couldn't fetch the replied-to event, when editing: {err}");
327                err
328            })
329            .ok()
330    } else {
331        None
332    };
333
334    replied_to_sync_timeline_event
335        .and_then(|sync_timeline_event| {
336            sync_timeline_event
337                .raw()
338                .deserialize()
339                .map_err(|err| warn!("unable to deserialize replied-to event: {err}"))
340                .ok()
341        })
342        .and_then(|event| {
343            if let AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(
344                MessageLikeEvent::Original(original),
345            )) = event.into_full_event(room_id.to_owned())
346            {
347                Some(original)
348            } else {
349                None
350            }
351        })
352}
353
354#[cfg(test)]
355mod tests {
356    use std::collections::BTreeMap;
357
358    use assert_matches2::{assert_let, assert_matches};
359    use matrix_sdk_base::deserialized_responses::TimelineEvent;
360    use matrix_sdk_test::{async_test, event_factory::EventFactory};
361    use ruma::{
362        event_id,
363        events::{
364            room::message::{MessageType, Relation, RoomMessageEventContentWithoutRelation},
365            AnyMessageLikeEventContent, AnySyncTimelineEvent, Mentions,
366        },
367        owned_mxc_uri, owned_user_id, room_id,
368        serde::Raw,
369        user_id, EventId, OwnedEventId,
370    };
371    use serde_json::json;
372
373    use super::{make_edit_event, EditError, EventSource};
374    use crate::room::edit::EditedContent;
375
376    #[derive(Default)]
377    struct TestEventCache {
378        events: BTreeMap<OwnedEventId, TimelineEvent>,
379    }
380
381    impl EventSource for TestEventCache {
382        async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, EditError> {
383            Ok(self.events.get(event_id).unwrap().clone())
384        }
385    }
386
387    #[async_test]
388    async fn test_edit_state_event() {
389        let event_id = event_id!("$1");
390        let own_user_id = user_id!("@me:saucisse.bzh");
391
392        let mut cache = TestEventCache::default();
393
394        cache.events.insert(
395            event_id.to_owned(),
396            // TODO: use the EventFactory for state events too.
397            TimelineEvent::new(
398                Raw::<AnySyncTimelineEvent>::from_json_string(
399                    json!({
400                        "content": {
401                            "name": "The room name"
402                        },
403                        "event_id": event_id,
404                        "sender": own_user_id,
405                        "state_key": "",
406                        "origin_server_ts": 1,
407                        "type": "m.room.name",
408                    })
409                    .to_string(),
410                )
411                .unwrap(),
412            ),
413        );
414
415        let room_id = room_id!("!galette:saucisse.bzh");
416        let new_content = RoomMessageEventContentWithoutRelation::text_plain("the edit");
417
418        assert_matches!(
419            make_edit_event(
420                cache,
421                room_id,
422                own_user_id,
423                event_id,
424                EditedContent::RoomMessage(new_content),
425            )
426            .await,
427            Err(EditError::StateEvent)
428        );
429    }
430
431    #[async_test]
432    async fn test_edit_event_other_user() {
433        let event_id = event_id!("$1");
434        let f = EventFactory::new();
435
436        let mut cache = TestEventCache::default();
437
438        cache.events.insert(
439            event_id.to_owned(),
440            f.text_msg("hi").event_id(event_id).sender(user_id!("@other:saucisse.bzh")).into(),
441        );
442
443        let room_id = room_id!("!galette:saucisse.bzh");
444        let own_user_id = user_id!("@me:saucisse.bzh");
445        let new_content = RoomMessageEventContentWithoutRelation::text_plain("the edit");
446
447        assert_matches!(
448            make_edit_event(
449                cache,
450                room_id,
451                own_user_id,
452                event_id,
453                EditedContent::RoomMessage(new_content),
454            )
455            .await,
456            Err(EditError::NotAuthor)
457        );
458    }
459
460    #[async_test]
461    async fn test_make_edit_event_success() {
462        let event_id = event_id!("$1");
463        let own_user_id = user_id!("@me:saucisse.bzh");
464
465        let mut cache = TestEventCache::default();
466        let f = EventFactory::new();
467        cache.events.insert(
468            event_id.to_owned(),
469            f.text_msg("hi").event_id(event_id).sender(own_user_id).into(),
470        );
471
472        let room_id = room_id!("!galette:saucisse.bzh");
473        let new_content = RoomMessageEventContentWithoutRelation::text_plain("the edit");
474
475        let edit_event = make_edit_event(
476            cache,
477            room_id,
478            own_user_id,
479            event_id,
480            EditedContent::RoomMessage(new_content),
481        )
482        .await
483        .unwrap();
484
485        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &edit_event);
486        // This is the fallback text, for clients not supporting edits.
487        assert_eq!(msg.body(), "* the edit");
488        assert_let!(Some(Relation::Replacement(repl)) = &msg.relates_to);
489
490        assert_eq!(repl.event_id, event_id);
491        assert_eq!(repl.new_content.msgtype.body(), "the edit");
492    }
493
494    #[async_test]
495    async fn test_make_edit_caption_for_non_media_room_message() {
496        let event_id = event_id!("$1");
497        let own_user_id = user_id!("@me:saucisse.bzh");
498
499        let mut cache = TestEventCache::default();
500        let f = EventFactory::new();
501        cache.events.insert(
502            event_id.to_owned(),
503            f.text_msg("hello world").event_id(event_id).sender(own_user_id).into(),
504        );
505
506        let room_id = room_id!("!galette:saucisse.bzh");
507
508        let err = make_edit_event(
509            cache,
510            room_id,
511            own_user_id,
512            event_id,
513            EditedContent::MediaCaption {
514                caption: Some("yo".to_owned()),
515                formatted_caption: None,
516                mentions: None,
517            },
518        )
519        .await
520        .unwrap_err();
521
522        assert_let!(EditError::IncompatibleEditType { target, new_content } = err);
523        assert_eq!(target, "m.text");
524        assert_eq!(new_content, "caption for a media room message");
525    }
526
527    #[async_test]
528    async fn test_add_caption_for_media() {
529        let event_id = event_id!("$1");
530        let own_user_id = user_id!("@me:saucisse.bzh");
531
532        let filename = "rickroll.gif";
533
534        let mut cache = TestEventCache::default();
535        let f = EventFactory::new();
536        cache.events.insert(
537            event_id.to_owned(),
538            f.image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll"))
539                .event_id(event_id)
540                .sender(own_user_id)
541                .into(),
542        );
543
544        let room_id = room_id!("!galette:saucisse.bzh");
545
546        let edit_event = make_edit_event(
547            cache,
548            room_id,
549            own_user_id,
550            event_id,
551            EditedContent::MediaCaption {
552                caption: Some("Best joke ever".to_owned()),
553                formatted_caption: None,
554                mentions: None,
555            },
556        )
557        .await
558        .unwrap();
559
560        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event);
561        assert_let!(MessageType::Image(image) = msg.msgtype);
562
563        assert_eq!(image.filename(), filename);
564        assert_eq!(image.caption(), Some("* Best joke ever")); // Fallback for a replacement 🤷
565        assert!(image.formatted_caption().is_none());
566
567        assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to);
568        assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype);
569        assert_eq!(new_image.filename(), filename);
570        assert_eq!(new_image.caption(), Some("Best joke ever"));
571        assert!(new_image.formatted_caption().is_none());
572    }
573
574    #[async_test]
575    async fn test_remove_caption_for_media() {
576        let event_id = event_id!("$1");
577        let own_user_id = user_id!("@me:saucisse.bzh");
578
579        let filename = "rickroll.gif";
580
581        let mut cache = TestEventCache::default();
582        let f = EventFactory::new();
583
584        let event = f
585            .image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll"))
586            .caption(Some("caption".to_owned()), None)
587            .event_id(event_id)
588            .sender(own_user_id)
589            .into_event();
590
591        {
592            // Sanity checks.
593            let event = event.raw().deserialize().unwrap();
594            assert_let!(AnySyncTimelineEvent::MessageLike(event) = event);
595            assert_let!(
596                AnyMessageLikeEventContent::RoomMessage(msg) = event.original_content().unwrap()
597            );
598            assert_let!(MessageType::Image(image) = msg.msgtype);
599            assert_eq!(image.filename(), filename);
600            assert_eq!(image.caption(), Some("caption"));
601            assert!(image.formatted_caption().is_none());
602        }
603
604        cache.events.insert(event_id.to_owned(), event);
605
606        let room_id = room_id!("!galette:saucisse.bzh");
607
608        let edit_event = make_edit_event(
609            cache,
610            room_id,
611            own_user_id,
612            event_id,
613            // Remove the caption by setting it to None.
614            EditedContent::MediaCaption { caption: None, formatted_caption: None, mentions: None },
615        )
616        .await
617        .unwrap();
618
619        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event);
620        assert_let!(MessageType::Image(image) = msg.msgtype);
621
622        assert_eq!(image.filename(), "* rickroll.gif"); // Fallback for a replacement 🤷
623        assert!(image.caption().is_none());
624        assert!(image.formatted_caption().is_none());
625
626        assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to);
627        assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype);
628        assert_eq!(new_image.filename(), "rickroll.gif");
629        assert!(new_image.caption().is_none());
630        assert!(new_image.formatted_caption().is_none());
631    }
632
633    #[async_test]
634    async fn test_add_media_caption_mention() {
635        let event_id = event_id!("$1");
636        let own_user_id = user_id!("@me:saucisse.bzh");
637
638        let filename = "rickroll.gif";
639
640        let mut cache = TestEventCache::default();
641        let f = EventFactory::new();
642
643        // Start with a media event that has no mentions.
644        let event = f
645            .image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll"))
646            .event_id(event_id)
647            .sender(own_user_id)
648            .into_event();
649
650        {
651            // Sanity checks.
652            let event = event.raw().deserialize().unwrap();
653            assert_let!(AnySyncTimelineEvent::MessageLike(event) = event);
654            assert_let!(
655                AnyMessageLikeEventContent::RoomMessage(msg) = event.original_content().unwrap()
656            );
657            assert_matches!(msg.mentions, None);
658        }
659
660        cache.events.insert(event_id.to_owned(), event);
661
662        let room_id = room_id!("!galette:saucisse.bzh");
663
664        // Add an intentional mention in the caption.
665        let mentioned_user_id = owned_user_id!("@crepe:saucisse.bzh");
666        let edit_event = {
667            let mentions = Mentions::with_user_ids([mentioned_user_id.clone()]);
668            make_edit_event(
669                cache,
670                room_id,
671                own_user_id,
672                event_id,
673                EditedContent::MediaCaption {
674                    caption: None,
675                    formatted_caption: None,
676                    mentions: Some(mentions),
677                },
678            )
679            .await
680            .unwrap()
681        };
682
683        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event);
684        assert_let!(MessageType::Image(image) = msg.msgtype);
685
686        assert!(image.caption().is_none());
687        assert!(image.formatted_caption().is_none());
688
689        // The raw event contains the mention.
690        assert_let!(Some(mentions) = msg.mentions);
691        assert!(!mentions.room);
692        assert_eq!(
693            mentions.user_ids.into_iter().collect::<Vec<_>>(),
694            vec![mentioned_user_id.clone()]
695        );
696
697        assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to);
698        assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype);
699        assert!(new_image.caption().is_none());
700        assert!(new_image.formatted_caption().is_none());
701
702        // The replacement contains the mention.
703        assert_let!(Some(mentions) = repl.new_content.mentions);
704        assert!(!mentions.room);
705        assert_eq!(mentions.user_ids.into_iter().collect::<Vec<_>>(), vec![mentioned_user_id]);
706    }
707
708    #[async_test]
709    async fn test_make_edit_event_success_with_response() {
710        let event_id = event_id!("$1");
711        let resp_event_id = event_id!("$resp");
712        let own_user_id = user_id!("@me:saucisse.bzh");
713
714        let mut cache = TestEventCache::default();
715        let f = EventFactory::new();
716
717        cache.events.insert(
718            event_id.to_owned(),
719            f.text_msg("hi").event_id(event_id).sender(user_id!("@steb:saucisse.bzh")).into(),
720        );
721
722        cache.events.insert(
723            resp_event_id.to_owned(),
724            f.text_msg("you're the hi")
725                .event_id(resp_event_id)
726                .sender(own_user_id)
727                .reply_to(event_id)
728                .into(),
729        );
730
731        let room_id = room_id!("!galette:saucisse.bzh");
732        let new_content = RoomMessageEventContentWithoutRelation::text_plain("uh i mean hi too");
733
734        let edit_event = make_edit_event(
735            cache,
736            room_id,
737            own_user_id,
738            resp_event_id,
739            EditedContent::RoomMessage(new_content),
740        )
741        .await
742        .unwrap();
743
744        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &edit_event);
745        // This is the fallback text, for clients not supporting edits.
746        assert_eq!(
747            msg.body(),
748            r#"> <@steb:saucisse.bzh> hi
749
750* uh i mean hi too"#
751        );
752        assert_let!(Some(Relation::Replacement(repl)) = &msg.relates_to);
753
754        assert_eq!(repl.event_id, resp_event_id);
755        assert_eq!(repl.new_content.msgtype.body(), "uh i mean hi too");
756    }
757}