feat(timeline): Expose method to send galleries in matrix-sdk-ui (#5125)

This was broken out of
https://github.com/matrix-org/matrix-rust-sdk/pull/4838 and is a step
towards implementing
https://github.com/matrix-org/matrix-spec-proposals/pull/4274. Building
upon https://github.com/matrix-org/matrix-rust-sdk/pull/4977, a new
method `Timeline::send_gallery` in matrix-sdk-ui for sending galleries.

- [x] Public API changes documented in changelogs (optional)

---------

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
This commit is contained in:
Johannes Marbach
2025-06-03 15:24:02 +02:00
committed by GitHub
parent 8e3ad22d92
commit ee06965d2e
11 changed files with 269 additions and 40 deletions
+1 -1
View File
@@ -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]
+3
View File
@@ -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
+3
View File
@@ -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
@@ -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(_)
@@ -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()
}
@@ -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))
}
}
}
+26
View File
@@ -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(
@@ -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);
}
@@ -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;
+1 -1
View File
@@ -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
+19 -3
View File
@@ -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,
@@ -811,6 +811,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<RoomMessageEventContent> {
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<TypingEventContent> {
let mut builder = self