From 900cf5d071f7dca613996b4305a4834fbcd7e510 Mon Sep 17 00:00:00 2001 From: Benjamin Bouvier Date: Mon, 18 Nov 2024 12:23:35 +0100 Subject: [PATCH] room: create edits to add a caption to a media event --- crates/matrix-sdk-ui/src/timeline/mod.rs | 5 + crates/matrix-sdk/src/room/edit.rs | 314 ++++++++++++++++++--- crates/matrix-sdk/src/test_utils/events.rs | 49 +++- 3 files changed, 325 insertions(+), 43 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index b5bd98b10..93f738185 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -461,6 +461,7 @@ impl Timeline { .into()); } } + EditedContent::PollStart { new_content, .. } => { if matches!(item.content, TimelineItemContent::Poll(_)) { AnyMessageLikeEventContent::UnstablePollStart( @@ -476,6 +477,10 @@ impl Timeline { .into()); } } + + EditedContent::MediaCaption { caption: _, formatted_caption: _ } => { + todo!("bnjbvr you had one job"); + } }; if !handle.edit(new_content).await.map_err(RoomSendQueueError::StorageError)? { diff --git a/crates/matrix-sdk/src/room/edit.rs b/crates/matrix-sdk/src/room/edit.rs index 0ed178ff6..4f79fb561 100644 --- a/crates/matrix-sdk/src/room/edit.rs +++ b/crates/matrix-sdk/src/room/edit.rs @@ -23,9 +23,13 @@ use ruma::{ ReplacementUnstablePollStartEventContent, UnstablePollStartContentBlock, UnstablePollStartEventContent, }, - room::message::{Relation, ReplacementMetadata, RoomMessageEventContentWithoutRelation}, + room::message::{ + FormattedBody, MessageType, Relation, ReplacementMetadata, RoomMessageEventContent, + RoomMessageEventContentWithoutRelation, + }, AnyMessageLikeEvent, AnyMessageLikeEventContent, AnySyncMessageLikeEvent, - AnySyncTimelineEvent, AnyTimelineEvent, MessageLikeEvent, SyncMessageLikeEvent, + AnySyncTimelineEvent, AnyTimelineEvent, MessageLikeEvent, OriginalMessageLikeEvent, + SyncMessageLikeEvent, }, EventId, RoomId, UserId, }; @@ -39,6 +43,19 @@ pub enum EditedContent { /// The content is a `m.room.message`. RoomMessage(RoomMessageEventContentWithoutRelation), + /// Tweak a caption for a `m.room.message` that's a media. + MediaCaption { + /// New caption for the media. + /// + /// Set to `None` to remove an existing caption. + caption: Option, + + /// New formatted caption for the media. + /// + /// Set to `None` to remove an existing formatted caption. + formatted_caption: Option, + }, + /// The content is a new poll start. PollStart { /// New fallback text for the poll. @@ -53,6 +70,7 @@ impl std::fmt::Debug for EditedContent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::RoomMessage(_) => f.debug_tuple("RoomMessage").finish(), + Self::MediaCaption { .. } => f.debug_tuple("MediaCaption").finish(), Self::PollStart { .. } => f.debug_tuple("PollStart").finish(), } } @@ -133,6 +151,28 @@ impl<'a> EventSource for &'a Room { } } +/// Sets the caption of a media event content. +/// +/// Why a macro over a plain function: the event content types all differ from +/// each other, and it would require adding a trait and implementing it for all +/// event types instead of having this simple macro. +macro_rules! set_caption { + ($event:expr, $caption:expr) => { + let filename = $event.filename().to_owned(); + // As a reminder: + // - body and no filename set means the body is the filename + // - body and filename set means the body is the caption, and filename is the + // filename. + if let Some(caption) = $caption { + $event.filename = Some(filename); + $event.body = caption; + } else { + $event.filename = None; + $event.body = filename; + } + }; +} + async fn make_edit_event( source: S, room_id: &RoomId, @@ -167,47 +207,66 @@ async fn make_edit_event( }; let mentions = original.content.mentions.clone(); + let replied_to_original_room_msg = + extract_replied_to(source, room_id, original.content.relates_to).await; - // Do a best effort at finding the replied-to original event. - let replied_to_sync_timeline_event = - if let Some(Relation::Reply { in_reply_to }) = original.content.relates_to { - source - .get_event(&in_reply_to.event_id) - .await - .map_err(|err| { - warn!("couldn't fetch the replied-to event, when editing: {err}"); - err - }) - .ok() - } else { - None - }; + let replacement = new_content.make_replacement( + ReplacementMetadata::new(event_id.to_owned(), mentions), + replied_to_original_room_msg.as_ref(), + ); - let replied_to_original_room_msg = replied_to_sync_timeline_event - .and_then(|sync_timeline_event| { - sync_timeline_event - .raw() - .deserialize() - .map_err(|err| warn!("unable to deserialize replied-to event: {err}")) - .ok() - }) - .and_then(|event| { - if let AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage( - MessageLikeEvent::Original(original), - )) = event.into_full_event(room_id.to_owned()) - { - Some(original) - } else { - None - } + Ok(replacement.into()) + } + + EditedContent::MediaCaption { caption, formatted_caption } => { + // Handle edits of m.room.message. + let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(original)) = + message_like_event + else { + return Err(EditError::IncompatibleEditType { + target: message_like_event.event_type().to_string(), + new_content: "caption for a media room message", }); + }; - Ok(new_content - .make_replacement( - ReplacementMetadata::new(event_id.to_owned(), mentions), - replied_to_original_room_msg.as_ref(), - ) - .into()) + let mentions = original.content.mentions.clone(); + let replied_to_original_room_msg = + extract_replied_to(source, room_id, original.content.relates_to.clone()).await; + + let mut prev_content = original.content; + + match &mut prev_content.msgtype { + MessageType::Audio(event) => { + set_caption!(event, caption); + event.formatted = formatted_caption; + } + MessageType::File(event) => { + set_caption!(event, caption); + event.formatted = formatted_caption; + } + MessageType::Image(event) => { + set_caption!(event, caption); + event.formatted = formatted_caption; + } + MessageType::Video(event) => { + set_caption!(event, caption); + event.formatted = formatted_caption; + } + + _ => { + return Err(EditError::IncompatibleEditType { + target: prev_content.msgtype.msgtype().to_owned(), + new_content: "caption for a media room message", + }) + } + } + + let replacement = prev_content.make_replacement( + ReplacementMetadata::new(event_id.to_owned(), mentions), + replied_to_original_room_msg.as_ref(), + ); + + Ok(replacement.into()) } EditedContent::PollStart { fallback_text, new_content } => { @@ -234,6 +293,45 @@ async fn make_edit_event( } } +/// Try to find the original replied-to event content, in a best-effort manner. +async fn extract_replied_to( + source: S, + room_id: &RoomId, + relates_to: Option>, +) -> Option> { + let replied_to_sync_timeline_event = if let Some(Relation::Reply { in_reply_to }) = relates_to { + source + .get_event(&in_reply_to.event_id) + .await + .map_err(|err| { + warn!("couldn't fetch the replied-to event, when editing: {err}"); + err + }) + .ok() + } else { + None + }; + + replied_to_sync_timeline_event + .and_then(|sync_timeline_event| { + sync_timeline_event + .raw() + .deserialize() + .map_err(|err| warn!("unable to deserialize replied-to event: {err}")) + .ok() + }) + .and_then(|event| { + if let AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage( + MessageLikeEvent::Original(original), + )) = event.into_full_event(room_id.to_owned()) + { + Some(original) + } else { + None + } + }) +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -244,10 +342,10 @@ mod tests { use ruma::{ event_id, events::{ - room::message::{Relation, RoomMessageEventContentWithoutRelation}, + room::message::{MessageType, Relation, RoomMessageEventContentWithoutRelation}, AnyMessageLikeEventContent, AnySyncTimelineEvent, }, - room_id, + owned_mxc_uri, room_id, serde::Raw, user_id, EventId, OwnedEventId, }; @@ -374,6 +472,140 @@ mod tests { assert_eq!(repl.new_content.msgtype.body(), "the edit"); } + #[async_test] + async fn test_make_edit_caption_for_non_media_room_message() { + let event_id = event_id!("$1"); + let own_user_id = user_id!("@me:saucisse.bzh"); + + let mut cache = TestEventCache::default(); + let f = EventFactory::new(); + cache.events.insert( + event_id.to_owned(), + f.text_msg("hello world").event_id(event_id).sender(own_user_id).into(), + ); + + let room_id = room_id!("!galette:saucisse.bzh"); + + let err = make_edit_event( + cache, + room_id, + own_user_id, + event_id, + EditedContent::MediaCaption { caption: Some("yo".to_owned()), formatted_caption: None }, + ) + .await + .unwrap_err(); + + assert_let!(EditError::IncompatibleEditType { target, new_content } = err); + assert_eq!(target, "m.text"); + assert_eq!(new_content, "caption for a media room message"); + } + + #[async_test] + async fn test_add_caption_for_media() { + let event_id = event_id!("$1"); + let own_user_id = user_id!("@me:saucisse.bzh"); + + let filename = "rickroll.gif"; + + let mut cache = TestEventCache::default(); + let f = EventFactory::new(); + cache.events.insert( + event_id.to_owned(), + f.image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll")) + .event_id(event_id) + .sender(own_user_id) + .into(), + ); + + let room_id = room_id!("!galette:saucisse.bzh"); + + let edit_event = make_edit_event( + cache, + room_id, + own_user_id, + event_id, + EditedContent::MediaCaption { + caption: Some("Best joke ever".to_owned()), + formatted_caption: None, + }, + ) + .await + .unwrap(); + + assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event); + assert_let!(MessageType::Image(image) = msg.msgtype); + + assert_eq!(image.filename(), filename); + assert_eq!(image.caption(), Some("* Best joke ever")); // Fallback for a replacement 🤷 + assert!(image.formatted_caption().is_none()); + + assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to); + assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype); + assert_eq!(new_image.filename(), filename); + assert_eq!(new_image.caption(), Some("Best joke ever")); + assert!(new_image.formatted_caption().is_none()); + } + + #[async_test] + async fn test_remove_caption_for_media() { + let event_id = event_id!("$1"); + let own_user_id = user_id!("@me:saucisse.bzh"); + + let filename = "rickroll.gif"; + + let mut cache = TestEventCache::default(); + let f = EventFactory::new(); + + let event: SyncTimelineEvent = f + .image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll")) + .caption(Some("caption".to_owned()), None) + .event_id(event_id) + .sender(own_user_id) + .into(); + + { + // Sanity checks. + let event = event.raw().deserialize().unwrap(); + assert_let!(AnySyncTimelineEvent::MessageLike(event) = event); + assert_let!( + AnyMessageLikeEventContent::RoomMessage(msg) = event.original_content().unwrap() + ); + assert_let!(MessageType::Image(image) = msg.msgtype); + assert_eq!(image.filename(), filename); + assert_eq!(image.caption(), Some("caption")); + assert!(image.formatted_caption().is_none()); + } + + cache.events.insert(event_id.to_owned(), event); + + let room_id = room_id!("!galette:saucisse.bzh"); + + let edit_event = make_edit_event( + cache, + room_id, + own_user_id, + event_id, + // Remove the caption by setting it to None. + EditedContent::MediaCaption { caption: None, formatted_caption: None }, + ) + .await + .unwrap(); + + assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event); + assert_let!(MessageType::Image(image) = msg.msgtype); + + assert_eq!(image.filename(), "* rickroll.gif"); // Fallback for a replacement 🤷 + assert!(image.caption().is_none()); + assert!(image.formatted_caption().is_none()); + + assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to); + assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype); + assert_eq!(new_image.filename(), "rickroll.gif"); + assert!(new_image.caption().is_none()); + assert!(new_image.formatted_caption().is_none()); + } + #[async_test] async fn test_make_edit_event_success_with_response() { let event_id = event_id!("$1"); diff --git a/crates/matrix-sdk/src/test_utils/events.rs b/crates/matrix-sdk/src/test_utils/events.rs index 3ecaebb5b..6f447a64b 100644 --- a/crates/matrix-sdk/src/test_utils/events.rs +++ b/crates/matrix-sdk/src/test_utils/events.rs @@ -34,13 +34,16 @@ use ruma::{ relation::{Annotation, InReplyTo, Replacement, Thread}, room::{ encrypted::{EncryptedEventScheme, RoomEncryptedEventContent}, - message::{Relation, RoomMessageEventContent, RoomMessageEventContentWithoutRelation}, + message::{ + FormattedBody, ImageMessageEventContent, MessageType, Relation, + RoomMessageEventContent, RoomMessageEventContentWithoutRelation, + }, redaction::RoomRedactionEventContent, }, AnySyncTimelineEvent, AnyTimelineEvent, BundledMessageLikeRelations, EventContent, }, serde::Raw, - server_name, EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, + server_name, EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedTransactionId, OwnedUserId, RoomId, TransactionId, UInt, UserId, }; use serde::Serialize; @@ -232,6 +235,37 @@ impl EventBuilder { Some(Relation::Replacement(Replacement::new(edited_event_id.to_owned(), new_content))); self } + + /// Adds a caption to a media event. + /// + /// Will crash if the event isn't a media room message. + pub fn caption( + mut self, + caption: Option, + formatted_caption: Option, + ) -> Self { + match &mut self.content.msgtype { + MessageType::Image(image) => { + let filename = image.filename().to_owned(); + if let Some(caption) = caption { + image.body = caption; + image.filename = Some(filename); + } else { + image.body = filename; + image.filename = None; + } + image.formatted = formatted_caption; + } + + MessageType::Audio(_) | MessageType::Video(_) | MessageType::File(_) => { + unimplemented!(); + } + + _ => panic!("unexpected event type for a caption"), + } + + self + } } impl From> for Raw @@ -413,6 +447,17 @@ impl EventFactory { self.event(poll_end_content) } + /// Creates a plain (unencrypted) image event content referencing the given + /// MXC ID. + pub fn image( + &self, + filename: String, + url: OwnedMxcUri, + ) -> EventBuilder { + let image_event_content = ImageMessageEventContent::plain(filename, url); + self.event(RoomMessageEventContent::new(MessageType::Image(image_event_content))) + } + /// Set the next server timestamp. /// /// Timestamps will continue to increase by 1 (millisecond) from that value.