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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user