Extract room screen action handling into separate component (#2045)

* Extract room screen action handling into separate component

* Fix tracing configuration unit tests

* Move the custom reactionsCollapsedBinding to the TimelineReactionsView

* Move voice message playback handling from the timelineController to the room view model

* Reorder methods

* Remove the need for a media player provider in the timeline controller

* Move room attachments opening to the view model, remove the need for a media provider in the timeline controller

* Rename RoomScreenActionsHandler -> RoomScreenInteractionHandler

* Move message sending retry and cancellation to the timeline controller.

* Move audio playback, attachment loading and  user tapping handling into the InteractionHandler

* Fix unit tests

* Switch back swiftlint file_length rule error to 1000 lines
This commit is contained in:
Stefan Ceriu
2023-11-08 18:31:39 +02:00
committed by GitHub
parent 07313926c9
commit abe2080ee2
25 changed files with 1085 additions and 925 deletions
@@ -28,40 +28,57 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
static let toastErrorID = "RoomScreenToastError"
}
private let timelineController: RoomTimelineControllerProtocol
private let roomProxy: RoomProxyProtocol
private let appSettings: AppSettings
private let analytics: AnalyticsService
private let application: ApplicationProtocol
private unowned let userIndicatorController: UserIndicatorControllerProtocol
private let notificationCenterProtocol: NotificationCenterProtocol
private let voiceMessageRecorder: VoiceMessageRecorderProtocol
private let composerFocusedSubject = PassthroughSubject<Bool, Never>()
private let timelineController: RoomTimelineControllerProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let application: ApplicationProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let notificationCenter: NotificationCenterProtocol
private let roomScreenInteractionHandler: RoomScreenInteractionHandler
private let composerFocusedSubject = PassthroughSubject<Bool, Never>()
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
private var canCurrentUserRedact = false
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var paginateBackwardsTask: Task<Void, Never>?
private var resumeVoiceMessagePlaybackAfterScrubbing = false
private var voiceMessageRecorderObserver: AnyCancellable?
init(timelineController: RoomTimelineControllerProtocol,
init(roomProxy: RoomProxyProtocol,
timelineController: RoomTimelineControllerProtocol,
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
roomProxy: RoomProxyProtocol,
appSettings: AppSettings,
analytics: AnalyticsService,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
application: ApplicationProtocol,
notificationCenterProtocol: NotificationCenterProtocol = NotificationCenter.default) {
self.roomProxy = roomProxy
appSettings: AppSettings,
analyticsService: AnalyticsService,
notificationCenter: NotificationCenterProtocol) {
self.timelineController = timelineController
self.appSettings = appSettings
self.analytics = analytics
self.userIndicatorController = userIndicatorController
self.notificationCenterProtocol = notificationCenterProtocol
self.mediaPlayerProvider = mediaPlayerProvider
self.roomProxy = roomProxy
self.appSettings = appSettings
self.analyticsService = analyticsService
self.userIndicatorController = userIndicatorController
self.application = application
voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
self.notificationCenter = notificationCenter
let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
roomScreenInteractionHandler = RoomScreenInteractionHandler(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageMediaManager: voiceMessageMediaManager,
voiceMessageRecorder: voiceMessageRecorder,
userIndicatorController: userIndicatorController,
application: application,
appSettings: appSettings,
analyticsService: analyticsService)
super.init(initialViewState: RoomScreenViewState(roomID: timelineController.roomID,
roomTitle: roomProxy.roomTitle,
@@ -76,13 +93,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
setupSubscriptions()
setupDirectRoomSubscriptionsIfNeeded()
state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in
guard let self else {
return nil
}
return self.timelineItemMenuActionsForItemId(itemId)
return self.roomScreenInteractionHandler.timelineItemMenuActionsForItemId(itemId)
}
state.audioPlayerStateProvider = { [weak self] itemId -> AudioPlayerState? in
@@ -90,7 +107,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return nil
}
return self.audioPlayerState(for: itemId)
return self.roomScreenInteractionHandler.audioPlayerState(for: itemId)
}
buildTimelineViews()
@@ -101,10 +118,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
// MARK: - Public
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
@@ -120,34 +133,27 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .itemDisappeared(let id):
Task { await timelineController.processItemDisappearance(id) }
case .itemTapped(let id):
Task { await itemTapped(with: id) }
Task { await handleItemTapped(with: id) }
case .toggleReaction(let emoji, let itemId):
Task { await timelineController.toggleReaction(emoji, to: itemId) }
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
Task { await sendReadReceiptIfNeeded(for: lastVisibleItemID) }
case .timelineItemMenu(let itemID):
Task {
if case let .success(value) = await roomProxy.canUserRedact(userID: roomProxy.ownUserID) {
canCurrentUserRedact = value
} else {
canCurrentUserRedact = false
}
showTimelineItemActionMenu(for: itemID)
}
roomScreenInteractionHandler.showTimelineItemActionMenu(for: itemID)
case .timelineItemMenuAction(let itemID, let action):
processTimelineItemMenuAction(action, itemID: itemID)
roomScreenInteractionHandler.processTimelineItemMenuAction(action, itemID: itemID)
case .handlePasteOrDrop(let provider):
handlePasteOrDrop(provider)
roomScreenInteractionHandler.handlePasteOrDrop(provider)
case .tappedOnUser(userID: let userID):
Task { await handleTappedUser(userID: userID) }
Task { await roomScreenInteractionHandler.handleTappedUser(userID: userID) }
case .displayEmojiPicker(let itemID):
showEmojiPicker(for: itemID)
roomScreenInteractionHandler.showEmojiPicker(for: itemID)
case .reactionSummary(let itemID, let key):
showReactionSummary(for: itemID, selectedKey: key)
case .retrySend(let itemID):
Task { await handleRetrySend(itemID: itemID) }
Task { await timelineController.retrySending(itemID: itemID) }
case .cancelSend(let itemID):
Task { await handleCancelSend(itemID: itemID) }
Task { await timelineController.cancelSending(itemID: itemID) }
case .paginateBackwards:
paginateBackwards()
case .scrolledToBottom:
@@ -155,17 +161,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
renderPendingTimelineItems()
}
case let .selectedPollOption(pollStartID, optionID):
sendPollResponse(pollStartID: pollStartID, optionID: optionID)
roomScreenInteractionHandler.sendPollResponse(pollStartID: pollStartID, optionID: optionID)
case .playPauseAudio(let itemID):
Task { await timelineController.playPauseAudio(for: itemID) }
Task { await roomScreenInteractionHandler.playPauseAudio(for: itemID) }
case .seekAudio(let itemID, let progress):
Task { await timelineController.seekAudio(for: itemID, progress: progress) }
Task { await roomScreenInteractionHandler.seekAudio(for: itemID, progress: progress) }
case let .endPoll(pollStartID):
state.bindings.confirmationAlertInfo = .init(id: .init(),
title: L10n.actionEndPoll,
message: L10n.commonPollEndConfirmation,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionOk, action: { self.endPoll(pollStartID: pollStartID) }))
secondaryButton: .init(title: L10n.actionOk, action: { self.roomScreenInteractionHandler.endPoll(pollStartID: pollStartID) }))
case .presentCall:
actionsSubject.send(.displayCallScreen)
}
@@ -191,7 +197,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .displayPollForm:
actionsSubject.send(.displayPollForm)
case .handlePasteOrDrop(let provider):
handlePasteOrDrop(provider)
roomScreenInteractionHandler.handlePasteOrDrop(provider)
case .composerModeChanged(mode: let mode):
trackComposerMode(mode)
case .composerFocusedChanged(isFocused: let isFocused):
@@ -199,24 +205,24 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .startVoiceMessageRecording:
Task {
await mediaPlayerProvider.detachAllStates(except: nil)
await startRecordingVoiceMessage()
await roomScreenInteractionHandler.startRecordingVoiceMessage()
}
case .stopVoiceMessageRecording:
Task { await stopRecordingVoiceMessage() }
Task { await roomScreenInteractionHandler.stopRecordingVoiceMessage() }
case .cancelVoiceMessageRecording:
Task { await cancelRecordingVoiceMessage() }
Task { await roomScreenInteractionHandler.cancelRecordingVoiceMessage() }
case .deleteVoiceMessageRecording:
Task { await deleteCurrentVoiceMessage() }
Task { await roomScreenInteractionHandler.deleteCurrentVoiceMessage() }
case .sendVoiceMessage:
Task { await sendCurrentVoiceMessage() }
Task { await roomScreenInteractionHandler.sendCurrentVoiceMessage() }
case .startVoiceMessagePlayback:
Task { await startPlayingRecordedVoiceMessage() }
Task { await roomScreenInteractionHandler.startPlayingRecordedVoiceMessage() }
case .pauseVoiceMessagePlayback:
pausePlayingRecordedVoiceMessage()
roomScreenInteractionHandler.pausePlayingRecordedVoiceMessage()
case .seekVoiceMessagePlayback(let progress):
Task { await seekRecordedVoiceMessage(to: progress) }
Task { await roomScreenInteractionHandler.seekRecordedVoiceMessage(to: progress) }
case .scrubVoiceMessagePlayback(let scrubbing):
Task { await scrubVoiceMessagePlayback(scrubbing: scrubbing) }
Task { await roomScreenInteractionHandler.scrubVoiceMessagePlayback(scrubbing: scrubbing) }
}
}
@@ -275,6 +281,36 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.members, on: self)
.store(in: &cancellables)
roomScreenInteractionHandler.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
guard let self else { return }
switch action {
case .composer(let action):
actionsSubject.send(.composer(action: action))
case .displayError(let type):
displayError(type)
case .displayEmojiPicker(let itemID, let selectedEmojis):
actionsSubject.send(.displayEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis))
case .displayMessageForwarding(let itemID):
actionsSubject.send(.displayMessageForwarding(itemID: itemID))
case .displayReportContent(let itemID, let senderID):
actionsSubject.send(.displayReportContent(itemID: itemID, senderID: senderID))
case .displayMediaUploadPreviewScreen(let url):
actionsSubject.send(.displayMediaUploadPreviewScreen(url: url))
case .displayRoomMemberDetails(let member):
actionsSubject.send(.displayRoomMemberDetails(member: member))
case .showActionMenu(let actionMenuInfo):
state.bindings.actionMenuInfo = actionMenuInfo
case .showDebugInfo(let debugInfo):
state.bindings.debugInfo = debugInfo
case .showConfirmationAlert(let alertInfo):
state.bindings.confirmationAlertInfo = alertInfo
}
}
.store(in: &cancellables)
}
private func setupDirectRoomSubscriptionsIfNeeded() {
@@ -342,7 +378,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// Clear any notifications from notification center.
if lastVisibleItemID.timelineID == state.timelineViewState.timelineIDs.last {
notificationCenterProtocol.post(name: .roomMarkedAsRead, object: roomProxy.id)
notificationCenter.post(name: .roomMarkedAsRead, object: roomProxy.id)
}
switch await timelineController.sendReadReceipt(for: eventItemID) {
@@ -364,9 +400,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return nearestItemID
}
private func itemTapped(with itemID: TimelineItemIdentifier) async {
private func handleItemTapped(with itemID: TimelineItemIdentifier) async {
state.showLoading = true
let action = await timelineController.processItemTap(itemID)
let action = await roomScreenInteractionHandler.processItemTap(itemID)
switch action {
case .displayMediaFile(let file, let title):
@@ -379,9 +415,65 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
state.showLoading = false
}
private func sendCurrentMessage(_ message: String, html: String?, mode: RoomScreenComposerMode, intentionalMentions: IntentionalMentions) async {
guard !message.isEmpty else {
fatalError("This message should never be empty")
}
actionsSubject.send(.composer(action: .clear))
switch mode {
case .reply(let itemId, _, _):
await timelineController.sendMessage(message,
html: html,
inReplyTo: itemId,
intentionalMentions: intentionalMentions)
case .edit(let originalItemId):
await timelineController.editMessage(message,
html: html,
original: originalItemId,
intentionalMentions: intentionalMentions)
case .default:
await timelineController.sendMessage(message,
html: html,
intentionalMentions: intentionalMentions)
case .recordVoiceMessage, .previewVoiceMessage:
fatalError("invalid composer mode.")
}
}
private func trackComposerMode(_ mode: RoomScreenComposerMode) {
var isEdit = false
var isReply = false
switch mode {
case .edit:
isEdit = true
case .reply:
isReply = true
default:
break
}
analyticsService.trackComposer(inThread: false, isEditing: isEdit, isReply: isReply, startsThread: nil)
}
// MARK: - Timeline Item Building
private func buildTimelineViews() {
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
timelineController.timelineItems.filter { $0 is RedactedRoomTimelineItem }.forEach { timelineItem in
// Stops the audio player when a voice message is redacted.
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
return
}
Task { @MainActor in
playerState.detachAudioPlayer()
mediaPlayerProvider.unregister(audioPlayerState: playerState)
}
}
let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in
canGroupItem(timelineItem: current, with: next)
@@ -487,352 +579,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return eventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender
}
private func sendCurrentMessage(_ message: String, html: String?, mode: RoomScreenComposerMode, intentionalMentions: IntentionalMentions) async {
guard !message.isEmpty else {
fatalError("This message should never be empty")
}
actionsSubject.send(.composer(action: .clear))
switch mode {
case .reply(let itemId, _, _):
await timelineController.sendMessage(message,
html: html,
inReplyTo: itemId,
intentionalMentions: intentionalMentions)
case .edit(let originalItemId):
await timelineController.editMessage(message,
html: html,
original: originalItemId,
intentionalMentions: intentionalMentions)
case .default:
await timelineController.sendMessage(message,
html: html,
intentionalMentions: intentionalMentions)
case .recordVoiceMessage, .previewVoiceMessage:
fatalError("invalid composer mode.")
}
}
private func trackComposerMode(_ mode: RoomScreenComposerMode) {
var isEdit = false
var isReply = false
switch mode {
case .edit:
isEdit = true
case .reply:
isReply = true
default:
break
}
analytics.trackComposer(inThread: false, isEditing: isEdit, isReply: isReply, startsThread: nil)
}
private func displayError(_ type: RoomScreenErrorType) {
switch type {
case .alert(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: L10n.commonError,
message: message)
case .toast(let message):
userIndicatorController.submitIndicator(UserIndicator(id: Constants.toastErrorID,
type: .toast,
title: message,
iconName: "xmark"))
}
}
// MARK: TimelineItemActionMenu
private func showTimelineItemActionMenu(for itemID: TimelineItemIdentifier) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
// Don't show a menu for non-event based items.
return
}
actionsSubject.send(.composer(action: .removeFocus))
state.bindings.actionMenuInfo = .init(item: eventTimelineItem)
}
// swiftlint:disable:next cyclomatic_complexity
private func timelineItemMenuActionsForItemId(_ itemID: TimelineItemIdentifier) -> TimelineItemMenuActions? {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
let item = timelineItem as? EventBasedTimelineItemProtocol else {
// Don't show a context menu for non-event based items.
return nil
}
if timelineItem is StateRoomTimelineItem {
// Don't show a context menu for state events.
return nil
}
var debugActions: [TimelineItemMenuAction] = []
if appSettings.canShowDeveloperOptions || appSettings.viewSourceEnabled {
debugActions.append(.viewSource)
}
if let encryptedItem = timelineItem as? EncryptedRoomTimelineItem {
switch encryptedItem.encryptionType {
case .megolmV1AesSha2(let sessionID):
debugActions.append(.retryDecryption(sessionID: sessionID))
default:
break
}
return .init(actions: [.copyPermalink], debugActions: debugActions)
}
var actions: [TimelineItemMenuAction] = []
if item.canBeRepliedTo {
if let messageItem = item as? EventBasedMessageTimelineItemProtocol {
actions.append(.reply(isThread: messageItem.isThreaded))
} else {
actions.append(.reply(isThread: false))
}
}
if item.isForwardable {
actions.append(.forward(itemID: itemID))
}
if item.isEditable {
actions.append(.edit)
}
if item.isCopyable {
actions.append(.copy)
}
actions.append(.copyPermalink)
if canRedactItem(item), let poll = item.pollIfAvailable, !poll.hasEnded, let eventID = itemID.eventID {
actions.append(.endPoll(pollStartID: eventID))
}
if canRedactItem(item) {
actions.append(.redact)
}
if !item.isOutgoing {
actions.append(.report)
}
if item.hasFailedToSend {
actions = actions.filter(\.canAppearInFailedEcho)
}
if item.isRedacted {
actions = actions.filter(\.canAppearInRedacted)
}
return .init(actions: actions, debugActions: debugActions)
}
private func canRedactItem(_ item: EventBasedTimelineItemProtocol) -> Bool {
item.isOutgoing || (canCurrentUserRedact && !roomProxy.isDirect)
}
private func processTimelineItemMenuAction(_ action: TimelineItemMenuAction, itemID: TimelineItemIdentifier) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
return
}
switch action {
case .copy:
guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else {
return
}
UIPasteboard.general.string = messageTimelineItem.body
case .edit:
guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else {
return
}
let text: String
switch messageTimelineItem.contentType {
case .text(let textItem):
if ServiceLocator.shared.settings.richTextEditorEnabled, let formattedBodyHTMLString = textItem.formattedBodyHTMLString {
text = formattedBodyHTMLString
} else {
text = messageTimelineItem.body
}
case .emote(let emoteItem):
if ServiceLocator.shared.settings.richTextEditorEnabled, let formattedBodyHTMLString = emoteItem.formattedBodyHTMLString {
text = "/me " + formattedBodyHTMLString
} else {
text = "/me " + messageTimelineItem.body
}
default:
text = messageTimelineItem.body
}
actionsSubject.send(.composer(action: .setText(text: text)))
actionsSubject.send(.composer(action: .setMode(mode: .edit(originalItemId: messageTimelineItem.id))))
case .copyPermalink:
do {
guard let eventID = eventTimelineItem.id.eventID else {
displayError(.alert(L10n.errorFailedCreatingThePermalink))
break
}
let permalink = try PermalinkBuilder.permalinkTo(eventIdentifier: eventID, roomIdentifier: timelineController.roomID,
baseURL: appSettings.permalinkBaseURL)
UIPasteboard.general.url = permalink
} catch {
displayError(.alert(L10n.errorFailedCreatingThePermalink))
}
case .redact:
Task {
if eventTimelineItem.hasFailedToSend {
await timelineController.cancelSend(itemID)
} else {
await timelineController.redact(itemID)
}
}
case .reply:
let replyInfo = buildReplyInfo(for: eventTimelineItem)
let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, contentType: replyInfo.type)
actionsSubject.send(.composer(action: .setMode(mode: .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails, isThread: replyInfo.isThread))))
case .forward(let itemID):
actionsSubject.send(.displayMessageForwarding(itemID: itemID))
case .viewSource:
let debugInfo = timelineController.debugInfo(for: eventTimelineItem.id)
MXLog.info(debugInfo)
state.bindings.debugInfo = debugInfo
case .retryDecryption(let sessionID):
Task {
await timelineController.retryDecryption(for: sessionID)
}
case .report:
actionsSubject.send(.displayReportContent(itemID: itemID, senderID: eventTimelineItem.sender.id))
case .react:
showEmojiPicker(for: itemID)
case .endPoll(let pollStartID):
endPoll(pollStartID: pollStartID)
}
if action.switchToDefaultComposer {
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
}
// Pasting and dropping
private func handlePasteOrDrop(_ provider: NSItemProvider) {
guard let contentType = provider.preferredContentType,
let preferredExtension = contentType.preferredFilenameExtension else {
MXLog.error("Invalid NSItemProvider: \(provider)")
displayError(.toast(L10n.screenRoomErrorFailedProcessingMedia))
return
}
let providerSuggestedName = provider.suggestedName
let providerDescription = provider.description
_ = provider.loadDataRepresentation(for: contentType) { data, error in
Task { @MainActor in
let loadingIndicatorIdentifier = UUID().uuidString
self.userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
defer {
self.userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
}
if let error {
self.displayError(.toast(L10n.screenRoomErrorFailedProcessingMedia))
MXLog.error("Failed processing NSItemProvider: \(providerDescription) with error: \(error)")
return
}
guard let data else {
self.displayError(.toast(L10n.screenRoomErrorFailedProcessingMedia))
MXLog.error("Invalid NSItemProvider data: \(providerDescription)")
return
}
do {
let url = try await Task.detached {
if let filename = providerSuggestedName {
let hasExtension = !(filename as NSString).pathExtension.isEmpty
let filename = hasExtension ? filename : "\(filename).\(preferredExtension)"
return try FileManager.default.writeDataToTemporaryDirectory(data: data, fileName: filename)
} else {
let filename = "\(UUID().uuidString).\(preferredExtension)"
return try FileManager.default.writeDataToTemporaryDirectory(data: data, fileName: filename)
}
}.value
self.actionsSubject.send(.displayMediaUploadPreviewScreen(url: url))
} catch {
self.displayError(.toast(L10n.screenRoomErrorFailedProcessingMedia))
MXLog.error("Failed storing NSItemProvider data \(providerDescription) with error: \(error)")
}
}
}
}
private func buildReplyInfo(for item: EventBasedTimelineItemProtocol) -> ReplyInfo {
guard let messageItem = item as? EventBasedMessageTimelineItemProtocol else {
return .init(type: .text(.init(body: item.body)), isThread: false)
}
return .init(type: messageItem.contentType, isThread: messageItem.isThreaded)
}
private func handleTappedUser(userID: String) async {
// This is generally fast but it could take some time for rooms with thousands of users on first load
// Show a loader only if it takes more than 0.1 seconds
showLoadingIndicator(with: .milliseconds(100))
let result = await roomProxy.getMember(userID: userID)
hideLoadingIndicator()
switch result {
case .success(let member):
actionsSubject.send(.displayRoomMemberDetails(member: member))
case .failure(let error):
displayError(.alert(L10n.screenRoomErrorFailedRetrievingUserDetails))
MXLog.error("Failed retrieving the user given the following id \(userID) with error: \(error)")
}
}
private func handleRetrySend(itemID: TimelineItemIdentifier) async {
guard let transactionID = itemID.transactionID else {
MXLog.error("Failed Retry Send: missing transaction ID")
return
}
await roomProxy.retrySend(transactionID: transactionID)
}
private func handleCancelSend(itemID: TimelineItemIdentifier) async {
guard let transactionID = itemID.transactionID else {
MXLog.error("Failed Cancel Send: missing transaction ID")
return
}
await roomProxy.cancelSend(transactionID: transactionID)
}
private static let loadingIndicatorIdentifier = "RoomScreenLoadingIndicator"
private func showLoadingIndicator(with delay: Duration) {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
title: L10n.commonLoading,
persistent: true),
delay: delay)
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
// MARK: - Direct chats logics
private func showInviteAlert() {
@@ -878,17 +624,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
// MARK: - Reactions
private func showEmojiPicker(for itemID: TimelineItemIdentifier) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
timelineItem.isReactable,
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
return
}
let selectedEmojis = Set(eventTimelineItem.properties.reactions.compactMap { $0.isHighlighted ? $0.key : nil })
actionsSubject.send(.displayEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis))
}
private func showReactionSummary(for itemID: TimelineItemIdentifier, selectedKey: String) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
@@ -897,157 +633,22 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.bindings.reactionSummaryInfo = .init(reactions: eventTimelineItem.properties.reactions, selectedKey: selectedKey)
}
// MARK: - Polls
private func sendPollResponse(pollStartID: String, optionID: String) {
Task {
let sendPollResponseResult = await roomProxy.sendPollResponse(pollStartID: pollStartID, answers: [optionID])
analytics.trackPollVote()
switch sendPollResponseResult {
case .success:
break
case .failure:
displayError(.toast(L10n.errorUnknown))
}
// MARK: - User Indicators
private func displayError(_ type: RoomScreenErrorType) {
switch type {
case .alert(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: L10n.commonError,
message: message)
case .toast(let message):
userIndicatorController.submitIndicator(UserIndicator(id: Constants.toastErrorID,
type: .toast,
title: message,
iconName: "xmark"))
}
}
private func endPoll(pollStartID: String) {
Task {
let endPollResult = await roomProxy.endPoll(pollStartID: pollStartID,
text: "The poll with event id: \(pollStartID) has ended")
analytics.trackPollEnd()
switch endPollResult {
case .success:
break
case .failure:
displayError(.toast(L10n.errorUnknown))
}
}
}
// MARK: - Audio
private func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
timelineController.audioPlayerState(for: itemID)
}
// MARK: - Voice message
private func handleVoiceMessageRecorderAction(_ action: VoiceMessageRecorderAction) {
MXLog.debug("handling voice recorder action: \(action) - (audio)")
switch action {
case .didStartRecording(let audioRecorder):
let audioRecordState = AudioRecorderState()
audioRecordState.attachAudioRecorder(audioRecorder)
actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState))))
case .didStopRecording(let previewAudioPlayerState, let url):
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: previewAudioPlayerState, waveform: .url(url), isUploading: false))))
case .didFailWithError(let error):
switch error {
case .audioRecorderError(.recordPermissionNotGranted):
MXLog.info("permission to record audio has not been granted.")
state.bindings.confirmationAlertInfo = .init(id: .init(),
title: L10n.dialogPermissionMicrophoneTitleIos(InfoPlistReader.main.bundleDisplayName),
message: L10n.dialogPermissionMicrophoneDescriptionIos,
primaryButton: .init(title: L10n.commonSettings, action: { [weak self] in self?.openSystemSettings() }),
secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil))
default:
MXLog.error("failed to start voice message recording. \(error)")
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
}
}
private func startRecordingVoiceMessage() async {
voiceMessageRecorderObserver = voiceMessageRecorder.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
self?.handleVoiceMessageRecorderAction(action)
}
await voiceMessageRecorder.startRecording()
}
private func stopRecordingVoiceMessage() async {
await voiceMessageRecorder.stopRecording()
}
private func cancelRecordingVoiceMessage() async {
await voiceMessageRecorder.cancelRecording()
voiceMessageRecorderObserver = nil
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
private func deleteCurrentVoiceMessage() async {
await voiceMessageRecorder.deleteRecording()
voiceMessageRecorderObserver = nil
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
private func sendCurrentVoiceMessage() async {
guard let audioPlayerState = voiceMessageRecorder.previewAudioPlayerState, let recordingURL = voiceMessageRecorder.recordingURL else {
displayError(.alert(L10n.errorFailedUploadingVoiceMessage))
return
}
analytics.trackComposer(inThread: false,
isEditing: false,
isReply: false,
messageType: .voiceMessage,
startsThread: nil)
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL), isUploading: true))))
await voiceMessageRecorder.stopPlayback()
switch await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: AudioConverter()) {
case .success:
await deleteCurrentVoiceMessage()
case .failure(let error):
MXLog.error("failed to send the voice message. \(error)")
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL), isUploading: false))))
displayError(.alert(L10n.errorFailedUploadingVoiceMessage))
}
}
private func startPlayingRecordedVoiceMessage() async {
await mediaPlayerProvider.detachAllStates(except: voiceMessageRecorder.previewAudioPlayerState)
if case .failure(let error) = await voiceMessageRecorder.startPlayback() {
MXLog.error("failed to play recorded voice message. \(error)")
}
}
private func pausePlayingRecordedVoiceMessage() {
voiceMessageRecorder.pausePlayback()
}
private func seekRecordedVoiceMessage(to progress: Double) async {
await mediaPlayerProvider.detachAllStates(except: voiceMessageRecorder.previewAudioPlayerState)
await voiceMessageRecorder.seekPlayback(to: progress)
}
private func scrubVoiceMessagePlayback(scrubbing: Bool) async {
guard let audioPlayerState = voiceMessageRecorder.previewAudioPlayerState else {
return
}
if scrubbing {
if audioPlayerState.playbackState == .playing {
resumeVoiceMessagePlaybackAfterScrubbing = true
pausePlayingRecordedVoiceMessage()
}
} else {
if resumeVoiceMessagePlaybackAfterScrubbing {
resumeVoiceMessagePlaybackAfterScrubbing = false
await startPlayingRecordedVoiceMessage()
}
}
}
private func openSystemSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
application.open(url)
}
}
private extension RoomProxyProtocol {
@@ -1057,35 +658,19 @@ private extension RoomProxyProtocol {
}
}
extension RoomScreenViewModel.Context {
/// A function to make it easier to bind to reactions expand/collapsed state
/// - Parameter itemID: The id of the timeline item the reacted to
/// - Returns: Wether the reactions should show in the collapsed state, true by default.
func reactionsCollapsedBinding(for itemID: TimelineItemIdentifier) -> Binding<Bool> {
Binding(get: {
self.reactionsCollapsed[itemID] ?? true
}, set: {
self.reactionsCollapsed[itemID] = $0
})
}
}
// MARK: - Mocks
extension RoomScreenViewModel {
static let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
static let mock = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
application: ApplicationMock.default)
}
private struct ReplyInfo {
let type: EventBasedMessageTimelineItemContentType
let isThread: Bool
application: ApplicationMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
notificationCenter: NotificationCenterMock())
}
private struct RoomContextKey: EnvironmentKey {