diff --git a/bindings/matrix-sdk-ffi/Cargo.toml b/bindings/matrix-sdk-ffi/Cargo.toml index bc6f452f1ec..f5f441e9cf4 100644 --- a/bindings/matrix-sdk-ffi/Cargo.toml +++ b/bindings/matrix-sdk-ffi/Cargo.toml @@ -17,7 +17,7 @@ release = true crate-type = ["cdylib", "staticlib"] [features] -default = ["bundled-sqlite"] +default = ["bundled-sqlite", "matrix-sdk-ui/unstable-msc4274"] bundled-sqlite = ["matrix-sdk/bundled-sqlite"] [dependencies] diff --git a/crates/matrix-sdk-ui/CHANGELOG.md b/crates/matrix-sdk-ui/CHANGELOG.md index 2e13829578c..fc0bf4cc9af 100644 --- a/crates/matrix-sdk-ui/CHANGELOG.md +++ b/crates/matrix-sdk-ui/CHANGELOG.md @@ -27,6 +27,9 @@ All notable changes to this project will be documented in this file. ([#5055](https://github.com/matrix-org/matrix-rust-sdk/pull/5055)) - `Timeline::mark_as_read()` unsets the unread flag of the room if it was set. ([#5055](https://github.com/matrix-org/matrix-rust-sdk/pull/5055)) +- Add new method `Timeline::send_gallery` to allow sending MSC4274-style + galleries. + ([#5125](https://github.com/matrix-org/matrix-rust-sdk/pull/5125)) ## [0.11.0] - 2025-04-11 diff --git a/crates/matrix-sdk-ui/Cargo.toml b/crates/matrix-sdk-ui/Cargo.toml index 938a384db96..892c4b2bb56 100644 --- a/crates/matrix-sdk-ui/Cargo.toml +++ b/crates/matrix-sdk-ui/Cargo.toml @@ -22,6 +22,9 @@ uniffi = ["dep:uniffi", "matrix-sdk/uniffi", "matrix-sdk-base/uniffi"] # Add support for encrypted extensible events. unstable-msc3956 = ["ruma/unstable-msc3956"] +# Add support for inline media galleries via msgtypes +unstable-msc4274 = ["matrix-sdk/unstable-msc4274"] + [dependencies] as_variant.workspace = true async-rx.workspace = true diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index 492ac613fa6..cd9a8a733fb 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -232,19 +232,21 @@ pub fn default_event_filter(event: &AnySyncTimelineEvent, room_version: &RoomVer return false; } - matches!( - content.msgtype, + match content.msgtype { MessageType::Audio(_) - | MessageType::Emote(_) - | MessageType::File(_) - | MessageType::Image(_) - | MessageType::Location(_) - | MessageType::Notice(_) - | MessageType::ServerNotice(_) - | MessageType::Text(_) - | MessageType::Video(_) - | MessageType::VerificationRequest(_) - ) + | MessageType::Emote(_) + | MessageType::File(_) + | MessageType::Image(_) + | MessageType::Location(_) + | MessageType::Notice(_) + | MessageType::ServerNotice(_) + | MessageType::Text(_) + | MessageType::Video(_) + | MessageType::VerificationRequest(_) => true, + #[cfg(feature = "unstable-msc4274")] + MessageType::Gallery(_) => true, + _ => false, + } } AnyMessageLikeEventContent::Sticker(_) diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs index cf8870e9fc2..536424b9477 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -362,17 +362,17 @@ impl EventTimelineItem { match self.content() { TimelineItemContent::MsgLike(msglike) => match &msglike.kind { - MsgLikeKind::Message(message) => { - matches!( - message.msgtype(), - MessageType::Text(_) - | MessageType::Emote(_) - | MessageType::Audio(_) - | MessageType::File(_) - | MessageType::Image(_) - | MessageType::Video(_) - ) - } + MsgLikeKind::Message(message) => match message.msgtype() { + MessageType::Text(_) + | MessageType::Emote(_) + | MessageType::Audio(_) + | MessageType::File(_) + | MessageType::Image(_) + | MessageType::Video(_) => true, + #[cfg(feature = "unstable-msc4274")] + MessageType::Gallery(_) => true, + _ => false, + }, MsgLikeKind::Poll(poll) => { poll.response_data.is_empty() && poll.end_event_timestamp.is_none() } diff --git a/crates/matrix-sdk-ui/src/timeline/futures.rs b/crates/matrix-sdk-ui/src/timeline/futures.rs index 3f0f1eb35aa..bb9af758747 100644 --- a/crates/matrix-sdk-ui/src/timeline/futures.rs +++ b/crates/matrix-sdk-ui/src/timeline/futures.rs @@ -92,3 +92,48 @@ impl<'a> IntoFuture for SendAttachment<'a> { Box::pin(fut.instrument(tracing_span)) } } + +#[cfg(feature = "unstable-msc4274")] +pub use galleries::*; + +#[cfg(feature = "unstable-msc4274")] +mod galleries { + use std::future::IntoFuture; + + use matrix_sdk::attachment::GalleryConfig; + use matrix_sdk_base::boxed_into_future; + use tracing::{Instrument as _, Span}; + + use super::{Error, Timeline}; + + pub struct SendGallery<'a> { + timeline: &'a Timeline, + gallery: GalleryConfig, + tracing_span: Span, + } + + impl<'a> SendGallery<'a> { + pub(crate) fn new(timeline: &'a Timeline, gallery: GalleryConfig) -> Self { + Self { timeline, gallery, tracing_span: Span::current() } + } + } + + impl<'a> IntoFuture for SendGallery<'a> { + type Output = Result<(), Error>; + boxed_into_future!(extra_bounds: 'a); + + fn into_future(self) -> Self::IntoFuture { + let Self { timeline, gallery, tracing_span } = self; + + let fut = async move { + let send_queue = timeline.room().send_queue(); + let fut = send_queue.send_gallery(gallery); + fut.await.map_err(|_| Error::FailedSendingAttachment)?; + + Ok(()) + }; + + Box::pin(fut.instrument(tracing_span)) + } + } +} diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 5109e7a1245..55f31845d78 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -21,8 +21,12 @@ use std::{fs, path::PathBuf, sync::Arc}; use algorithms::rfind_event_by_item_id; use event_item::TimelineItemHandle; use eyeball_im::VectorDiff; +#[cfg(feature = "unstable-msc4274")] +use futures::SendGallery; use futures_core::Stream; use imbl::Vector; +#[cfg(feature = "unstable-msc4274")] +use matrix_sdk::attachment::GalleryConfig; use matrix_sdk::{ attachment::AttachmentConfig, deserialized_responses::TimelineEvent, @@ -416,6 +420,28 @@ impl Timeline { SendAttachment::new(self, source.into(), mime_type, config) } + /// Sends a media gallery to the room. + /// + /// If the encryption feature is enabled, this method will transparently + /// encrypt the room message if the room is encrypted. + /// + /// The attachments and their optional thumbnails are stored in the media + /// cache and can be retrieved at any time, by calling + /// [`Media::get_media_content()`] with the `MediaSource` that can be found + /// in the corresponding `TimelineEventItem`, and using a + /// `MediaFormat::File`. + /// + /// # Arguments + /// * `gallery` - A configuration object containing details about the + /// gallery like files, thumbnails, etc. + /// + /// [`Media::get_media_content()`]: matrix_sdk::Media::get_media_content + #[cfg(feature = "unstable-msc4274")] + #[instrument(skip_all)] + pub fn send_gallery(&self, gallery: GalleryConfig) -> SendGallery<'_> { + SendGallery::new(self, gallery) + } + /// Redact an event given its [`TimelineEventItemId`] and an optional /// reason. pub async fn redact( diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/media.rs b/crates/matrix-sdk-ui/tests/integration/timeline/media.rs index 7db667a515b..0be1d9c91e4 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/media.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/media.rs @@ -17,7 +17,9 @@ use std::{fs::File, io::Write as _, path::PathBuf, time::Duration}; use assert_matches::assert_matches; use assert_matches2::assert_let; use eyeball_im::VectorDiff; -use futures_util::{FutureExt, StreamExt}; +use futures_util::StreamExt; +#[cfg(feature = "unstable-msc4274")] +use matrix_sdk::attachment::{AttachmentInfo, BaseFileInfo, GalleryConfig, GalleryItemInfo}; use matrix_sdk::{ assert_let_timeout, attachment::AttachmentConfig, @@ -26,6 +28,10 @@ use matrix_sdk::{ }; use matrix_sdk_test::{async_test, event_factory::EventFactory, JoinedRoomBuilder, ALICE}; use matrix_sdk_ui::timeline::{AttachmentSource, EventSendState, RoomExt}; +#[cfg(feature = "unstable-msc4274")] +use ruma::events::room::message::GalleryItemType; +#[cfg(feature = "unstable-msc4274")] +use ruma::owned_mxc_uri; use ruma::{ event_id, events::room::{ @@ -35,8 +41,8 @@ use ruma::{ room_id, }; use serde_json::json; +use stream_assert::assert_pending; use tempfile::TempDir; -use tokio::time::sleep; use wiremock::ResponseTemplate; fn create_temporary_file(filename: &str) -> (TempDir, PathBuf) { @@ -89,7 +95,7 @@ async fn test_send_attachment_from_file() { assert_eq!(msg.body(), "hello"); // No other updates. - assert!(timeline_stream.next().now_or_never().is_none()); + assert_pending!(timeline_stream); // Store a file in a temporary directory. let (_tmp_dir, file_path) = create_temporary_file("test.bin"); @@ -134,10 +140,9 @@ async fn test_send_attachment_from_file() { } // Eventually, the media is updated with the final MXC IDs… - sleep(Duration::from_secs(4)).await; - { assert_let_timeout!( + Duration::from_secs(3), Some(VectorDiff::Set { index: 1, value: item }) = timeline_stream.next() ); assert_let!(Some(msg) = item.content().as_message()); @@ -161,7 +166,7 @@ async fn test_send_attachment_from_file() { } // That's all, folks! - assert!(timeline_stream.next().now_or_never().is_none()); + assert_pending!(timeline_stream); } #[async_test] @@ -194,7 +199,7 @@ async fn test_send_attachment_from_bytes() { assert_eq!(msg.body(), "hello"); // No other updates. - assert!(timeline_stream.next().now_or_never().is_none()); + assert_pending!(timeline_stream); // The data of the file. let filename = "test.bin"; @@ -234,10 +239,9 @@ async fn test_send_attachment_from_bytes() { } // Eventually, the media is updated with the final MXC IDs… - sleep(Duration::from_secs(4)).await; - { assert_let_timeout!( + Duration::from_secs(3), Some(VectorDiff::Set { index: 1, value: item }) = timeline_stream.next() ); assert_let!(Some(msg) = item.content().as_message()); @@ -261,7 +265,136 @@ async fn test_send_attachment_from_bytes() { } // That's all, folks! - assert!(timeline_stream.next().now_or_never().is_none()); + assert_pending!(timeline_stream); +} + +#[cfg(feature = "unstable-msc4274")] +#[async_test] +async fn test_send_gallery_from_bytes() { + let mock = MatrixMockServer::new().await; + let client = mock.client_builder().build().await; + + mock.mock_authenticated_media_config().ok_default().mount().await; + mock.mock_room_state_encryption().plain().mount().await; + + let room_id = room_id!("!a98sd12bjh:example.org"); + let room = mock.sync_joined_room(&client, room_id).await; + let timeline = room.timeline().await.unwrap(); + + let (items, mut timeline_stream) = + timeline.subscribe_filter_map(|item| item.as_event().cloned()).await; + + assert!(items.is_empty()); + + let f = EventFactory::new(); + mock.sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.gallery( + "check out my favourite gifs".to_owned(), + "rickroll.gif".to_owned(), + owned_mxc_uri!("mxc://sdk.rs/rickroll"), + ) + .sender(&ALICE), + ), + ) + .await; + + // Sanity check. + assert_let_timeout!(Some(VectorDiff::PushBack { value: item }) = timeline_stream.next()); + assert_let!(Some(msg) = item.content().as_message()); + assert_eq!(msg.body(), "check out my favourite gifs"); + + // No other updates. + assert_pending!(timeline_stream); + + // The data of the file. + let filename = "test.bin"; + let data = b"hello world".to_vec(); + + // Set up mocks for the file upload. + mock.mock_upload() + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2)).set_body_json( + json!({ + "content_uri": "mxc://sdk.rs/media" + }), + )) + .mock_once() + .mount() + .await; + + mock.mock_room_send().ok(event_id!("$media")).mock_once().mount().await; + + // Queue sending of a gallery. + let gallery = + GalleryConfig::new().caption(Some("caption".to_owned())).add_item(GalleryItemInfo { + filename: filename.to_owned(), + content_type: mime::TEXT_PLAIN, + data, + attachment_info: AttachmentInfo::File(BaseFileInfo { size: None }), + caption: Some("item caption".to_owned()), + formatted_caption: None, + thumbnail: None, + }); + timeline.send_gallery(gallery).await.unwrap(); + + { + assert_let_timeout!(Some(VectorDiff::PushBack { value: item }) = timeline_stream.next()); + assert_matches!(item.send_state(), Some(EventSendState::NotSentYet)); + assert_let!(Some(msg) = item.content().as_message()); + + // Body matches gallery caption. + assert_eq!(msg.body(), "caption"); + + // Message is gallery of expected length + assert_let!(MessageType::Gallery(content) = msg.msgtype()); + assert_eq!(1, content.itemtypes.len()); + assert_let!(GalleryItemType::File(file) = content.itemtypes.first().unwrap()); + + // Item has filename and caption + assert_eq!(filename, file.filename()); + assert_eq!(Some("item caption"), file.caption()); + + // The URI refers to the local cache. + assert_let!(MediaSource::Plain(uri) = &file.source); + assert!(uri.to_string().contains("localhost")); + } + + // Eventually, the media is updated with the final MXC IDs… + { + assert_let_timeout!( + Duration::from_secs(3), + Some(VectorDiff::Set { index: 1, value: item }) = timeline_stream.next() + ); + assert_let!(Some(msg) = item.content().as_message()); + assert_matches!(item.send_state(), Some(EventSendState::NotSentYet)); + + // Message is gallery of expected length + assert_let!(MessageType::Gallery(content) = msg.msgtype()); + assert_eq!(1, content.itemtypes.len()); + assert_let!(GalleryItemType::File(file) = content.itemtypes.first().unwrap()); + + // Item has filename and caption + assert_eq!(filename, file.filename()); + assert_eq!(Some("item caption"), file.caption()); + + // The URI now refers to the final MXC URI. + assert_let!(MediaSource::Plain(uri) = &file.source); + assert_eq!(uri.to_string(), "mxc://sdk.rs/media"); + } + + // And eventually the event itself is sent. + { + assert_let_timeout!( + Some(VectorDiff::Set { index: 1, value: item }) = timeline_stream.next() + ); + assert_matches!(item.send_state(), Some(EventSendState::Sent{ event_id }) => { + assert_eq!(event_id, event_id!("$media")); + }); + } + + // That's all, folks! + assert_pending!(timeline_stream); } #[async_test] @@ -282,7 +415,7 @@ async fn test_react_to_local_media() { timeline.subscribe_filter_map(|item| item.as_event().cloned()).await; assert!(items.is_empty()); - assert!(timeline_stream.next().now_or_never().is_none()); + assert_pending!(timeline_stream); // Store a file in a temporary directory. let (_tmp_dir, file_path) = create_temporary_file("test.bin"); @@ -315,5 +448,5 @@ async fn test_react_to_local_media() { reactions.get("🤪").unwrap().get(own_user_id).unwrap(); // That's all, folks! - assert!(timeline_stream.next().now_or_never().is_none()); + assert_pending!(timeline_stream); } diff --git a/crates/matrix-sdk/tests/integration/send_queue.rs b/crates/matrix-sdk/tests/integration/send_queue.rs index ba1096b29a8..b900c74e686 100644 --- a/crates/matrix-sdk/tests/integration/send_queue.rs +++ b/crates/matrix-sdk/tests/integration/send_queue.rs @@ -2134,6 +2134,7 @@ async fn test_gallery_uploads() { // ---------------------- // Prepare endpoints. + mock.mock_authenticated_media_config().ok_default().mount().await; mock.mock_room_state_encryption().plain().mount().await; mock.mock_room_send().ok(event_id!("$1")).mock_once().mount().await; diff --git a/testing/matrix-sdk-test/Cargo.toml b/testing/matrix-sdk-test/Cargo.toml index db04b904d3c..9c7df27cda4 100644 --- a/testing/matrix-sdk-test/Cargo.toml +++ b/testing/matrix-sdk-test/Cargo.toml @@ -27,7 +27,7 @@ matrix-sdk-test-macros = { version = "0.11.0", path = "../matrix-sdk-test-macros once_cell.workspace = true # Enable the unstable feature for polls support. # "client-api-s" enables need the "server" feature of ruma-client-api, which is needed to serialize Response objects to JSON. -ruma = { workspace = true, features = ["canonical-json", "client-api-s", "rand", "unstable-msc3381"] } +ruma = { workspace = true, features = ["canonical-json", "client-api-s", "rand", "unstable-msc3381", "unstable-msc4274"] } serde.workspace = true serde_json.workspace = true vodozemac.workspace = true diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index 55bd108068d..182c65d1148 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -45,9 +45,9 @@ use ruma::{ encrypted::{EncryptedEventScheme, RoomEncryptedEventContent}, member::{MembershipState, RoomMemberEventContent}, message::{ - FormattedBody, ImageMessageEventContent, MessageType, Relation, - RelationWithoutReplacement, RoomMessageEventContent, - RoomMessageEventContentWithoutRelation, + FormattedBody, GalleryItemType, GalleryMessageEventContent, + ImageMessageEventContent, MessageType, Relation, RelationWithoutReplacement, + RoomMessageEventContent, RoomMessageEventContentWithoutRelation, }, name::RoomNameEventContent, power_levels::RoomPowerLevelsEventContent, @@ -800,6 +800,22 @@ impl EventFactory { self.event(RoomMessageEventContent::new(MessageType::Image(image_event_content))) } + /// Create a gallery event containing a single plain (unencrypted) image + /// referencing the given MXC ID. + pub fn gallery( + &self, + body: String, + filename: String, + url: OwnedMxcUri, + ) -> EventBuilder { + let gallery_event_content = GalleryMessageEventContent::new( + body, + None, + vec![GalleryItemType::Image(ImageMessageEventContent::plain(filename, url))], + ); + self.event(RoomMessageEventContent::new(MessageType::Gallery(gallery_event_content))) + } + /// Create a typing notification event. pub fn typing(&self, user_ids: Vec<&UserId>) -> EventBuilder { let mut builder = self