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