mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
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:
@@ -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 {
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+52
-5
@@ -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()
|
||||
}
|
||||
|
||||
+16
-11
@@ -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()),
|
||||
|
||||
+2
@@ -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 }
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user