room: create edits to add a caption to a media event

This commit is contained in:
Benjamin Bouvier
2024-11-18 12:23:35 +01:00
parent 8a6ced0e8f
commit 900cf5d071
3 changed files with 325 additions and 43 deletions

View File

@@ -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)? {

View File

@@ -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<String>,
/// New formatted caption for the media.
///
/// Set to `None` to remove an existing formatted caption.
formatted_caption: Option<FormattedBody>,
},
/// 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<S: EventSource>(
source: S,
room_id: &RoomId,
@@ -167,47 +207,66 @@ async fn make_edit_event<S: EventSource>(
};
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<S: EventSource>(
}
}
/// Try to find the original replied-to event content, in a best-effort manner.
async fn extract_replied_to<S: EventSource>(
source: S,
room_id: &RoomId,
relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
) -> Option<OriginalMessageLikeEvent<RoomMessageEventContent>> {
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");

View File

@@ -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<RoomMessageEventContent> {
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<String>,
formatted_caption: Option<FormattedBody>,
) -> 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<E: EventContent> From<EventBuilder<E>> for Raw<AnySyncTimelineEvent>
@@ -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<RoomMessageEventContent> {
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.