From abe2080ee2faddff738093e71ec87706ea367580 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Wed, 8 Nov 2023 18:31:39 +0200 Subject: [PATCH] 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 --- .swiftlint.yml | 2 +- ElementX.xcodeproj/project.pbxproj | 4 + .../RoomFlowCoordinator.swift | 8 +- .../RoomScreen/RoomScreenCoordinator.swift | 13 +- .../RoomScreenInteractionHandler.swift | 652 +++++++++++++++ .../RoomScreen/RoomScreenViewModel.swift | 765 ++++-------------- .../RoomScreen/View/RoomHeaderView.swift | 24 +- .../Screens/RoomScreen/View/RoomScreen.swift | 12 +- .../Style/TimelineItemBubbledStylerView.swift | 6 +- .../Style/TimelineItemPlainStylerView.swift | 6 +- .../Supplementary/TimelineReactionsView.swift | 52 +- .../TimelineReadReceiptsView.swift | 13 +- .../View/Timeline/UITimelineView.swift | 12 +- .../RoomScreen/View/TimelineView.swift | 14 +- .../MockRoomTimelineController.swift | 27 +- .../MockRoomTimelineControllerFactory.swift | 4 - .../RoomTimelineController.swift | 161 +--- .../RoomTimelineControllerFactory.swift | 7 - ...oomTimelineControllerFactoryProtocol.swift | 4 - .../RoomTimelineControllerProtocol.swift | 10 +- .../UITests/UITestsAppCoordinator.swift | 13 + UnitTests/Sources/PillContextTests.swift | 36 +- .../Sources/RoomScreenViewModelTests.swift | 157 ++-- .../Sources/TracingConfigurationTests.swift | 4 +- .../test_timelineReactionView.1.png | 4 +- 25 files changed, 1085 insertions(+), 925 deletions(-) create mode 100644 ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 85a50662c..1166ea1f5 100755 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -25,7 +25,7 @@ line_length: file_length: warning: 1000 - error: 1200 + error: 1000 type_name: min_length: 3 diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index f36b6bdc3..62c10b5a2 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -263,6 +263,7 @@ 4714991754A08B58B4D7ED85 /* OnboardingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F27BAB69EB568369F1F6B3 /* OnboardingScreenViewModelProtocol.swift */; }; 47305C0911C9E1AA774A4000 /* TemplateScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA90BD288E5AE6BC643AFDDF /* TemplateScreenCoordinator.swift */; }; 4799A852132F1744E2825994 /* CreateRoomViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340179A0FC1AD4AEDA7FC134 /* CreateRoomViewModelProtocol.swift */; }; + 47FF70C051A991FB65CDBCF3 /* RoomScreenInteractionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0135A608FFAD86E6674EE730 /* RoomScreenInteractionHandler.swift */; }; 4807E8F51DB54F56B25E1C7E /* AppLockSetupSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8C38663020DF2EB2D13F5E /* AppLockSetupSettingsScreenViewModel.swift */; }; 484202C5D50983442D24D061 /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; }; 491D62ACD19E6F134B1766AF /* RoomNotificationSettingsUserDefinedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3203C6566DC17B7AECC1B7FD /* RoomNotificationSettingsUserDefinedScreen.swift */; }; @@ -1029,6 +1030,7 @@ /* Begin PBXFileReference section */ 00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreen.swift; sourceTree = ""; }; 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelTests.swift; sourceTree = ""; }; + 0135A608FFAD86E6674EE730 /* RoomScreenInteractionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenInteractionHandler.swift; sourceTree = ""; }; 01C4C7DB37597D7D8379511A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 022E6BD64CB4610B9C95FC02 /* UserDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenViewModel.swift; sourceTree = ""; }; 024F7398C5FC12586FB10E9D /* EffectsScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EffectsScene.swift; sourceTree = ""; }; @@ -3066,6 +3068,7 @@ isa = PBXGroup; children = ( B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */, + 0135A608FFAD86E6674EE730 /* RoomScreenInteractionHandler.swift */, C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */, 9CE3C90E487B255B735D73C8 /* RoomScreenViewModel.swift */, A00C7A331B72C0F05C00392F /* RoomScreenViewModelProtocol.swift */, @@ -5706,6 +5709,7 @@ FA9C427FFB11B1AA2DCC5602 /* RoomProxyProtocol.swift in Sources */, C55A44C99F64A479ABA85B46 /* RoomScreen.swift in Sources */, A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */, + 47FF70C051A991FB65CDBCF3 /* RoomScreenInteractionHandler.swift in Sources */, 352C439BE0F75E101EF11FB1 /* RoomScreenModels.swift in Sources */, 7BB31E67648CF32D2AB5E502 /* RoomScreenViewModel.swift in Sources */, 617624A97BDBB75ED3DD8156 /* RoomScreenViewModelProtocol.swift in Sources */, diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index fe11790a5..f0afa967e 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -332,8 +332,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let userID = userSession.clientProxy.userID - let mediaPlayerProvider = MediaPlayerProvider() - let timelineItemFactory = RoomTimelineItemFactory(userID: userID, mediaProvider: userSession.mediaProvider, attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: appSettings.permalinkBaseURL, @@ -343,9 +341,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, - mediaProvider: userSession.mediaProvider, - mediaPlayerProvider: mediaPlayerProvider, - voiceMessageMediaManager: userSession.voiceMessageMediaManager, secureBackupController: userSession.clientProxy.secureBackupController) self.timelineController = timelineController @@ -356,7 +351,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy, timelineController: timelineController, mediaProvider: userSession.mediaProvider, - mediaPlayerProvider: mediaPlayerProvider, + mediaPlayerProvider: MediaPlayerProvider(), + voiceMessageMediaManager: userSession.voiceMessageMediaManager, emojiProvider: emojiProvider, completionSuggestionService: completionSuggestionService, appSettings: appSettings) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 173d064cf..9ae194fc4 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -24,6 +24,7 @@ struct RoomScreenCoordinatorParameters { let timelineController: RoomTimelineControllerProtocol let mediaProvider: MediaProviderProtocol let mediaPlayerProvider: MediaPlayerProviderProtocol + let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol let emojiProvider: EmojiProviderProtocol let completionSuggestionService: CompletionSuggestionServiceProtocol let appSettings: AppSettings @@ -59,14 +60,16 @@ final class RoomScreenCoordinator: CoordinatorProtocol { init(parameters: RoomScreenCoordinatorParameters) { self.parameters = parameters - viewModel = RoomScreenViewModel(timelineController: parameters.timelineController, + viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy, + timelineController: parameters.timelineController, mediaProvider: parameters.mediaProvider, mediaPlayerProvider: parameters.mediaPlayerProvider, - roomProxy: parameters.roomProxy, - appSettings: parameters.appSettings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: parameters.voiceMessageMediaManager, userIndicatorController: ServiceLocator.shared.userIndicatorController, - application: UIApplication.shared) + application: UIApplication.shared, + appSettings: parameters.appSettings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenter.default) wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight, maxCompressedHeight: ComposerConstant.maxHeight, diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift new file mode 100644 index 000000000..5f9bd3731 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift @@ -0,0 +1,652 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import UIKit + +enum RoomScreenInteractionHandlerAction { + case composer(action: RoomScreenComposerAction) + case displayError(RoomScreenErrorType) + case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set) + case displayReportContent(itemID: TimelineItemIdentifier, senderID: String) + case displayMessageForwarding(itemID: TimelineItemIdentifier) + case displayMediaUploadPreviewScreen(url: URL) + case displayRoomMemberDetails(member: RoomMemberProxyProtocol) + case showActionMenu(TimelineItemActionMenuInfo) + case showDebugInfo(TimelineItemDebugInfo) + case showConfirmationAlert(AlertInfo) +} + +@MainActor +class RoomScreenInteractionHandler { + private let roomProxy: RoomProxyProtocol + private let timelineController: RoomTimelineControllerProtocol + private let mediaProvider: MediaProviderProtocol + private let mediaPlayerProvider: MediaPlayerProviderProtocol + private let voiceMessageRecorder: VoiceMessageRecorderProtocol + private let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol + private let userIndicatorController: UserIndicatorControllerProtocol + private let application: ApplicationProtocol + private let appSettings: AppSettings + private let analyticsService: AnalyticsService + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + private var voiceMessageRecorderObserver: AnyCancellable? + private var canCurrentUserRedact = false + private var resumeVoiceMessagePlaybackAfterScrubbing = false + + init(roomProxy: RoomProxyProtocol, + timelineController: RoomTimelineControllerProtocol, + mediaProvider: MediaProviderProtocol, + mediaPlayerProvider: MediaPlayerProviderProtocol, + voiceMessageMediaManager: VoiceMessageMediaManagerProtocol, + voiceMessageRecorder: VoiceMessageRecorderProtocol, + userIndicatorController: UserIndicatorControllerProtocol, + application: ApplicationProtocol, + appSettings: AppSettings, + analyticsService: AnalyticsService) { + self.roomProxy = roomProxy + self.timelineController = timelineController + self.mediaProvider = mediaProvider + self.mediaPlayerProvider = mediaPlayerProvider + self.voiceMessageMediaManager = voiceMessageMediaManager + self.voiceMessageRecorder = voiceMessageRecorder + self.userIndicatorController = userIndicatorController + self.application = application + self.appSettings = appSettings + self.analyticsService = analyticsService + } + + // MARK: Timeline Item Action Menu + + func showTimelineItemActionMenu(for itemID: TimelineItemIdentifier) { + Task { + if case let .success(value) = await roomProxy.canUserRedact(userID: roomProxy.ownUserID) { + canCurrentUserRedact = value + } else { + canCurrentUserRedact = false + } + + 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)) + actionsSubject.send(.showActionMenu(.init(item: eventTimelineItem))) + } + } + + // swiftlint:disable:next cyclomatic_complexity + 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) + } + + 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 { + actionsSubject.send(.displayError(.alert(L10n.errorFailedCreatingThePermalink))) + break + } + + let permalink = try PermalinkBuilder.permalinkTo(eventIdentifier: eventID, roomIdentifier: timelineController.roomID, + baseURL: appSettings.permalinkBaseURL) + UIPasteboard.general.url = permalink + } catch { + actionsSubject.send(.displayError(.alert(L10n.errorFailedCreatingThePermalink))) + } + case .redact: + Task { + if eventTimelineItem.hasFailedToSend { + await timelineController.cancelSending(itemID: 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) + actionsSubject.send(.showDebugInfo(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))) + } + } + + // MARK: Polls + + func sendPollResponse(pollStartID: String, optionID: String) { + Task { + let sendPollResponseResult = await roomProxy.sendPollResponse(pollStartID: pollStartID, answers: [optionID]) + analyticsService.trackPollVote() + + switch sendPollResponseResult { + case .success: + break + case .failure: + actionsSubject.send(.displayError(.toast(L10n.errorUnknown))) + } + } + } + + func endPoll(pollStartID: String) { + Task { + let endPollResult = await roomProxy.endPoll(pollStartID: pollStartID, + text: "The poll with event id: \(pollStartID) has ended") + analyticsService.trackPollEnd() + switch endPollResult { + case .success: + break + case .failure: + actionsSubject.send(.displayError(.toast(L10n.errorUnknown))) + } + } + } + + // MARK: Pasting and dropping + + func handlePasteOrDrop(_ provider: NSItemProvider) { + guard let contentType = provider.preferredContentType, + let preferredExtension = contentType.preferredFilenameExtension else { + MXLog.error("Invalid NSItemProvider: \(provider)") + actionsSubject.send(.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.actionsSubject.send(.displayError(.toast(L10n.screenRoomErrorFailedProcessingMedia))) + MXLog.error("Failed processing NSItemProvider: \(providerDescription) with error: \(error)") + return + } + + guard let data else { + self.actionsSubject.send(.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.actionsSubject.send(.displayError(.toast(L10n.screenRoomErrorFailedProcessingMedia))) + MXLog.error("Failed storing NSItemProvider data \(providerDescription) with error: \(error)") + } + } + } + } + + // MARK: Voice messages + + 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.") + actionsSubject.send(.showConfirmationAlert(.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))) + } + } + } + + func startRecordingVoiceMessage() async { + voiceMessageRecorderObserver = voiceMessageRecorder.actions + .receive(on: DispatchQueue.main) + .sink { [weak self] action in + self?.handleVoiceMessageRecorderAction(action) + } + + await voiceMessageRecorder.startRecording() + } + + func stopRecordingVoiceMessage() async { + await voiceMessageRecorder.stopRecording() + } + + func cancelRecordingVoiceMessage() async { + await voiceMessageRecorder.cancelRecording() + voiceMessageRecorderObserver = nil + actionsSubject.send(.composer(action: .setMode(mode: .default))) + } + + func deleteCurrentVoiceMessage() async { + await voiceMessageRecorder.deleteRecording() + voiceMessageRecorderObserver = nil + actionsSubject.send(.composer(action: .setMode(mode: .default))) + } + + func sendCurrentVoiceMessage() async { + guard let audioPlayerState = voiceMessageRecorder.previewAudioPlayerState, let recordingURL = voiceMessageRecorder.recordingURL else { + actionsSubject.send(.displayError(.alert(L10n.errorFailedUploadingVoiceMessage))) + return + } + + analyticsService.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)))) + actionsSubject.send(.displayError(.alert(L10n.errorFailedUploadingVoiceMessage))) + } + } + + 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)") + } + } + + func pausePlayingRecordedVoiceMessage() { + voiceMessageRecorder.pausePlayback() + } + + func seekRecordedVoiceMessage(to progress: Double) async { + await mediaPlayerProvider.detachAllStates(except: voiceMessageRecorder.previewAudioPlayerState) + await voiceMessageRecorder.seekPlayback(to: progress) + } + + 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() + } + } + } + + // MARK: Audio Playback + + func playPauseAudio(for itemID: TimelineItemIdentifier) async { + MXLog.info("Toggle play/pause audio for itemID \(itemID)") + guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else { + fatalError("TimelineItem \(itemID) not found") + } + + guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else { + fatalError("Invalid TimelineItem type for itemID \(itemID) (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead") + } + + guard let source = voiceMessageRoomTimelineItem.content.source else { + MXLog.error("Cannot start voice message playback, source is not defined for itemID \(itemID)") + return + } + + guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: source), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else { + MXLog.error("Cannot play a voice message without an audio player") + return + } + + let audioPlayerState = audioPlayerState(for: itemID) + + // Ensure this one is attached + if !audioPlayerState.isAttached { + audioPlayerState.attachAudioPlayer(audioPlayer) + } + + // Detach all other states + await mediaPlayerProvider.detachAllStates(except: audioPlayerState) + + guard audioPlayer.mediaSource == source, audioPlayer.state != .error else { + // Load content + do { + MXLog.info("Loading voice message audio content from source for itemID \(itemID)") + let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(source, body: nil) + + // Make sure that the player is still attached, as it may have been detached while waiting for the voice message to be loaded. + if audioPlayerState.isAttached { + audioPlayer.load(mediaSource: source, using: url, autoplay: true) + } + } catch { + MXLog.error("Failed to load voice message: \(error)") + audioPlayerState.reportError(error) + } + + return + } + + if audioPlayer.state == .playing { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + + func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { + guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else { + return + } + await mediaPlayerProvider.detachAllStates(except: playerState) + await playerState.updateState(progress: progress) + } + + func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState { + guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else { + fatalError("TimelineItem \(itemID) not found") + } + + guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else { + fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead") + } + + if let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) { + return playerState + } + + let playerState = AudioPlayerState(id: .timelineItemIdentifier(itemID), + duration: voiceMessageRoomTimelineItem.content.duration, + waveform: voiceMessageRoomTimelineItem.content.waveform) + mediaPlayerProvider.register(audioPlayerState: playerState) + return playerState + } + + // MARK: Other + + 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)) + } + + 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): + actionsSubject.send(.displayError(.alert(L10n.screenRoomErrorFailedRetrievingUserDetails))) + MXLog.error("Failed retrieving the user given the following id \(userID) with error: \(error)") + } + } + + func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction { + guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else { + return .none + } + + switch timelineItem { + case let item as LocationRoomTimelineItem: + guard let geoURI = item.content.geoURI else { return .none } + return .displayLocation(body: item.content.body, geoURI: geoURI, description: item.content.description) + default: + return await displayMediaActionIfPossible(timelineItem: timelineItem) + } + } + + // MARK: - Private + + private func canRedactItem(_ item: EventBasedTimelineItemProtocol) -> Bool { + item.isOutgoing || (canCurrentUserRedact && !roomProxy.isDirect) + } + + 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 openSystemSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + application.open(url) + } + + private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> RoomTimelineControllerAction { + var source: MediaSourceProxy? + var body: String + + switch timelineItem { + case let item as ImageRoomTimelineItem: + source = item.content.source + body = item.content.body + case let item as VideoRoomTimelineItem: + source = item.content.source + body = item.content.body + case let item as FileRoomTimelineItem: + source = item.content.source + body = item.content.body + case let item as AudioRoomTimelineItem: + // For now we are just displaying audio messages with the File preview until we create a timeline player for them. + source = item.content.source + body = item.content.body + default: + return .none + } + + guard let source else { return .none } + switch await mediaProvider.loadFileFromSource(source, body: body) { + case .success(let file): + return .displayMediaFile(file: file, title: body) + case .failure: + return .none + } + } + + // MARK: User indicators + + 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) + } +} + +private struct ReplyInfo { + let type: EventBasedMessageTimelineItemContentType + let isThread: Bool +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 4edaf8fbc..3fe915525 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -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() + 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() + private let actionsSubject: PassthroughSubject = .init() - private var canCurrentUserRedact = false + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + private var paginateBackwardsTask: Task? - 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 { - 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() + + 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 { - 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 { diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift index 5a8d50657..deb7f3982 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift @@ -55,14 +55,16 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview { @ViewBuilder static var bodyPlain: some View { - let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name", avatarURL: URL.picturesDirectory)), + timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name", avatarURL: URL.picturesDirectory)), - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: ServiceLocator.shared.userIndicatorController, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) RoomHeaderView(context: viewModel.context) .previewLayout(.sizeThatFits) @@ -71,14 +73,16 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview { @ViewBuilder static var bodyEncrypted: some View { - let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name")), + timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name")), - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: ServiceLocator.shared.userIndicatorController, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) RoomHeaderView(context: viewModel.context) .previewLayout(.sizeThatFits) diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 4d1a9c82d..cc91f2489 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -187,14 +187,16 @@ struct RoomScreen: View { // MARK: - Previews struct RoomScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "Preview room", isCallOngoing: true)), + timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: RoomProxyMock(with: .init(displayName: "Preview room", isCallOngoing: true)), - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: ServiceLocator.shared.userIndicatorController, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) static var previews: some View { NavigationStack { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index e19d5ec09..eb5c5e413 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -103,10 +103,10 @@ struct TimelineItemBubbledStylerView: View { .timelineAccessibility(timelineItem) if !timelineItem.properties.reactions.isEmpty { - TimelineReactionsView(itemID: timelineItem.id, + TimelineReactionsView(context: context, + itemID: timelineItem.id, reactions: timelineItem.properties.reactions, - isLayoutRTL: timelineItem.isOutgoing, - collapsed: context.reactionsCollapsedBinding(for: timelineItem.id)) + isLayoutRTL: timelineItem.isOutgoing) // Workaround to stop the message long press stealing the touch from the reaction buttons .onTapGesture { } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index cb08f8ef3..3b90d0030 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -129,9 +129,9 @@ struct TimelineItemPlainStylerView: View { } if !timelineItem.properties.reactions.isEmpty { - TimelineReactionsView(itemID: timelineItem.id, - reactions: timelineItem.properties.reactions, - collapsed: context.reactionsCollapsedBinding(for: timelineItem.id)) + TimelineReactionsView(context: context, + itemID: timelineItem.id, + reactions: timelineItem.properties.reactions) // Workaround to stop the message long press stealing the touch from the reaction buttons .onTapGesture { } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift index ac7612faf..a7fe1bf75 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift @@ -16,17 +16,35 @@ import SwiftUI +@MainActor struct TimelineReactionsView: View { private static let horizontalSpacing: CGFloat = 4 private static let verticalSpacing: CGFloat = 4 private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) - @EnvironmentObject private var context: RoomScreenViewModel.Context @Environment(\.layoutDirection) private var layoutDirection: LayoutDirection + let context: RoomScreenViewModel.Context let itemID: TimelineItemIdentifier let reactions: [AggregatedReaction] - var isLayoutRTL = false - @Binding var collapsed: Bool + let isLayoutRTL: Bool + + private var collapsed: Binding + + init(context: RoomScreenViewModel.Context, + itemID: TimelineItemIdentifier, + reactions: [AggregatedReaction], + isLayoutRTL: Bool = false) { + self.context = context + self.itemID = itemID + self.reactions = reactions + self.isLayoutRTL = isLayoutRTL + + collapsed = Binding(get: { + context.reactionsCollapsed[itemID] ?? true + }, set: { + context.reactionsCollapsed[itemID] = $0 + }) + } var reactionsLayoutDirection: LayoutDirection { guard isLayoutRTL else { return layoutDirection } @@ -48,9 +66,9 @@ struct TimelineReactionsView: View { if isCollapsible { Button { - collapsed.toggle() + collapsed.wrappedValue.toggle() } label: { - TimelineCollapseButtonLabel(collapsed: collapsed) + TimelineCollapseButtonLabel(collapsed: collapsed.wrappedValue) .transaction { $0.animation = nil } } .reactionLayoutItem(.expandCollapse) @@ -79,7 +97,7 @@ struct TimelineReactionsView: View { if isCollapsible { return AnyLayout(CollapsibleReactionLayout(itemSpacing: 4, rowSpacing: 4, - collapsed: collapsed, + collapsed: collapsed.wrappedValue, rowsBeforeCollapsible: 2)) } @@ -188,21 +206,25 @@ struct TimelineReactionAddMoreButtonLabel: View { } struct TimelineReactionViewPreviewsContainer: View { - @State private var collapseState1 = false - @State private var collapseState2 = true - var body: some View { VStack { - TimelineReactionsView(itemID: .init(timelineID: "1"), + TimelineReactionsView(context: RoomScreenViewModel.mock.context, + itemID: .init(timelineID: "1"), reactions: [AggregatedReaction.mockReactionWithLongText, - AggregatedReaction.mockReactionWithLongTextRTL], - collapsed: .constant(true)) + AggregatedReaction.mockReactionWithLongTextRTL]) Divider() - TimelineReactionsView(itemID: .init(timelineID: "2"), reactions: Array(AggregatedReaction.mockReactions.prefix(3)), collapsed: .constant(true)) + TimelineReactionsView(context: RoomScreenViewModel.mock.context, + itemID: .init(timelineID: "2"), + reactions: Array(AggregatedReaction.mockReactions.prefix(3))) Divider() - TimelineReactionsView(itemID: .init(timelineID: "3"), reactions: AggregatedReaction.mockReactions, collapsed: $collapseState1) + TimelineReactionsView(context: RoomScreenViewModel.mock.context, + itemID: .init(timelineID: "3"), + reactions: AggregatedReaction.mockReactions) Divider() - TimelineReactionsView(itemID: .init(timelineID: "4"), reactions: AggregatedReaction.mockReactions, isLayoutRTL: true, collapsed: $collapseState2) + TimelineReactionsView(context: RoomScreenViewModel.mock.context, + itemID: .init(timelineID: "4"), + reactions: AggregatedReaction.mockReactions, + isLayoutRTL: true) } .background(Color.red) .frame(maxWidth: 250, alignment: .leading) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift index faa8940c7..009afeeff 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift @@ -58,15 +58,16 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview { .mockMe ] - static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "Test", members: members)), + timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: RoomProxyMock(with: .init(displayName: "Test", - members: members)), - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: ServiceLocator.shared.userIndicatorController, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) static let singleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now")] static let doubleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"), diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift index 13d452cdd..5e43384f0 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift @@ -80,14 +80,16 @@ struct UITimelineView: UIViewControllerRepresentable { // MARK: - Previews struct UITimelineView_Previews: PreviewProvider, TestablePreview { - static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + static let viewModel = 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) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) static var previews: some View { NavigationStack { diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index e005afc93..67488d49e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -168,15 +168,17 @@ struct TimelineView: View { // MARK: - Previews struct TimelineView_Previews: PreviewProvider, TestablePreview { - static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + static let viewModel = 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) - + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) + static var previews: some View { NavigationStack { RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock()) diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index dccc53395..a0c17739c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -63,8 +63,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func processItemAppearance(_ itemID: TimelineItemIdentifier) async { } func processItemDisappearance(_ itemID: TimelineItemIdentifier) async { } - - func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction { .none } func sendMessage(_ message: String, html: String?, @@ -79,8 +77,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { intentionalMentions: IntentionalMentions) async { } func redact(_ itemID: TimelineItemIdentifier) async { } - - func cancelSend(_ itemID: TimelineItemIdentifier) async { } func debugInfo(for itemID: TimelineItemIdentifier) -> TimelineItemDebugInfo { .init(model: "Mock debug description", originalJSON: nil, latestEditJSON: nil) @@ -88,18 +84,21 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func retryDecryption(for sessionID: String) async { } - func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState { - AudioPlayerState(id: .timelineItemIdentifier(itemID), - duration: 10.0, - waveform: nil, - progress: 0.0) + func retrySending(itemID: TimelineItemIdentifier) async { + guard let transactionID = itemID.transactionID else { + return + } + + await roomProxy?.retrySend(transactionID: transactionID) } - func playPauseAudio(for itemID: TimelineItemIdentifier) async { } - - func pauseAudio() { } - - func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { } + func cancelSending(itemID: TimelineItemIdentifier) async { + guard let transactionID = itemID.transactionID else { + return + } + + await roomProxy?.cancelSend(transactionID: transactionID) + } // MARK: - UI Test signalling diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift index 968fde5b0..6b05cbfee 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineControllerFactory.swift @@ -17,12 +17,8 @@ import Foundation struct MockRoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { - // swiftlint:disable:next function_parameter_count func buildRoomTimelineController(roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, - mediaProvider: MediaProviderProtocol, - mediaPlayerProvider: MediaPlayerProviderProtocol, - voiceMessageMediaManager: VoiceMessageMediaManagerProtocol, secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol { let timelineController = MockRoomTimelineController() timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index a7e978a7f..b71c0e71c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -22,9 +22,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol { private let roomProxy: RoomProxyProtocol private let timelineProvider: RoomTimelineProviderProtocol private let timelineItemFactory: RoomTimelineItemFactoryProtocol - private let mediaProvider: MediaProviderProtocol - private let mediaPlayerProvider: MediaPlayerProviderProtocol - private let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol private let appSettings: AppSettings private let secureBackupController: SecureBackupControllerProtocol private let serialDispatchQueue: DispatchQueue @@ -46,17 +43,11 @@ class RoomTimelineController: RoomTimelineControllerProtocol { init(roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, - mediaProvider: MediaProviderProtocol, - mediaPlayerProvider: MediaPlayerProviderProtocol, - voiceMessageMediaManager: VoiceMessageMediaManagerProtocol, appSettings: AppSettings, secureBackupController: SecureBackupControllerProtocol) { self.roomProxy = roomProxy timelineProvider = roomProxy.timelineProvider self.timelineItemFactory = timelineItemFactory - self.mediaProvider = mediaProvider - self.mediaPlayerProvider = mediaPlayerProvider - self.voiceMessageMediaManager = voiceMessageMediaManager self.appSettings = appSettings self.secureBackupController = secureBackupController serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility) @@ -117,20 +108,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } func processItemDisappearance(_ itemID: TimelineItemIdentifier) { } - - func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction { - guard let timelineItem = timelineItems.firstUsingStableID(itemID) else { - return .none - } - - switch timelineItem { - case let item as LocationRoomTimelineItem: - guard let geoURI = item.content.geoURI else { return .none } - return .displayLocation(body: item.content.body, geoURI: geoURI, description: item.content.description) - default: - return await displayMediaActionIfPossible(timelineItem: timelineItem) - } - } func sendMessage(_ message: String, html: String?, @@ -182,7 +159,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { let item = timelineItem as? EventBasedTimelineItemProtocol, item.hasFailedToSend { MXLog.info("Editing a failed echo, will cancel and resend it as a new message") - await cancelSend(itemID) + await cancelSending(itemID: itemID) await sendMessage(newMessage, html: html, intentionalMentions: intentionalMentions) } else if let eventID = itemID.eventID { switch await roomProxy.editMessage(newMessage, @@ -211,15 +188,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol { MXLog.error("Failed redacting message with error: \(error)") } } - - func cancelSend(_ itemID: TimelineItemIdentifier) async { - guard let transactionID = itemID.transactionID else { - MXLog.error("Failed cancelling send, missing transaction ID") - return - } - MXLog.info("Cancelling send in \(roomID)") - await roomProxy.cancelSend(transactionID: transactionID) - } // Handle this parallel to the timeline items so we're not forced // to bundle the Rust side objects within them @@ -242,87 +210,24 @@ class RoomTimelineController: RoomTimelineControllerProtocol { await roomProxy.retryDecryption(for: sessionID) } - func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState { - guard let timelineItem = timelineItems.firstUsingStableID(itemID) else { - fatalError("TimelineItem \(itemID) not found") + func retrySending(itemID: TimelineItemIdentifier) async { + guard let transactionID = itemID.transactionID else { + MXLog.error("Failed Retry Send: missing transaction ID") + return } - guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else { - fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead") - } - - if let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) { - return playerState - } - - let playerState = AudioPlayerState(id: .timelineItemIdentifier(itemID), - duration: voiceMessageRoomTimelineItem.content.duration, - waveform: voiceMessageRoomTimelineItem.content.waveform) - mediaPlayerProvider.register(audioPlayerState: playerState) - return playerState + MXLog.info("Retry sending in \(roomID)") + await roomProxy.retrySend(transactionID: transactionID) } - func playPauseAudio(for itemID: TimelineItemIdentifier) async { - MXLog.info("Toggle play/pause audio for itemID \(itemID)") - guard let timelineItem = timelineItems.firstUsingStableID(itemID) else { - fatalError("TimelineItem \(itemID) not found") - } - - guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else { - fatalError("Invalid TimelineItem type for itemID \(itemID) (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead") - } - - guard let source = voiceMessageRoomTimelineItem.content.source else { - MXLog.error("Cannot start voice message playback, source is not defined for itemID \(itemID)") + func cancelSending(itemID: TimelineItemIdentifier) async { + guard let transactionID = itemID.transactionID else { + MXLog.error("Failed Cancel Send: missing transaction ID") return } - guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: source), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else { - MXLog.error("Cannot play a voice message without an audio player") - return - } - - let audioPlayerState = audioPlayerState(for: itemID) - - // Ensure this one is attached - if !audioPlayerState.isAttached { - audioPlayerState.attachAudioPlayer(audioPlayer) - } - - // Detach all other states - await mediaPlayerProvider.detachAllStates(except: audioPlayerState) - - guard audioPlayer.mediaSource == source, audioPlayer.state != .error else { - // Load content - do { - MXLog.info("Loading voice message audio content from source for itemID \(itemID)") - let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(source, body: nil) - - // Make sure that the player is still attached, as it may have been detached while waiting for the voice message to be loaded. - if audioPlayerState.isAttached { - audioPlayer.load(mediaSource: source, using: url, autoplay: true) - } - } catch { - MXLog.error("Failed to load voice message: \(error)") - audioPlayerState.reportError(error) - } - - return - } - - if audioPlayer.state == .playing { - audioPlayer.pause() - } else { - audioPlayer.play() - } - } - - func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { - guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else { - return - } - await mediaPlayerProvider.detachAllStates(except: playerState) - await playerState.updateState(progress: progress) + MXLog.info("Cancelling send in \(roomID)") + await roomProxy.cancelSend(transactionID: transactionID) } // MARK: - Private @@ -331,37 +236,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol { // Recompute all attributed strings on content size changes -> DynamicType support updateTimelineItems() } - - private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> RoomTimelineControllerAction { - var source: MediaSourceProxy? - var body: String - - switch timelineItem { - case let item as ImageRoomTimelineItem: - source = item.content.source - body = item.content.body - case let item as VideoRoomTimelineItem: - source = item.content.source - body = item.content.body - case let item as FileRoomTimelineItem: - source = item.content.source - body = item.content.body - case let item as AudioRoomTimelineItem: - // For now we are just displaying audio messages with the File preview until we create a timeline player for them. - source = item.content.source - body = item.content.body - default: - return .none - } - - guard let source else { return .none } - switch await mediaProvider.loadFileFromSource(source, body: body) { - case .success(let file): - return .displayMediaFile(file: file, title: body) - case .failure: - return .none - } - } private func updateTimelineItems() { var newTimelineItems = [RoomTimelineItemProtocol]() @@ -404,17 +278,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol { lastEncryptedHistoryItemIndex = newTimelineItems.endIndex } - // Stops the audio player when a voice message is redacted. - if timelineItem is RedactedRoomTimelineItem { - guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else { - continue - } - Task { @MainActor in - playerState.detachAudioPlayer() - mediaPlayerProvider.unregister(audioPlayerState: playerState) - } - } - newTimelineItems.append(timelineItem) } else { newTimelineItems.append(CollapsibleTimelineItem(items: items)) diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift index 0d36b5088..87aa4e848 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift @@ -17,18 +17,11 @@ import Foundation struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { - // swiftlint:disable:next function_parameter_count func buildRoomTimelineController(roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, - mediaProvider: MediaProviderProtocol, - mediaPlayerProvider: MediaPlayerProviderProtocol, - voiceMessageMediaManager: VoiceMessageMediaManagerProtocol, secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol { RoomTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, - mediaProvider: mediaProvider, - mediaPlayerProvider: mediaPlayerProvider, - voiceMessageMediaManager: voiceMessageMediaManager, appSettings: ServiceLocator.shared.settings, secureBackupController: secureBackupController) } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift index cb05f7f76..60fe5be1c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactoryProtocol.swift @@ -18,11 +18,7 @@ import Foundation @MainActor protocol RoomTimelineControllerFactoryProtocol { - // swiftlint:disable:next function_parameter_count func buildRoomTimelineController(roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, - mediaProvider: MediaProviderProtocol, - mediaPlayerProvider: MediaPlayerProviderProtocol, - voiceMessageMediaManager: VoiceMessageMediaManagerProtocol, secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index 27f2cf2f7..061f1957d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -44,8 +44,6 @@ protocol RoomTimelineControllerProtocol { func processItemAppearance(_ itemID: TimelineItemIdentifier) async func processItemDisappearance(_ itemID: TimelineItemIdentifier) async - - func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result @@ -64,18 +62,14 @@ protocol RoomTimelineControllerProtocol { func toggleReaction(_ reaction: String, to itemID: TimelineItemIdentifier) async func redact(_ itemID: TimelineItemIdentifier) async - - func cancelSend(_ itemID: TimelineItemIdentifier) async func debugInfo(for itemID: TimelineItemIdentifier) -> TimelineItemDebugInfo func retryDecryption(for sessionID: String) async - func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState + func retrySending(itemID: TimelineItemIdentifier) async - func playPauseAudio(for itemID: TimelineItemIdentifier) async - - func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async + func cancelSending(itemID: TimelineItemIdentifier) async } extension RoomTimelineControllerProtocol { diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 22dd3f40e..319db0e62 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -280,6 +280,7 @@ class MockScreen: Identifiable { timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -292,6 +293,7 @@ class MockScreen: Identifiable { timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -306,6 +308,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -320,6 +323,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -335,6 +339,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -352,6 +357,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -369,6 +375,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -386,6 +393,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -404,6 +412,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -421,6 +430,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -438,6 +448,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -455,6 +466,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -472,6 +484,7 @@ class MockScreen: Identifiable { timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) diff --git a/UnitTests/Sources/PillContextTests.swift b/UnitTests/Sources/PillContextTests.swift index a9618c709..109756e40 100644 --- a/UnitTests/Sources/PillContextTests.swift +++ b/UnitTests/Sources/PillContextTests.swift @@ -26,14 +26,16 @@ class PillContextTests: XCTestCase { let proxyMock = RoomProxyMock(with: .init(displayName: "Test")) let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([]) proxyMock.members = subject.asCurrentValuePublisher() - let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + let mock = RoomScreenViewModel(roomProxy: proxyMock, + timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: proxyMock, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: ServiceLocator.shared.userIndicatorController, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body))) XCTAssertFalse(context.viewState.isOwnMention) @@ -53,14 +55,16 @@ class PillContextTests: XCTestCase { let proxyMock = RoomProxyMock(with: .init(displayName: "Test", ownUserID: id)) let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([]) proxyMock.members = subject.asCurrentValuePublisher() - let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + let mock = RoomScreenViewModel(roomProxy: proxyMock, + timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: proxyMock, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: ServiceLocator.shared.userIndicatorController, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body))) XCTAssertTrue(context.viewState.isOwnMention) @@ -73,14 +77,16 @@ class PillContextTests: XCTestCase { let proxyMock = RoomProxyMock(with: .init(id: id, displayName: displayName, avatarURL: avatarURL)) let mockController = MockRoomTimelineController() mockController.roomProxy = proxyMock - let mock = RoomScreenViewModel(timelineController: mockController, + let mock = RoomScreenViewModel(roomProxy: proxyMock, + timelineController: mockController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: proxyMock, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: ServiceLocator.shared.userIndicatorController, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .allUsers, font: .preferredFont(forTextStyle: .body))) XCTAssertTrue(context.viewState.isOwnMention) diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 89b97dc1c..596e57dac 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -49,14 +49,16 @@ class RoomScreenViewModelTests: XCTestCase { // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = items - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")), + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: RoomProxyMock(with: .init(displayName: "")), - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) // Then the messages should be grouped together. XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") @@ -84,14 +86,16 @@ class RoomScreenViewModelTests: XCTestCase { // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = items - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")), + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: RoomProxyMock(with: .init(displayName: "")), - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) // Then the messages should be grouped by sender. XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "A message should not be grouped when the sender changes.") @@ -117,14 +121,16 @@ class RoomScreenViewModelTests: XCTestCase { // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = items - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")), + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: RoomProxyMock(with: .init(displayName: "")), - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) // Then the first message should not be grouped but the other two should. XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "When the first message has reactions it should not be grouped.") @@ -147,14 +153,16 @@ class RoomScreenViewModelTests: XCTestCase { // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = items - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")), + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: RoomProxyMock(with: .init(displayName: "")), - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) // Then the first and second messages should be grouped and the last one should not. XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") @@ -177,14 +185,16 @@ class RoomScreenViewModelTests: XCTestCase { // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = items - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")), + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: RoomProxyMock(with: .init(displayName: "")), - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) // Then the messages should be grouped together. XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") @@ -203,14 +213,16 @@ class RoomScreenViewModelTests: XCTestCase { roomMemberMock.userID = "bob" roomProxyMock.getMemberUserIDReturnValue = .success(roomMemberMock) - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: roomProxyMock, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) viewModel.actions .sink { action in switch action { @@ -243,14 +255,16 @@ class RoomScreenViewModelTests: XCTestCase { .success(roomMemberMock) } - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: roomProxyMock, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) viewModel.actions .sink { action in @@ -284,14 +298,16 @@ class RoomScreenViewModelTests: XCTestCase { .failure(.failedRetrievingMember) } - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: roomProxyMock, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) viewModel.actions .sink { _ in XCTFail("Should not receive any action") @@ -316,15 +332,18 @@ class RoomScreenViewModelTests: XCTestCase { func testRetrySend() async throws { let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) + timelineController.roomProxy = roomProxyMock - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: roomProxyMock, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) viewModel.context.send(viewAction: .retrySend(itemID: .init(timelineID: UUID().uuidString, transactionID: "test retry send id"))) @@ -338,14 +357,16 @@ class RoomScreenViewModelTests: XCTestCase { let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: roomProxyMock, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) viewModel.context.send(viewAction: .retrySend(itemID: .random)) @@ -357,15 +378,18 @@ class RoomScreenViewModelTests: XCTestCase { func testCancelSend() async { let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) + timelineController.roomProxy = roomProxyMock - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: roomProxyMock, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) viewModel.context.send(viewAction: .cancelSend(itemID: .init(timelineID: UUID().uuidString, transactionID: "test cancel send id"))) @@ -379,14 +403,16 @@ class RoomScreenViewModelTests: XCTestCase { let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: roomProxyMock, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, - application: ApplicationMock.default) + application: ApplicationMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: NotificationCenterMock()) viewModel.context.send(viewAction: .cancelSend(itemID: .random)) @@ -517,15 +543,16 @@ class RoomScreenViewModelTests: XCTestCase { timelineController.timelineItems = items timelineController.roomProxy = roomProxy - let viewModel = RoomScreenViewModel(timelineController: timelineController, + let viewModel = RoomScreenViewModel(roomProxy: roomProxy, + timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), - roomProxy: roomProxy, - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, - notificationCenterProtocol: notificationCenter) + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + notificationCenter: notificationCenter) return (viewModel, roomProxy, timelineController, notificationCenter) } diff --git a/UnitTests/Sources/TracingConfigurationTests.swift b/UnitTests/Sources/TracingConfigurationTests.swift index 43c6a81e5..937b1ecb8 100644 --- a/UnitTests/Sources/TracingConfigurationTests.swift +++ b/UnitTests/Sources/TracingConfigurationTests.swift @@ -25,8 +25,8 @@ class TracingConfigurationTests: XCTestCase { let filterComponents = configuration.filter.components(separatedBy: ",") XCTAssertEqual(filterComponents.first, "info") XCTAssertTrue(filterComponents.contains("matrix_sdk_base::sliding_sync=trace")) - XCTAssertTrue(filterComponents.contains("matrix_sdk::http_client=trace")) - XCTAssertTrue(filterComponents.contains("matrix_sdk_crypto=trace")) + XCTAssertTrue(filterComponents.contains("matrix_sdk::http_client=debug")) + XCTAssertTrue(filterComponents.contains("matrix_sdk_crypto=debug")) XCTAssertTrue(filterComponents.contains("hyper=warn")) } } diff --git a/UnitTests/__Snapshots__/PreviewTests/test_timelineReactionView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_timelineReactionView.1.png index 45e29fce3..eda87c872 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_timelineReactionView.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_timelineReactionView.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1cb552e4238b94f765b9e27242b64daa0517a40f79bd3ef642b3c3a167e47725 -size 281715 +oid sha256:fa55c2da53a661e2218356fb8a7165da0bdc5d8ca09b36c074814070779f8cba +size 198157