diff --git a/bindings/matrix-sdk-ffi/CHANGELOG.md b/bindings/matrix-sdk-ffi/CHANGELOG.md index 471e9872c7b..4349373d098 100644 --- a/bindings/matrix-sdk-ffi/CHANGELOG.md +++ b/bindings/matrix-sdk-ffi/CHANGELOG.md @@ -27,6 +27,8 @@ All notable changes to this project will be documented in this file. current device scanning or generating the QR code. Additionally, new errors `HumanQrLoginError::CheckCodeAlreadySent` and `HumanQrLoginError::CheckCodeCannotBeSent` were added. ([#5786](https://github.com/matrix-org/matrix-rust-sdk/pull/5786)) +- `ComposerDraft` now includes attachments alongside the text message. + ([#5794](https://github.com/matrix-org/matrix-rust-sdk/pull/5794)) ### Features: diff --git a/bindings/matrix-sdk-ffi/src/room/mod.rs b/bindings/matrix-sdk-ffi/src/room/mod.rs index e12e5a679e9..0ac4f602264 100644 --- a/bindings/matrix-sdk-ffi/src/room/mod.rs +++ b/bindings/matrix-sdk-ffi/src/room/mod.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, pin::pin, sync::Arc}; +use std::{collections::HashMap, fs, path::PathBuf, pin::pin, sync::Arc}; use anyhow::{Context, Result}; use futures_util::{pin_mut, StreamExt}; @@ -9,7 +9,8 @@ use matrix_sdk::{ TryFromReportedContentScoreError, }, send_queue::RoomSendQueueUpdate as SdkRoomSendQueueUpdate, - ComposerDraft as SdkComposerDraft, ComposerDraftType as SdkComposerDraftType, EncryptionState, + ComposerDraft as SdkComposerDraft, ComposerDraftType as SdkComposerDraftType, + DraftAttachment as SdkDraftAttachment, DraftAttachmentContent, DraftThumbnail, EncryptionState, PredecessorRoom as SdkPredecessorRoom, RoomHero as SdkRoomHero, RoomMemberships, RoomState, SuccessorRoom as SdkSuccessorRoom, }; @@ -45,11 +46,14 @@ use crate::{ live_location_share::{LastLocation, LiveLocationShare}, room_member::{RoomMember, RoomMemberWithSenderInfo}, room_preview::RoomPreview, - ruma::{ImageInfo, LocationContent, MediaSource}, + ruma::{ + AudioInfo, FileInfo, ImageInfo, LocationContent, MediaSource, ThumbnailInfo, VideoInfo, + }, runtime::get_runtime_handle, timeline::{ configuration::{TimelineConfiguration, TimelineFilter}, AbstractProgress, EventTimelineItem, LatestEventValue, ReceiptType, SendHandle, Timeline, + UploadSource, }, utils::{u64_to_uint, AsyncRuntimeDropped}, TaskHandle, @@ -1442,21 +1446,257 @@ pub struct ComposerDraft { pub html_text: Option, /// The type of draft. pub draft_type: ComposerDraftType, + /// Attachments associated with this draft. + pub attachments: Vec, } impl From for ComposerDraft { fn from(value: SdkComposerDraft) -> Self { - let SdkComposerDraft { plain_text, html_text, draft_type } = value; - Self { plain_text, html_text, draft_type: draft_type.into() } + let SdkComposerDraft { plain_text, html_text, draft_type, attachments } = value; + Self { + plain_text, + html_text, + draft_type: draft_type.into(), + attachments: attachments.into_iter().map(|a| a.into()).collect(), + } } } impl TryFrom for SdkComposerDraft { - type Error = ruma::IdParseError; + type Error = ClientError; fn try_from(value: ComposerDraft) -> std::result::Result { - let ComposerDraft { plain_text, html_text, draft_type } = value; - Ok(Self { plain_text, html_text, draft_type: draft_type.try_into()? }) + let ComposerDraft { plain_text, html_text, draft_type, attachments } = value; + Ok(Self { + plain_text, + html_text, + draft_type: draft_type.try_into()?, + attachments: attachments + .into_iter() + .map(|a| a.try_into()) + .collect::, _>>()?, + }) + } +} + +/// An attachment stored with a composer draft. +#[derive(uniffi::Enum)] +pub enum DraftAttachment { + Audio { audio_info: AudioInfo, source: UploadSource }, + File { file_info: FileInfo, source: UploadSource }, + Image { image_info: ImageInfo, source: UploadSource, thumbnail_source: Option }, + Video { video_info: VideoInfo, source: UploadSource, thumbnail_source: Option }, +} + +impl From for DraftAttachment { + fn from(value: SdkDraftAttachment) -> Self { + match value.content { + DraftAttachmentContent::Image { + data, + mimetype, + size, + width, + height, + blurhash, + thumbnail, + } => { + let thumbnail_source = thumbnail.as_ref().map(|t| UploadSource::Data { + bytes: t.data.clone(), + filename: t.filename.clone(), + }); + let thumbnail_info = thumbnail.map(|t| ThumbnailInfo { + width: t.width, + height: t.height, + mimetype: t.mimetype, + size: t.size, + }); + DraftAttachment::Image { + image_info: ImageInfo { + height, + width, + mimetype, + size, + thumbnail_info, + thumbnail_source: None, + blurhash, + is_animated: None, + }, + source: UploadSource::Data { bytes: data, filename: value.filename }, + thumbnail_source, + } + } + DraftAttachmentContent::Video { + data, + mimetype, + size, + width, + height, + duration, + blurhash, + thumbnail, + } => { + let thumbnail_source = thumbnail.as_ref().map(|t| UploadSource::Data { + bytes: t.data.clone(), + filename: t.filename.clone(), + }); + let thumbnail_info = thumbnail.map(|t| ThumbnailInfo { + width: t.width, + height: t.height, + mimetype: t.mimetype, + size: t.size, + }); + DraftAttachment::Video { + video_info: VideoInfo { + duration, + height, + width, + mimetype, + size, + thumbnail_info, + thumbnail_source: None, + blurhash, + }, + source: UploadSource::Data { bytes: data, filename: value.filename }, + thumbnail_source, + } + } + DraftAttachmentContent::Audio { data, mimetype, size, duration } => { + DraftAttachment::Audio { + audio_info: AudioInfo { duration, size, mimetype }, + source: UploadSource::Data { bytes: data, filename: value.filename }, + } + } + DraftAttachmentContent::File { data, mimetype, size } => DraftAttachment::File { + file_info: FileInfo { + mimetype, + size, + thumbnail_info: None, + thumbnail_source: None, + }, + source: UploadSource::Data { bytes: data, filename: value.filename }, + }, + } + } +} + +/// Resolve the bytes and filename from an `UploadSource`, reading the file +/// contents if needed. +fn read_upload_source(source: UploadSource) -> Result<(Vec, String), ClientError> { + match source { + UploadSource::Data { bytes, filename } => Ok((bytes, filename)), + UploadSource::File { filename } => { + let path: PathBuf = filename.into(); + let filename = path + .file_name() + .ok_or(ClientError::Generic { + msg: "Invalid attachment path".to_owned(), + details: None, + })? + .to_str() + .ok_or(ClientError::Generic { + msg: "Invalid attachment path".to_owned(), + details: None, + })? + .to_owned(); + + let bytes = fs::read(&path).map_err(|_| ClientError::Generic { + msg: "Could not load file".to_owned(), + details: None, + })?; + + Ok((bytes, filename)) + } + } +} + +impl TryFrom for SdkDraftAttachment { + type Error = ClientError; + + fn try_from(value: DraftAttachment) -> Result { + match value { + DraftAttachment::Image { image_info, source, thumbnail_source, .. } => { + let (data, filename) = read_upload_source(source)?; + let thumbnail = match (image_info.thumbnail_info, thumbnail_source) { + (Some(info), Some(source)) => { + let (data, filename) = read_upload_source(source)?; + Some(DraftThumbnail { + filename, + data, + mimetype: info.mimetype, + width: info.width, + height: info.height, + size: info.size, + }) + } + _ => None, + }; + Ok(Self { + filename, + content: DraftAttachmentContent::Image { + data, + mimetype: image_info.mimetype, + size: image_info.size, + width: image_info.width, + height: image_info.height, + blurhash: image_info.blurhash, + thumbnail, + }, + }) + } + DraftAttachment::Video { video_info, source, thumbnail_source, .. } => { + let (data, filename) = read_upload_source(source)?; + let thumbnail = match (video_info.thumbnail_info, thumbnail_source) { + (Some(info), Some(source)) => { + let (data, filename) = read_upload_source(source)?; + Some(DraftThumbnail { + filename, + data, + mimetype: info.mimetype, + width: info.width, + height: info.height, + size: info.size, + }) + } + _ => None, + }; + Ok(Self { + filename, + content: DraftAttachmentContent::Video { + data, + mimetype: video_info.mimetype, + size: video_info.size, + width: video_info.width, + height: video_info.height, + duration: video_info.duration, + blurhash: video_info.blurhash, + thumbnail, + }, + }) + } + DraftAttachment::Audio { audio_info, source, .. } => { + let (data, filename) = read_upload_source(source)?; + Ok(Self { + filename, + content: DraftAttachmentContent::Audio { + data, + mimetype: audio_info.mimetype, + size: audio_info.size, + duration: audio_info.duration, + }, + }) + } + DraftAttachment::File { file_info, source, .. } => { + let (data, filename) = read_upload_source(source)?; + Ok(Self { + filename, + content: DraftAttachmentContent::File { + data, + mimetype: file_info.mimetype, + size: file_info.size, + }, + }) + } + } } } diff --git a/crates/matrix-sdk-base/CHANGELOG.md b/crates/matrix-sdk-base/CHANGELOG.md index 498604d7041..009eaabd88e 100644 --- a/crates/matrix-sdk-base/CHANGELOG.md +++ b/crates/matrix-sdk-base/CHANGELOG.md @@ -11,6 +11,11 @@ All notable changes to this project will be documented in this file. - `Client::sync_lock` has been renamed `Client::state_store_lock`. ([#5707](https://github.com/matrix-org/matrix-rust-sdk/pull/5707)) +### Features + +- `ComposerDraft` can now store attachments alongside text messages. + ([#5794](https://github.com/matrix-org/matrix-rust-sdk/pull/5794)) + ## [0.14.1] - 2025-09-10 ### Security Fixes diff --git a/crates/matrix-sdk-base/src/lib.rs b/crates/matrix-sdk-base/src/lib.rs index 3edf111860a..5781b49d69f 100644 --- a/crates/matrix-sdk-base/src/lib.rs +++ b/crates/matrix-sdk-base/src/lib.rs @@ -64,8 +64,9 @@ pub use room::{ RoomState, RoomStateFilter, SuccessorRoom, apply_redaction, }; pub use store::{ - ComposerDraft, ComposerDraftType, QueueWedgeError, StateChanges, StateStore, StateStoreDataKey, - StateStoreDataValue, StoreError, ThreadSubscriptionCatchupToken, + ComposerDraft, ComposerDraftType, DraftAttachment, DraftAttachmentContent, DraftThumbnail, + QueueWedgeError, StateChanges, StateStore, StateStoreDataKey, StateStoreDataValue, StoreError, + ThreadSubscriptionCatchupToken, }; pub use utils::{ MinimalRoomMemberEvent, MinimalStateEvent, OriginalMinimalStateEvent, RedactedMinimalStateEvent, diff --git a/crates/matrix-sdk-base/src/store/mod.rs b/crates/matrix-sdk-base/src/store/mod.rs index f7e0f037926..b1faf832e31 100644 --- a/crates/matrix-sdk-base/src/store/mod.rs +++ b/crates/matrix-sdk-base/src/store/mod.rs @@ -94,9 +94,9 @@ pub use self::{ SentMediaInfo, SentRequestKey, SerializableEventContent, }, traits::{ - ComposerDraft, ComposerDraftType, DynStateStore, IntoStateStore, ServerInfo, StateStore, - StateStoreDataKey, StateStoreDataValue, StateStoreExt, ThreadSubscriptionCatchupToken, - WellKnownResponse, + ComposerDraft, ComposerDraftType, DraftAttachment, DraftAttachmentContent, DraftThumbnail, + DynStateStore, IntoStateStore, ServerInfo, StateStore, StateStoreDataKey, + StateStoreDataValue, StateStoreExt, ThreadSubscriptionCatchupToken, WellKnownResponse, }, }; diff --git a/crates/matrix-sdk-base/src/store/traits.rs b/crates/matrix-sdk-base/src/store/traits.rs index fe0eed45d20..fccce238e94 100644 --- a/crates/matrix-sdk-base/src/store/traits.rs +++ b/crates/matrix-sdk-base/src/store/traits.rs @@ -1195,6 +1195,97 @@ pub struct ComposerDraft { pub html_text: Option, /// The type of draft. pub draft_type: ComposerDraftType, + /// Attachments associated with this draft. + #[serde(default)] + pub attachments: Vec, +} + +/// An attachment stored with a composer draft. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DraftAttachment { + /// The filename of the attachment. + pub filename: String, + /// The attachment content with type-specific data. + pub content: DraftAttachmentContent, +} + +/// The content of a draft attachment with type-specific data. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type")] +pub enum DraftAttachmentContent { + /// Image attachment. + Image { + /// The image file data. + data: Vec, + /// MIME type. + mimetype: Option, + /// File size in bytes. + size: Option, + /// Width in pixels. + width: Option, + /// Height in pixels. + height: Option, + /// BlurHash string. + blurhash: Option, + /// Optional thumbnail. + thumbnail: Option, + }, + /// Video attachment. + Video { + /// The video file data. + data: Vec, + /// MIME type. + mimetype: Option, + /// File size in bytes. + size: Option, + /// Width in pixels. + width: Option, + /// Height in pixels. + height: Option, + /// Duration. + duration: Option, + /// BlurHash string. + blurhash: Option, + /// Optional thumbnail. + thumbnail: Option, + }, + /// Audio attachment. + Audio { + /// The audio file data. + data: Vec, + /// MIME type. + mimetype: Option, + /// File size in bytes. + size: Option, + /// Duration. + duration: Option, + }, + /// Generic file attachment. + File { + /// The file data. + data: Vec, + /// MIME type. + mimetype: Option, + /// File size in bytes. + size: Option, + }, +} + +/// Thumbnail data for a draft attachment. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DraftThumbnail { + /// The filename of the thumbnail. + pub filename: String, + /// The thumbnail image data. + pub data: Vec, + /// MIME type of the thumbnail. + pub mimetype: Option, + /// Width in pixels. + pub width: Option, + /// Height in pixels. + pub height: Option, + /// File size in bytes. + pub size: Option, } /// The type of draft of the composer. diff --git a/crates/matrix-sdk/src/lib.rs b/crates/matrix-sdk/src/lib.rs index b724e707aaa..bd549a59bc5 100644 --- a/crates/matrix-sdk/src/lib.rs +++ b/crates/matrix-sdk/src/lib.rs @@ -23,8 +23,9 @@ pub use bytes; #[cfg(feature = "e2e-encryption")] pub use matrix_sdk_base::crypto; pub use matrix_sdk_base::{ - ComposerDraft, ComposerDraftType, EncryptionState, PredecessorRoom, QueueWedgeError, - Room as BaseRoom, RoomCreateWithCreatorEventContent, RoomDisplayName, RoomHero, RoomInfo, + ComposerDraft, ComposerDraftType, DraftAttachment, DraftAttachmentContent, DraftThumbnail, + EncryptionState, PredecessorRoom, QueueWedgeError, Room as BaseRoom, + RoomCreateWithCreatorEventContent, RoomDisplayName, RoomHero, RoomInfo, RoomMember as BaseRoomMember, RoomMemberships, RoomRecencyStamp, RoomState, SessionMeta, StateChanges, StateStore, StoreError, SuccessorRoom, ThreadingSupport, deserialized_responses, store::{self, DynStateStore, MemoryStore, StateStoreExt}, diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 66c885b9434..8d3343f49bd 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -4656,7 +4656,7 @@ pub struct RoomMemberWithSenderInfo { mod tests { use std::collections::BTreeMap; - use matrix_sdk_base::{ComposerDraft, store::ComposerDraftType}; + use matrix_sdk_base::{ComposerDraft, DraftAttachment, store::ComposerDraftType}; use matrix_sdk_test::{ JoinedRoomBuilder, StateTestEvent, SyncResponseBuilder, async_test, event_factory::EventFactory, test_json, @@ -4852,6 +4852,14 @@ mod tests { plain_text: "Hello, world!".to_owned(), html_text: Some("Hello, world!".to_owned()), draft_type: ComposerDraftType::NewMessage, + attachments: vec![DraftAttachment { + filename: "cat.txt".to_owned(), + content: matrix_sdk_base::DraftAttachmentContent::File { + data: b"meow".to_vec(), + mimetype: Some("text/plain".to_owned()), + size: Some(5), + }, + }], }; room.save_composer_draft(draft.clone(), None).await.unwrap(); @@ -4861,6 +4869,14 @@ mod tests { plain_text: "Hello, thread!".to_owned(), html_text: Some("Hello, thread!".to_owned()), draft_type: ComposerDraftType::NewMessage, + attachments: vec![DraftAttachment { + filename: "dog.txt".to_owned(), + content: matrix_sdk_base::DraftAttachmentContent::File { + data: b"wuv".to_vec(), + mimetype: Some("text/plain".to_owned()), + size: Some(4), + }, + }], }; room.save_composer_draft(thread_draft.clone(), Some(&thread_root)).await.unwrap();