Support sending gallery messages behind a feature flag (#5909)

dds sending of multi-media "gallery" messages, gated behind the new galleryEnabled developer option (Developer Options → Room, off by default). Rendering of received galleries ships in a separate PR.

Wires sendGallery through TimelineProxy/TimelineController onto the SDK's Timeline.sendGallery.
MediaUploadPreviewScreen sends selected items as one gallery when the flag is on.
Co-authored with the original author of #5601.

NOTE: This PR allows to send gallery messages, but they won't be rendered without the gallery timeline item PR.
This commit is contained in:
Mauro
2026-07-21 17:25:30 +02:00
committed by GitHub
parent 711cb0a05c
commit b5b28b9986
14 changed files with 252 additions and 30 deletions
@@ -416,6 +416,11 @@ final nonisolated class AppSettings: @unchecked Sendable {
@UserPreference(defaultValue: false)
var linkPreviewsEnabled: Bool
/// Enables *sending* gallery messages (multiple media in a single message).
/// Received galleries are always rendered regardless of this flag.
@UserPreference(defaultValue: false)
var galleryEnabled: Bool
@UserPreference(defaultValue: false)
var jumpToReadMarkerEnabled: Bool
@@ -1120,6 +1120,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
caption: caption,
title: title,
shouldShowCaptionWarning: flowParameters.appSettings.shouldShowMediaCaptionWarning,
galleryEnabled: flowParameters.appSettings.galleryEnabled,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings),
timelineController: timelineController,
clientProxy: userSession.clientProxy,
@@ -12267,6 +12267,48 @@ nonisolated class TimelineControllerMock: TimelineControllerProtocol, @unchecked
return sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue
}
}
//MARK: - sendGallery
private let sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount = 0
var sendGalleryItemInfosCaptionInReplyToEventIDCallsCount: Int {
get { sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount } }
set { sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount = newValue } }
}
var sendGalleryItemInfosCaptionInReplyToEventIDCalled: Bool {
return sendGalleryItemInfosCaptionInReplyToEventIDCallsCount > 0
}
private let sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)?
var sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)? {
get { sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments } }
set { sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments = newValue } }
}
private let sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)] = []
var sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)] {
get { sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations } }
set { sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations = newValue } }
}
private let sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue: Result<Void, TimelineControllerError>!
var sendGalleryItemInfosCaptionInReplyToEventIDReturnValue: Result<Void, TimelineControllerError>! {
get { sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue } }
set { sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDClosure: (([GalleryItemInfo], String?, String?) async -> Result<Void, TimelineControllerError>)?
@concurrent func sendGallery(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?) async -> Result<Void, TimelineControllerError> {
sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount += 1 }
sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments = (itemInfos: itemInfos, caption: caption, inReplyToEventID: inReplyToEventID)
sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations.append((itemInfos: itemInfos, caption: caption, inReplyToEventID: inReplyToEventID)) }
if let sendGalleryItemInfosCaptionInReplyToEventIDClosure = sendGalleryItemInfosCaptionInReplyToEventIDClosure {
return await sendGalleryItemInfosCaptionInReplyToEventIDClosure(itemInfos, caption, inReplyToEventID)
} else {
return sendGalleryItemInfosCaptionInReplyToEventIDReturnValue
}
}
//MARK: - createPoll
private let createPollQuestionAnswersMaxSelectionsPollKindCallsCountLock = NSLock()
@@ -13022,6 +13064,48 @@ nonisolated class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable
return sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue
}
}
//MARK: - sendGallery
private let sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount = 0
var sendGalleryItemInfosCaptionInReplyToEventIDCallsCount: Int {
get { sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount } }
set { sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount = newValue } }
}
var sendGalleryItemInfosCaptionInReplyToEventIDCalled: Bool {
return sendGalleryItemInfosCaptionInReplyToEventIDCallsCount > 0
}
private let sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)?
var sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)? {
get { sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments } }
set { sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments = newValue } }
}
private let sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)] = []
var sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)] {
get { sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations } }
set { sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations = newValue } }
}
private let sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock = NSLock()
private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue: Result<Void, TimelineProxyError>!
var sendGalleryItemInfosCaptionInReplyToEventIDReturnValue: Result<Void, TimelineProxyError>! {
get { sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue } }
set { sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDClosure: (([GalleryItemInfo], String?, String?) async -> Result<Void, TimelineProxyError>)?
@concurrent func sendGallery(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?) async -> Result<Void, TimelineProxyError> {
sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount += 1 }
sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments = (itemInfos: itemInfos, caption: caption, inReplyToEventID: inReplyToEventID)
sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations.append((itemInfos: itemInfos, caption: caption, inReplyToEventID: inReplyToEventID)) }
if let sendGalleryItemInfosCaptionInReplyToEventIDClosure = sendGalleryItemInfosCaptionInReplyToEventIDClosure {
return await sendGalleryItemInfosCaptionInReplyToEventIDClosure(itemInfos, caption, inReplyToEventID)
} else {
return sendGalleryItemInfosCaptionInReplyToEventIDReturnValue
}
}
//MARK: - sendReadReceipt
private let sendReadReceiptForTypeCallsCountLock = NSLock()
@@ -162,6 +162,16 @@ struct TimelineControllerMockConfiguration {
return .success(())
}
sendGalleryItemInfosCaptionInReplyToEventIDClosure = { [weak self, timelineProxy] itemInfos, caption, inReplyToEventID in
self?.callbacks.send(.messageSentOrEdited)
if let timelineProxy {
return await timelineProxy.sendGallery(itemInfos: itemInfos,
caption: caption,
inReplyToEventID: inReplyToEventID).mapError(TimelineControllerError.timelineProxyError)
}
return .success(())
}
sendVoiceMessageUrlAudioInfoWaveformRequestHandleClosure = { [weak self, timelineProxy] url, audioInfo, waveform, requestHandle in
self?.callbacks.send(.messageSentOrEdited)
if let timelineProxy {
@@ -14,6 +14,8 @@ struct MediaUploadPreviewScreenCoordinatorParameters {
let caption: NSAttributedString?
let title: String?
let shouldShowCaptionWarning: Bool
/// When `false`, multiple attachments are sent as individual messages.
let galleryEnabled: Bool
let mediaUploadingPreprocessor: MediaUploadingPreprocessor
let timelineController: TimelineControllerProtocol
let clientProxy: ClientProxyProtocol
@@ -38,6 +40,7 @@ final class MediaUploadPreviewScreenCoordinator: CoordinatorProtocol {
caption: parameters.caption,
title: parameters.title,
shouldShowCaptionWarning: parameters.shouldShowCaptionWarning,
galleryEnabled: parameters.galleryEnabled,
mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor,
timelineController: parameters.timelineController,
clientProxy: parameters.clientProxy,
@@ -22,6 +22,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
private var processingTask: Task<Result<[MediaInfo], MediaUploadingPreprocessorError>, Never>
private var requestHandle: SendAttachmentJoinHandleProtocol?
private let clientProxy: ClientProxyProtocol
private let galleryEnabled: Bool
private var actionsSubject: PassthroughSubject<MediaUploadPreviewScreenViewModelAction, Never> = .init()
@@ -33,6 +34,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
caption: NSAttributedString?,
title: String?,
shouldShowCaptionWarning: Bool,
galleryEnabled: Bool,
mediaUploadingPreprocessor: MediaUploadingPreprocessor,
timelineController: TimelineControllerProtocol,
clientProxy: ClientProxyProtocol,
@@ -42,6 +44,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
self.timelineController = timelineController
self.clientProxy = clientProxy
self.userIndicatorController = userIndicatorController
self.galleryEnabled = galleryEnabled
// Start processing the media whilst the user is reviewing it/adding a caption.
processingTask = Self.processMedia(at: mediaURLs, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy)
@@ -54,7 +57,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
override func process(viewAction: MediaUploadPreviewScreenViewAction) {
// Get the current caption before all the processing starts.
var caption = state.bindings.caption.nonBlankString
let caption = state.bindings.caption.nonBlankString
switch viewAction {
case .send:
@@ -65,14 +68,25 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
switch await processingTask.value {
case .success(let mediaInfos):
for mediaInfo in mediaInfos {
switch await sendAttachment(mediaInfo: mediaInfo, caption: caption) {
if galleryEnabled, mediaInfos.count > 1 {
switch await sendGallery(mediaInfos: mediaInfos, caption: caption) {
case .success:
caption = nil // Set the caption only on the first uploaded file.
break
case .failure(let error):
MXLog.error("Failed sending media with error: \(error)")
MXLog.error("Failed sending gallery with error: \(error)")
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)
@@ -107,6 +121,39 @@ 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):
.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):
.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):
.audio(audioInfo: audioInfo,
source: .file(filename: audioURL.path(percentEncoded: false)),
caption: nil,
formattedCaption: nil)
case let .file(fileURL, fileInfo):
.file(fileInfo: fileInfo,
source: .file(filename: fileURL.path(percentEncoded: false)),
caption: nil,
formattedCaption: nil)
}
}
return await timelineController.sendGallery(itemInfos: itemInfos,
caption: caption,
inReplyToEventID: nil)
}
func stopProcessing() {
processingTask.cancel()
}
@@ -6,6 +6,7 @@
// Please see LICENSE files in the repository root for full details.
//
import AVFoundation
import Combine
import Compound
import GameController
@@ -35,17 +36,7 @@ struct MediaUploadPreviewScreen: View {
mainContent
.id(context.viewState.mediaURLs)
.ignoresSafeArea(edges: [.horizontal])
.safeAreaInset(edge: .top) {
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)
}
}
.overlay(alignment: .top) { galleryBadge }
.safeAreaInset(edge: .bottom, spacing: 0) {
composer
.padding(.horizontal, 12)
@@ -74,6 +65,19 @@ 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(.compound.bgCanvasDefault.opacity(0.85), in: .capsule)
.padding(.top, 12)
}
}
@ViewBuilder
private var mainContent: some View {
if ProcessInfo.processInfo.isiOSAppOnMac {
@@ -393,6 +397,7 @@ struct MediaUploadPreviewScreen_Previews: PreviewProvider, TestablePreview {
caption: nil,
title: "App Icon.png",
shouldShowCaptionWarning: true,
galleryEnabled: true,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()),
timelineController: TimelineControllerMock(.init()),
clientProxy: ClientProxyMock(.init()),
@@ -67,6 +67,8 @@ protocol DeveloperOptionsProtocol: AnyObject {
var linkPreviewsEnabled: Bool { get set }
var galleryEnabled: Bool { get set }
var jumpToReadMarkerEnabled: Bool { get set }
var linkNewDeviceEnabled: Bool { get set }
@@ -97,6 +97,11 @@ struct DeveloperOptionsScreen: View {
.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) {
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.")
@@ -402,6 +402,14 @@ class TimelineController: TimelineControllerProtocol {
}
}
func sendGallery(itemInfos: [GalleryItemInfo],
caption: String?,
inReplyToEventID: String?) async -> Result<Void, TimelineControllerError> {
await activeTimeline.sendGallery(itemInfos: itemInfos,
caption: caption,
inReplyToEventID: inReplyToEventID).mapError(TimelineControllerError.timelineProxyError)
}
// MARK: - Polls
func createPoll(question: String, answers: [String], maxSelections: Int, pollKind: Poll.Kind) async -> Result<Void, TimelineControllerError> {
@@ -134,6 +134,10 @@ protocol TimelineControllerProtocol: Sendable {
waveform: [Float],
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineControllerError>
func sendGallery(itemInfos: [GalleryItemInfo],
caption: String?,
inReplyToEventID: String?) async -> Result<Void, TimelineControllerError>
// MARK: - Poll
func createPoll(question: String, answers: [String], maxSelections: Int, pollKind: Poll.Kind) async -> Result<Void, TimelineControllerError>
@@ -374,6 +374,27 @@ final class TimelineProxy: TimelineProxyProtocol {
return .success(())
}
func sendGallery(itemInfos: [GalleryItemInfo],
caption: String?,
inReplyToEventID: String?) async -> Result<Void, TimelineProxyError> {
MXLog.info("Sending gallery with \(itemInfos.count) items")
do {
let handle = try timeline.sendGallery(params: .init(caption: caption,
formattedCaption: nil, // Rust will build this from the caption's markdown.
mentions: nil,
inReplyTo: inReplyToEventID),
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,
audioInfo: AudioInfo,
waveform: [Float],
@@ -110,6 +110,10 @@ protocol TimelineProxyProtocol: Sendable {
waveform: [Float],
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>
func sendGallery(itemInfos: [GalleryItemInfo],
caption: String?,
inReplyToEventID: String?) async -> Result<Void, TimelineProxyError>
func sendReadReceipt(for eventID: String, type: ReceiptType) async -> Result<Void, TimelineProxyError>
func markAsRead(receiptType: ReceiptType) async -> Result<Void, TimelineProxyError>
@@ -177,7 +177,7 @@ final class MediaUploadPreviewScreenViewModelTests {
}
@Test
func multipleFiles() async throws {
func gallery() async throws {
// Given an upload screen with multiple media files.
setUpViewModel(urls: [fileURL, imageURL, fileURL], expectedCaption: nil)
#expect(!context.viewState.shouldDisableInteraction)
@@ -190,15 +190,17 @@ final class MediaUploadPreviewScreenViewModelTests {
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#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()
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 1)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2)
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 1)
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments?.itemInfos.count == 3)
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 0)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 0)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only a loading indicator should be shown.")
}
@Test
func multipleFilesWithProcessingFailure() async throws {
func galleryWithProcessingFailure() async throws {
// Given an upload screen for a non-existent file.
setUpViewModel(urls: [imageURL, fileURL, badImageURL], expectedCaption: nil)
#expect(!context.viewState.shouldDisableInteraction)
@@ -217,9 +219,9 @@ final class MediaUploadPreviewScreenViewModelTests {
}
@Test
func multipleFilesWithSendFailure() async throws {
// Given an upload screen with multiple media files where one of the files will fail to send.
setUpViewModel(urls: [fileURL, imageURL, imageURL, fileURL], expectedCaption: nil, simulateImageSendFailures: true)
func galleryWithSendFailure() async throws {
// Given an upload screen with multiple media files where the gallery send fails.
setUpViewModel(urls: [fileURL, imageURL, imageURL, fileURL], expectedCaption: nil, simulateGallerySendFailure: true)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
@@ -230,11 +232,27 @@ final class MediaUploadPreviewScreenViewModelTests {
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#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()
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 2)
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 1)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.")
}
@Test
func galleryDisabledSendsIndividualMessages() async throws {
// Given an upload screen with multiple media files and gallery sending disabled.
setUpViewModel(urls: [fileURL, imageURL, fileURL], expectedCaption: nil, galleryEnabled: false)
#expect(!context.viewState.shouldDisableInteraction)
// When attempting to send the files.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
// Then each file should be sent as an individual message rather than a single gallery.
try await deferredDismiss.fulfill()
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 0)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 3, "Error indicators for each failure should be shown.")
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 1)
}
// MARK: - Helpers
@@ -268,7 +286,8 @@ final class MediaUploadPreviewScreenViewModelTests {
private func setUpViewModel(urls: [URL],
expectedCaption: String?,
maxUploadSizeResult: Result<UInt, ClientProxyError>? = nil,
simulateImageSendFailures: Bool = false) {
simulateGallerySendFailure: Bool = false,
galleryEnabled: Bool = true) {
timelineProxy = TimelineProxyMock(.init())
timelineProxy.sendAudioUrlAudioInfoCaptionRequestHandleClosure = { [weak self] _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
@@ -277,12 +296,15 @@ final class MediaUploadPreviewScreenViewModelTests {
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in
guard !simulateImageSendFailures else { return .failure(.sdkError(TestError.unknown)) }
return self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendVideoUrlThumbnailURLVideoInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDClosure = { [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())
if let maxUploadSizeResult {
@@ -293,6 +315,7 @@ final class MediaUploadPreviewScreenViewModelTests {
caption: nil,
title: "Some File",
shouldShowCaptionWarning: true,
galleryEnabled: galleryEnabled,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings),
timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)),
clientProxy: clientProxy,