Support sending gallery messages behind a feature flag

Adds the ability to send a gallery message (multiple attachments in one message)
via the media upload preview, wired through the timeline controller/proxy to the
SDK's sendGallery. Gated by a `galleryEnabled` developer flag (default off),
matching Element X Android — with the flag off, attachments are sent as
individual messages as before.

Extends the community PR #5601, rebased onto develop. Tracking:
element-hq/element-meta#3201

Co-authored-by: svetoslav.mitov <svetoslav.mitov@sportinno.com>
This commit is contained in:
Mauro Romito
2026-07-21 11:34:44 +02:00
parent 762e5ad0ff
commit 6a2ed331fa
15 changed files with 387 additions and 41 deletions
+8 -8
View File
@@ -7610,16 +7610,16 @@
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
C0FAEB81CFD9776CD78CE489 /* ElementX */,
FEB53A5BC378C913769656D8 /* NSE */,
19F0C845D67E9BEA4BE7133E /* ShareExtension */,
676A87694F0F247B2AF1A142 /* MatrixRustSDKMocks */,
32C23C8D224D46EFE62AFAD0 /* UnitTests */,
7A17BE29BAC81ADBAC6349D9 /* PreviewTests */,
0E28CD62691FDBC63147D5E3 /* UITests */,
D3DB351B7FBE0F49649171FC /* IntegrationTests */,
C0C687DE1D270F9895FEE186 /* AccessibilityTests */, C0C687DE1D270F9895FEE186 /* AccessibilityTests */,
C0FAEB81CFD9776CD78CE489 /* ElementX */,
D3DB351B7FBE0F49649171FC /* IntegrationTests */,
676A87694F0F247B2AF1A142 /* MatrixRustSDKMocks */,
FEB53A5BC378C913769656D8 /* NSE */,
F8E276FD6DC43EADB85241BC /* Periphery */, F8E276FD6DC43EADB85241BC /* Periphery */,
7A17BE29BAC81ADBAC6349D9 /* PreviewTests */,
19F0C845D67E9BEA4BE7133E /* ShareExtension */,
0E28CD62691FDBC63147D5E3 /* UITests */,
32C23C8D224D46EFE62AFAD0 /* UnitTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -416,6 +416,11 @@ final nonisolated class AppSettings: @unchecked Sendable {
@UserPreference(defaultValue: false) @UserPreference(defaultValue: false)
var linkPreviewsEnabled: Bool var linkPreviewsEnabled: Bool
/// Enables *sending* gallery messages (multiple media in a single message). Received galleries
/// are always rendered regardless of this flag, matching Element X Android.
@UserPreference(defaultValue: false)
var galleryEnabled: Bool
@UserPreference(defaultValue: false) @UserPreference(defaultValue: false)
var jumpToReadMarkerEnabled: Bool var jumpToReadMarkerEnabled: Bool
@@ -1123,7 +1123,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings), mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings),
timelineController: timelineController, timelineController: timelineController,
clientProxy: userSession.clientProxy, clientProxy: userSession.clientProxy,
userIndicatorController: flowParameters.userIndicatorController) userIndicatorController: flowParameters.userIndicatorController,
galleryEnabled: flowParameters.appSettings.galleryEnabled)
let mediaUploadPreviewScreenCoordinator = MediaUploadPreviewScreenCoordinator(parameters: parameters) let mediaUploadPreviewScreenCoordinator = MediaUploadPreviewScreenCoordinator(parameters: parameters)
@@ -12267,6 +12267,48 @@ nonisolated class TimelineControllerMock: TimelineControllerProtocol, @unchecked
return sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue return sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue
} }
} }
//MARK: - sendGallery
private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount = 0
var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount: Int {
get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount } }
set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount = newValue } }
}
var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCalled: Bool {
return sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount > 0
}
private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)?
var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)? {
get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments } }
set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments = newValue } }
}
private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)] = []
var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)] {
get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations } }
set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations = newValue } }
}
private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue: Result<Void, TimelineControllerError>!
var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValue: Result<Void, TimelineControllerError>! {
get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue } }
set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure: (([GalleryItemInfo], String?, String?, String?) async -> Result<Void, TimelineControllerError>)?
@concurrent func sendGallery(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?) async -> Result<Void, TimelineControllerError> {
sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount += 1 }
sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArguments = (itemInfos: itemInfos, caption: caption, formattedCaption: formattedCaption, inReplyToEventID: inReplyToEventID)
sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations.append((itemInfos: itemInfos, caption: caption, formattedCaption: formattedCaption, inReplyToEventID: inReplyToEventID)) }
if let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure = sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure {
return await sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure(itemInfos, caption, formattedCaption, inReplyToEventID)
} else {
return sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValue
}
}
//MARK: - createPoll //MARK: - createPoll
private let createPollQuestionAnswersMaxSelectionsPollKindCallsCountLock = NSLock() private let createPollQuestionAnswersMaxSelectionsPollKindCallsCountLock = NSLock()
@@ -13022,6 +13064,48 @@ nonisolated class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable
return sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue return sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue
} }
} }
//MARK: - sendGallery
private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount = 0
var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount: Int {
get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount } }
set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount = newValue } }
}
var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCalled: Bool {
return sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount > 0
}
private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)?
var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)? {
get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments } }
set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments = newValue } }
}
private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)] = []
var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)] {
get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations } }
set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations = newValue } }
}
private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue: Result<Void, TimelineProxyError>!
var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValue: Result<Void, TimelineProxyError>! {
get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue } }
set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure: (([GalleryItemInfo], String?, String?, String?) async -> Result<Void, TimelineProxyError>)?
@concurrent func sendGallery(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?) async -> Result<Void, TimelineProxyError> {
sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount += 1 }
sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArguments = (itemInfos: itemInfos, caption: caption, formattedCaption: formattedCaption, inReplyToEventID: inReplyToEventID)
sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations.append((itemInfos: itemInfos, caption: caption, formattedCaption: formattedCaption, inReplyToEventID: inReplyToEventID)) }
if let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure = sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure {
return await sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure(itemInfos, caption, formattedCaption, inReplyToEventID)
} else {
return sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValue
}
}
//MARK: - sendReadReceipt //MARK: - sendReadReceipt
private let sendReadReceiptForTypeCallsCountLock = NSLock() private let sendReadReceiptForTypeCallsCountLock = NSLock()
@@ -162,6 +162,17 @@ struct TimelineControllerMockConfiguration {
return .success(()) return .success(())
} }
sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure = { [weak self, timelineProxy] itemInfos, caption, formattedCaption, inReplyToEventID in
self?.callbacks.send(.messageSentOrEdited)
if let timelineProxy {
return await timelineProxy.sendGallery(itemInfos: itemInfos,
caption: caption,
formattedCaption: formattedCaption,
inReplyToEventID: inReplyToEventID).mapError(TimelineControllerError.timelineProxyError)
}
return .success(())
}
sendVoiceMessageUrlAudioInfoWaveformRequestHandleClosure = { [weak self, timelineProxy] url, audioInfo, waveform, requestHandle in sendVoiceMessageUrlAudioInfoWaveformRequestHandleClosure = { [weak self, timelineProxy] url, audioInfo, waveform, requestHandle in
self?.callbacks.send(.messageSentOrEdited) self?.callbacks.send(.messageSentOrEdited)
if let timelineProxy { if let timelineProxy {
@@ -18,6 +18,9 @@ struct MediaUploadPreviewScreenCoordinatorParameters {
let timelineController: TimelineControllerProtocol let timelineController: TimelineControllerProtocol
let clientProxy: ClientProxyProtocol let clientProxy: ClientProxyProtocol
let userIndicatorController: UserIndicatorControllerProtocol let userIndicatorController: UserIndicatorControllerProtocol
/// Whether sending as a gallery is enabled. When `false`, multiple attachments are sent as
/// individual messages (the previous behaviour).
let galleryEnabled: Bool
} }
enum MediaUploadPreviewScreenCoordinatorAction { enum MediaUploadPreviewScreenCoordinatorAction {
@@ -41,7 +44,8 @@ final class MediaUploadPreviewScreenCoordinator: CoordinatorProtocol {
mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor, mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor,
timelineController: parameters.timelineController, timelineController: parameters.timelineController,
clientProxy: parameters.clientProxy, clientProxy: parameters.clientProxy,
userIndicatorController: parameters.userIndicatorController) userIndicatorController: parameters.userIndicatorController,
galleryEnabled: parameters.galleryEnabled)
} }
func start() { func start() {
@@ -22,6 +22,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
private var processingTask: Task<Result<[MediaInfo], MediaUploadingPreprocessorError>, Never> private var processingTask: Task<Result<[MediaInfo], MediaUploadingPreprocessorError>, Never>
private var requestHandle: SendAttachmentJoinHandleProtocol? private var requestHandle: SendAttachmentJoinHandleProtocol?
private let clientProxy: ClientProxyProtocol private let clientProxy: ClientProxyProtocol
private let galleryEnabled: Bool
private var actionsSubject: PassthroughSubject<MediaUploadPreviewScreenViewModelAction, Never> = .init() private var actionsSubject: PassthroughSubject<MediaUploadPreviewScreenViewModelAction, Never> = .init()
@@ -36,12 +37,14 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
mediaUploadingPreprocessor: MediaUploadingPreprocessor, mediaUploadingPreprocessor: MediaUploadingPreprocessor,
timelineController: TimelineControllerProtocol, timelineController: TimelineControllerProtocol,
clientProxy: ClientProxyProtocol, clientProxy: ClientProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol) { userIndicatorController: UserIndicatorControllerProtocol,
galleryEnabled: Bool) {
self.mediaURLs = mediaURLs self.mediaURLs = mediaURLs
self.mediaUploadingPreprocessor = mediaUploadingPreprocessor self.mediaUploadingPreprocessor = mediaUploadingPreprocessor
self.timelineController = timelineController self.timelineController = timelineController
self.clientProxy = clientProxy self.clientProxy = clientProxy
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.galleryEnabled = galleryEnabled
// Start processing the media whilst the user is reviewing it/adding a caption. // Start processing the media whilst the user is reviewing it/adding a caption.
processingTask = Self.processMedia(at: mediaURLs, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy) processingTask = Self.processMedia(at: mediaURLs, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy)
@@ -54,7 +57,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
override func process(viewAction: MediaUploadPreviewScreenViewAction) { override func process(viewAction: MediaUploadPreviewScreenViewAction) {
// Get the current caption before all the processing starts. // Get the current caption before all the processing starts.
var caption = state.bindings.caption.nonBlankString let caption = state.bindings.caption.nonBlankString
switch viewAction { switch viewAction {
case .send: case .send:
@@ -65,14 +68,25 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
switch await processingTask.value { switch await processingTask.value {
case .success(let mediaInfos): case .success(let mediaInfos):
for mediaInfo in mediaInfos { if galleryEnabled, mediaInfos.count > 1 {
switch await sendAttachment(mediaInfo: mediaInfo, caption: caption) { switch await sendGallery(mediaInfos: mediaInfos, caption: caption) {
case .success: case .success:
caption = nil // Set the caption only on the first uploaded file. break
case .failure(let error): case .failure(let error):
MXLog.error("Failed sending media with error: \(error)") MXLog.error("Failed sending gallery with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedSending) showError(label: L10n.screenMediaUploadPreviewErrorFailedSending)
} }
} else {
var perItemCaption = caption
for mediaInfo in mediaInfos {
switch await sendAttachment(mediaInfo: mediaInfo, caption: perItemCaption) {
case .success:
perItemCaption = nil // Set the caption only on the first uploaded file.
case .failure(let error):
MXLog.error("Failed sending media with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedSending)
}
}
} }
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
@@ -107,6 +121,40 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
} }
} }
private func sendGallery(mediaInfos: [MediaInfo], caption: String?) async -> Result<Void, TimelineControllerError> {
let itemInfos: [GalleryItemInfo] = mediaInfos.map { mediaInfo in
switch mediaInfo {
case let .image(imageURL, thumbnailURL, imageInfo):
return .image(imageInfo: imageInfo,
source: .file(filename: imageURL.path(percentEncoded: false)),
caption: nil,
formattedCaption: nil,
thumbnailSource: .file(filename: thumbnailURL.path(percentEncoded: false)))
case let .video(videoURL, thumbnailURL, videoInfo):
return .video(videoInfo: videoInfo,
source: .file(filename: videoURL.path(percentEncoded: false)),
caption: nil,
formattedCaption: nil,
thumbnailSource: .file(filename: thumbnailURL.path(percentEncoded: false)))
case let .audio(audioURL, audioInfo):
return .audio(audioInfo: audioInfo,
source: .file(filename: audioURL.path(percentEncoded: false)),
caption: nil,
formattedCaption: nil)
case let .file(fileURL, fileInfo):
return .file(fileInfo: fileInfo,
source: .file(filename: fileURL.path(percentEncoded: false)),
caption: nil,
formattedCaption: nil)
}
}
return await timelineController.sendGallery(itemInfos: itemInfos,
caption: caption,
formattedCaption: nil,
inReplyToEventID: nil)
}
func stopProcessing() { func stopProcessing() {
processingTask.cancel() processingTask.cancel()
} }
@@ -6,6 +6,7 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
// //
import AVFoundation
import Combine import Combine
import Compound import Compound
import GameController import GameController
@@ -35,17 +36,7 @@ struct MediaUploadPreviewScreen: View {
mainContent mainContent
.id(context.viewState.mediaURLs) .id(context.viewState.mediaURLs)
.ignoresSafeArea(edges: [.horizontal]) .ignoresSafeArea(edges: [.horizontal])
.safeAreaInset(edge: .top) { .overlay(alignment: .top) { galleryBadge }
if context.viewState.mediaURLs.count > 1 {
Text(L10n.screenMediaUploadPreviewItemCount(currentIndex + 1, context.viewState.mediaURLs.count))
.font(.compound.bodyMD)
.foregroundColor(.compound.textPrimary)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(.compound.bgBadgeDefault)
.clipShape(.capsule)
}
}
.safeAreaInset(edge: .bottom, spacing: 0) { .safeAreaInset(edge: .bottom, spacing: 0) {
composer composer
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -74,6 +65,20 @@ struct MediaUploadPreviewScreen: View {
} }
} }
@ViewBuilder
private var galleryBadge: some View {
if context.viewState.mediaURLs.count > 1 {
Text(L10n.screenMediaUploadPreviewItemCount(currentIndex + 1, context.viewState.mediaURLs.count))
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.compound.bgCanvasDefault.opacity(0.85),
in: Capsule())
.padding(.top, 12)
}
}
@ViewBuilder @ViewBuilder
private var mainContent: some View { private var mainContent: some View {
if ProcessInfo.processInfo.isiOSAppOnMac { if ProcessInfo.processInfo.isiOSAppOnMac {
@@ -81,6 +86,9 @@ struct MediaUploadPreviewScreen: View {
.font(.compound.headingMD) .font(.compound.headingMD)
.foregroundColor(.compound.textSecondary) .foregroundColor(.compound.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else if context.viewState.mediaURLs.count > 1 {
UploadMediaPeekCarousel(mediaURLs: context.viewState.mediaURLs,
currentIndex: $currentIndex)
} else { } else {
PreviewView(mediaURLs: context.viewState.mediaURLs, PreviewView(mediaURLs: context.viewState.mediaURLs,
title: context.viewState.title, title: context.viewState.title,
@@ -214,6 +222,133 @@ struct MediaUploadPreviewScreen: View {
} }
} }
private struct UploadMediaPeekCarousel: View {
let mediaURLs: [URL]
@Binding var currentIndex: Int
@State private var scrolledID: Int?
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(Array(mediaURLs.enumerated()), id: \.offset) { index, url in
UploadMediaThumbnail(url: url)
.containerRelativeFrame(.horizontal)
.id(index)
}
}
.scrollTargetLayout()
}
.contentMargins(.horizontal, 24, for: .scrollContent)
.scrollTargetBehavior(.viewAligned)
.scrollPosition(id: $scrolledID)
.onAppear {
if scrolledID == nil {
scrolledID = currentIndex
}
}
.onChange(of: scrolledID) { _, newValue in
if let newValue, newValue != currentIndex {
currentIndex = newValue
}
}
}
}
private struct UploadMediaThumbnail: View {
let url: URL
private var contentType: UTType? {
UTType(filenameExtension: url.pathExtension)
}
private var isImageOrVideo: Bool {
guard let contentType else { return false }
return contentType.conforms(to: .image) || contentType.conforms(to: .movie) || contentType.conforms(to: .audiovisualContent)
}
var body: some View {
Group {
if isImageOrVideo {
UploadMediaImageThumbnail(url: url)
} else {
UploadMediaFilePreview(url: url, title: url.lastPathComponent)
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
private struct UploadMediaImageThumbnail: View {
let url: URL
@State private var image: UIImage?
var body: some View {
ZStack {
Color.black
if let image {
Image(uiImage: image)
.resizable()
.scaledToFit()
} else {
ProgressView().tint(.white)
}
}
.task(id: url) { await load() }
}
private func load() async {
if let image = UIImage(contentsOfFile: url.path(percentEncoded: false)) {
self.image = image
return
}
// Fall back to a video frame thumbnail.
let asset = AVURLAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
do {
let cgImage = try await generator.image(at: .zero).image
image = UIImage(cgImage: cgImage)
} catch {
image = nil
}
}
}
private struct UploadMediaFilePreview: UIViewControllerRepresentable {
let url: URL
let title: String
func makeUIViewController(context: Context) -> QLPreviewController {
let controller = QLPreviewController()
controller.dataSource = context.coordinator
return controller
}
func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(url: url, title: title)
}
final class Coordinator: NSObject, QLPreviewControllerDataSource {
private let item: PreviewItem
init(url: URL, title: String) {
item = PreviewItem(previewItemURL: url, previewItemTitle: title)
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
item
}
}
}
private struct PreviewView: UIViewControllerRepresentable { private struct PreviewView: UIViewControllerRepresentable {
let mediaURLs: [URL] let mediaURLs: [URL]
let title: String? let title: String?
@@ -396,7 +531,8 @@ struct MediaUploadPreviewScreen_Previews: PreviewProvider, TestablePreview {
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()), mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()),
timelineController: TimelineControllerMock(.init()), timelineController: TimelineControllerMock(.init()),
clientProxy: ClientProxyMock(.init()), clientProxy: ClientProxyMock(.init()),
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock(),
galleryEnabled: true)
static var previews: some View { static var previews: some View {
ElementNavigationStack { ElementNavigationStack {
MediaUploadPreviewScreen(context: viewModel.context) MediaUploadPreviewScreen(context: viewModel.context)
@@ -67,6 +67,8 @@ protocol DeveloperOptionsProtocol: AnyObject {
var linkPreviewsEnabled: Bool { get set } var linkPreviewsEnabled: Bool { get set }
var galleryEnabled: Bool { get set }
var jumpToReadMarkerEnabled: Bool { get set } var jumpToReadMarkerEnabled: Bool { get set }
var linkNewDeviceEnabled: Bool { get set } var linkNewDeviceEnabled: Bool { get set }
@@ -97,6 +97,11 @@ struct DeveloperOptionsScreen: View {
.foregroundStyle(.compound.textCriticalPrimary) .foregroundStyle(.compound.textCriticalPrimary)
} }
Toggle(isOn: $context.galleryEnabled) {
Text("Gallery messages")
Text("Allows sending multiple media in a single message. Received galleries always render regardless of this setting.")
}
Toggle(isOn: $context.jumpToReadMarkerEnabled) { Toggle(isOn: $context.jumpToReadMarkerEnabled) {
Text("Jump to unread") Text("Jump to unread")
Text("Adds a button to jump to the read marker, plus a presence dot on the scroll-to-bottom button when new messages arrive while scrolled away.") Text("Adds a button to jump to the read marker, plus a presence dot on the scroll-to-bottom button when new messages arrive while scrolled away.")
@@ -402,6 +402,16 @@ class TimelineController: TimelineControllerProtocol {
} }
} }
func sendGallery(itemInfos: [GalleryItemInfo],
caption: String?,
formattedCaption: String?,
inReplyToEventID: String?) async -> Result<Void, TimelineControllerError> {
await activeTimeline.sendGallery(itemInfos: itemInfos,
caption: caption,
formattedCaption: formattedCaption,
inReplyToEventID: inReplyToEventID).mapError(TimelineControllerError.timelineProxyError)
}
// MARK: - Polls // MARK: - Polls
func createPoll(question: String, answers: [String], maxSelections: Int, pollKind: Poll.Kind) async -> Result<Void, TimelineControllerError> { func createPoll(question: String, answers: [String], maxSelections: Int, pollKind: Poll.Kind) async -> Result<Void, TimelineControllerError> {
@@ -134,6 +134,11 @@ protocol TimelineControllerProtocol: Sendable {
waveform: [Float], waveform: [Float],
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineControllerError> requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineControllerError>
func sendGallery(itemInfos: [GalleryItemInfo],
caption: String?,
formattedCaption: String?,
inReplyToEventID: String?) async -> Result<Void, TimelineControllerError>
// MARK: - Poll // MARK: - Poll
func createPoll(question: String, answers: [String], maxSelections: Int, pollKind: Poll.Kind) async -> Result<Void, TimelineControllerError> func createPoll(question: String, answers: [String], maxSelections: Int, pollKind: Poll.Kind) async -> Result<Void, TimelineControllerError>
@@ -374,6 +374,30 @@ final class TimelineProxy: TimelineProxyProtocol {
return .success(()) return .success(())
} }
func sendGallery(itemInfos: [GalleryItemInfo],
caption: String?,
formattedCaption: String?,
inReplyToEventID: String?) async -> Result<Void, TimelineProxyError> {
MXLog.info("Sending gallery with \(itemInfos.count) items")
do {
let formatted = formattedCaption.map { FormattedBody(format: .html, body: $0) }
let params = GalleryUploadParameters(caption: caption,
formattedCaption: formatted,
mentions: nil,
inReplyTo: inReplyToEventID)
let handle = try timeline.sendGallery(params: params, itemInfos: itemInfos)
try await handle.join()
MXLog.info("Finished sending gallery")
} catch {
MXLog.error("Failed sending gallery with error: \(error)")
return .failure(.sdkError(error))
}
return .success(())
}
func sendVoiceMessage(url: URL, func sendVoiceMessage(url: URL,
audioInfo: AudioInfo, audioInfo: AudioInfo,
waveform: [Float], waveform: [Float],
@@ -110,6 +110,11 @@ protocol TimelineProxyProtocol: Sendable {
waveform: [Float], waveform: [Float],
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>
func sendGallery(itemInfos: [GalleryItemInfo],
caption: String?,
formattedCaption: String?,
inReplyToEventID: String?) async -> Result<Void, TimelineProxyError>
func sendReadReceipt(for eventID: String, type: ReceiptType) async -> Result<Void, TimelineProxyError> func sendReadReceipt(for eventID: String, type: ReceiptType) async -> Result<Void, TimelineProxyError>
func markAsRead(receiptType: ReceiptType) async -> Result<Void, TimelineProxyError> func markAsRead(receiptType: ReceiptType) async -> Result<Void, TimelineProxyError>
@@ -190,10 +190,12 @@ final class MediaUploadPreviewScreenViewModelTests {
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the screen should be dismissed once all of the files have been sent. // Then the screen should be dismissed once the gallery has been sent in a single request.
try await deferredDismiss.fulfill() try await deferredDismiss.fulfill()
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 1) #expect(timelineProxy.sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount == 1)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2) #expect(timelineProxy.sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArguments?.itemInfos.count == 3)
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 0)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 0)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only a loading indicator should be shown.") #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only a loading indicator should be shown.")
} }
@@ -218,8 +220,8 @@ final class MediaUploadPreviewScreenViewModelTests {
@Test @Test
func multipleFilesWithSendFailure() async throws { func multipleFilesWithSendFailure() async throws {
// Given an upload screen with multiple media files where one of the files will fail to send. // Given an upload screen with multiple media files where the gallery send fails.
setUpViewModel(urls: [fileURL, imageURL, imageURL, fileURL], expectedCaption: nil, simulateImageSendFailures: true) setUpViewModel(urls: [fileURL, imageURL, imageURL, fileURL], expectedCaption: nil, simulateGallerySendFailure: true)
#expect(!context.viewState.shouldDisableInteraction) #expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0) #expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
@@ -230,11 +232,10 @@ final class MediaUploadPreviewScreenViewModelTests {
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the screen should be dismissed so the user can see which files made it into the timeline. // Then the screen should still be dismissed and an error indicator surfaced.
try await deferredDismiss.fulfill() try await deferredDismiss.fulfill()
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 2) #expect(timelineProxy.sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount == 1)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2) #expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 3, "Error indicators for each failure should be shown.")
} }
// MARK: - Helpers // MARK: - Helpers
@@ -268,7 +269,8 @@ final class MediaUploadPreviewScreenViewModelTests {
private func setUpViewModel(urls: [URL], private func setUpViewModel(urls: [URL],
expectedCaption: String?, expectedCaption: String?,
maxUploadSizeResult: Result<UInt, ClientProxyError>? = nil, maxUploadSizeResult: Result<UInt, ClientProxyError>? = nil,
simulateImageSendFailures: Bool = false) { simulateGallerySendFailure: Bool = false,
galleryEnabled: Bool = true) {
timelineProxy = TimelineProxyMock(.init()) timelineProxy = TimelineProxyMock(.init())
timelineProxy.sendAudioUrlAudioInfoCaptionRequestHandleClosure = { [weak self] _, _, caption, _ in timelineProxy.sendAudioUrlAudioInfoCaptionRequestHandleClosure = { [weak self] _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown)) self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
@@ -277,12 +279,15 @@ final class MediaUploadPreviewScreenViewModelTests {
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown)) self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
} }
timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in
guard !simulateImageSendFailures else { return .failure(.sdkError(TestError.unknown)) } self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
return self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
} }
timelineProxy.sendVideoUrlThumbnailURLVideoInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in timelineProxy.sendVideoUrlThumbnailURLVideoInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown)) self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
} }
timelineProxy.sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure = { [weak self] _, caption, _, _ in
guard !simulateGallerySendFailure else { return .failure(.sdkError(TestError.unknown)) }
return self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
clientProxy = ClientProxyMock(.init()) clientProxy = ClientProxyMock(.init())
if let maxUploadSizeResult { if let maxUploadSizeResult {
@@ -296,7 +301,8 @@ final class MediaUploadPreviewScreenViewModelTests {
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings), mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings),
timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)), timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)),
clientProxy: clientProxy, clientProxy: clientProxy,
userIndicatorController: userIndicatorController) userIndicatorController: userIndicatorController,
galleryEnabled: galleryEnabled)
} }
private func verifyCaption(_ caption: String?, expectedCaption: String?) -> Result<Void, TimelineProxyError> { private func verifyCaption(_ caption: String?, expectedCaption: String?) -> Result<Void, TimelineProxyError> {