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