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