mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
Extract room screen action handling into separate component (#2045)
* Extract room screen action handling into separate component * Fix tracing configuration unit tests * Move the custom reactionsCollapsedBinding to the TimelineReactionsView * Move voice message playback handling from the timelineController to the room view model * Reorder methods * Remove the need for a media player provider in the timeline controller * Move room attachments opening to the view model, remove the need for a media provider in the timeline controller * Rename RoomScreenActionsHandler -> RoomScreenInteractionHandler * Move message sending retry and cancellation to the timeline controller. * Move audio playback, attachment loading and user tapping handling into the InteractionHandler * Fix unit tests * Switch back swiftlint file_length rule error to 1000 lines
This commit is contained in:
parent
07313926c9
commit
abe2080ee2
@ -25,7 +25,7 @@ line_length:
|
|||||||
|
|
||||||
file_length:
|
file_length:
|
||||||
warning: 1000
|
warning: 1000
|
||||||
error: 1200
|
error: 1000
|
||||||
|
|
||||||
type_name:
|
type_name:
|
||||||
min_length: 3
|
min_length: 3
|
||||||
|
@ -263,6 +263,7 @@
|
|||||||
4714991754A08B58B4D7ED85 /* OnboardingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F27BAB69EB568369F1F6B3 /* OnboardingScreenViewModelProtocol.swift */; };
|
4714991754A08B58B4D7ED85 /* OnboardingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F27BAB69EB568369F1F6B3 /* OnboardingScreenViewModelProtocol.swift */; };
|
||||||
47305C0911C9E1AA774A4000 /* TemplateScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA90BD288E5AE6BC643AFDDF /* TemplateScreenCoordinator.swift */; };
|
47305C0911C9E1AA774A4000 /* TemplateScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA90BD288E5AE6BC643AFDDF /* TemplateScreenCoordinator.swift */; };
|
||||||
4799A852132F1744E2825994 /* CreateRoomViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340179A0FC1AD4AEDA7FC134 /* CreateRoomViewModelProtocol.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 */; };
|
4807E8F51DB54F56B25E1C7E /* AppLockSetupSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8C38663020DF2EB2D13F5E /* AppLockSetupSettingsScreenViewModel.swift */; };
|
||||||
484202C5D50983442D24D061 /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; };
|
484202C5D50983442D24D061 /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; };
|
||||||
491D62ACD19E6F134B1766AF /* RoomNotificationSettingsUserDefinedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3203C6566DC17B7AECC1B7FD /* RoomNotificationSettingsUserDefinedScreen.swift */; };
|
491D62ACD19E6F134B1766AF /* RoomNotificationSettingsUserDefinedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3203C6566DC17B7AECC1B7FD /* RoomNotificationSettingsUserDefinedScreen.swift */; };
|
||||||
@ -1029,6 +1030,7 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreen.swift; sourceTree = "<group>"; };
|
00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreen.swift; sourceTree = "<group>"; };
|
||||||
00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelTests.swift; sourceTree = "<group>"; };
|
00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
|
0135A608FFAD86E6674EE730 /* RoomScreenInteractionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenInteractionHandler.swift; sourceTree = "<group>"; };
|
||||||
01C4C7DB37597D7D8379511A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
01C4C7DB37597D7D8379511A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
022E6BD64CB4610B9C95FC02 /* UserDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenViewModel.swift; sourceTree = "<group>"; };
|
022E6BD64CB4610B9C95FC02 /* UserDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenViewModel.swift; sourceTree = "<group>"; };
|
||||||
024F7398C5FC12586FB10E9D /* EffectsScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EffectsScene.swift; sourceTree = "<group>"; };
|
024F7398C5FC12586FB10E9D /* EffectsScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EffectsScene.swift; sourceTree = "<group>"; };
|
||||||
@ -3066,6 +3068,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */,
|
B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */,
|
||||||
|
0135A608FFAD86E6674EE730 /* RoomScreenInteractionHandler.swift */,
|
||||||
C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */,
|
C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */,
|
||||||
9CE3C90E487B255B735D73C8 /* RoomScreenViewModel.swift */,
|
9CE3C90E487B255B735D73C8 /* RoomScreenViewModel.swift */,
|
||||||
A00C7A331B72C0F05C00392F /* RoomScreenViewModelProtocol.swift */,
|
A00C7A331B72C0F05C00392F /* RoomScreenViewModelProtocol.swift */,
|
||||||
@ -5706,6 +5709,7 @@
|
|||||||
FA9C427FFB11B1AA2DCC5602 /* RoomProxyProtocol.swift in Sources */,
|
FA9C427FFB11B1AA2DCC5602 /* RoomProxyProtocol.swift in Sources */,
|
||||||
C55A44C99F64A479ABA85B46 /* RoomScreen.swift in Sources */,
|
C55A44C99F64A479ABA85B46 /* RoomScreen.swift in Sources */,
|
||||||
A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */,
|
A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */,
|
||||||
|
47FF70C051A991FB65CDBCF3 /* RoomScreenInteractionHandler.swift in Sources */,
|
||||||
352C439BE0F75E101EF11FB1 /* RoomScreenModels.swift in Sources */,
|
352C439BE0F75E101EF11FB1 /* RoomScreenModels.swift in Sources */,
|
||||||
7BB31E67648CF32D2AB5E502 /* RoomScreenViewModel.swift in Sources */,
|
7BB31E67648CF32D2AB5E502 /* RoomScreenViewModel.swift in Sources */,
|
||||||
617624A97BDBB75ED3DD8156 /* RoomScreenViewModelProtocol.swift in Sources */,
|
617624A97BDBB75ED3DD8156 /* RoomScreenViewModelProtocol.swift in Sources */,
|
||||||
|
@ -332,8 +332,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
|
|
||||||
let userID = userSession.clientProxy.userID
|
let userID = userSession.clientProxy.userID
|
||||||
|
|
||||||
let mediaPlayerProvider = MediaPlayerProvider()
|
|
||||||
|
|
||||||
let timelineItemFactory = RoomTimelineItemFactory(userID: userID,
|
let timelineItemFactory = RoomTimelineItemFactory(userID: userID,
|
||||||
mediaProvider: userSession.mediaProvider,
|
mediaProvider: userSession.mediaProvider,
|
||||||
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: appSettings.permalinkBaseURL,
|
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: appSettings.permalinkBaseURL,
|
||||||
@ -343,9 +341,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
|
|
||||||
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
|
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
|
||||||
timelineItemFactory: timelineItemFactory,
|
timelineItemFactory: timelineItemFactory,
|
||||||
mediaProvider: userSession.mediaProvider,
|
|
||||||
mediaPlayerProvider: mediaPlayerProvider,
|
|
||||||
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
|
|
||||||
secureBackupController: userSession.clientProxy.secureBackupController)
|
secureBackupController: userSession.clientProxy.secureBackupController)
|
||||||
self.timelineController = timelineController
|
self.timelineController = timelineController
|
||||||
|
|
||||||
@ -356,7 +351,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
|
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
|
||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: userSession.mediaProvider,
|
mediaProvider: userSession.mediaProvider,
|
||||||
mediaPlayerProvider: mediaPlayerProvider,
|
mediaPlayerProvider: MediaPlayerProvider(),
|
||||||
|
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
|
||||||
emojiProvider: emojiProvider,
|
emojiProvider: emojiProvider,
|
||||||
completionSuggestionService: completionSuggestionService,
|
completionSuggestionService: completionSuggestionService,
|
||||||
appSettings: appSettings)
|
appSettings: appSettings)
|
||||||
|
@ -24,6 +24,7 @@ struct RoomScreenCoordinatorParameters {
|
|||||||
let timelineController: RoomTimelineControllerProtocol
|
let timelineController: RoomTimelineControllerProtocol
|
||||||
let mediaProvider: MediaProviderProtocol
|
let mediaProvider: MediaProviderProtocol
|
||||||
let mediaPlayerProvider: MediaPlayerProviderProtocol
|
let mediaPlayerProvider: MediaPlayerProviderProtocol
|
||||||
|
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
|
||||||
let emojiProvider: EmojiProviderProtocol
|
let emojiProvider: EmojiProviderProtocol
|
||||||
let completionSuggestionService: CompletionSuggestionServiceProtocol
|
let completionSuggestionService: CompletionSuggestionServiceProtocol
|
||||||
let appSettings: AppSettings
|
let appSettings: AppSettings
|
||||||
@ -59,14 +60,16 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
|||||||
init(parameters: RoomScreenCoordinatorParameters) {
|
init(parameters: RoomScreenCoordinatorParameters) {
|
||||||
self.parameters = parameters
|
self.parameters = parameters
|
||||||
|
|
||||||
viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
|
viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
|
||||||
|
timelineController: parameters.timelineController,
|
||||||
mediaProvider: parameters.mediaProvider,
|
mediaProvider: parameters.mediaProvider,
|
||||||
mediaPlayerProvider: parameters.mediaPlayerProvider,
|
mediaPlayerProvider: parameters.mediaPlayerProvider,
|
||||||
roomProxy: parameters.roomProxy,
|
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
|
||||||
appSettings: parameters.appSettings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
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,
|
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
|
||||||
maxCompressedHeight: ComposerConstant.maxHeight,
|
maxCompressedHeight: ComposerConstant.maxHeight,
|
||||||
|
@ -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<String>)
|
||||||
|
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<UUID>)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<RoomScreenInteractionHandlerAction, Never> = .init()
|
||||||
|
var actions: AnyPublisher<RoomScreenInteractionHandlerAction, Never> {
|
||||||
|
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
|
||||||
|
}
|
@ -28,40 +28,57 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
static let toastErrorID = "RoomScreenToastError"
|
static let toastErrorID = "RoomScreenToastError"
|
||||||
}
|
}
|
||||||
|
|
||||||
private let timelineController: RoomTimelineControllerProtocol
|
|
||||||
private let roomProxy: RoomProxyProtocol
|
private let roomProxy: RoomProxyProtocol
|
||||||
private let appSettings: AppSettings
|
private let timelineController: RoomTimelineControllerProtocol
|
||||||
private let analytics: AnalyticsService
|
|
||||||
private let application: ApplicationProtocol
|
|
||||||
private unowned let userIndicatorController: UserIndicatorControllerProtocol
|
|
||||||
private let notificationCenterProtocol: NotificationCenterProtocol
|
|
||||||
private let voiceMessageRecorder: VoiceMessageRecorderProtocol
|
|
||||||
private let composerFocusedSubject = PassthroughSubject<Bool, Never>()
|
|
||||||
private let mediaPlayerProvider: MediaPlayerProviderProtocol
|
private let mediaPlayerProvider: MediaPlayerProviderProtocol
|
||||||
|
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||||
|
private let application: ApplicationProtocol
|
||||||
|
private let appSettings: AppSettings
|
||||||
|
private let analyticsService: AnalyticsService
|
||||||
|
private let notificationCenter: NotificationCenterProtocol
|
||||||
|
|
||||||
|
private let roomScreenInteractionHandler: RoomScreenInteractionHandler
|
||||||
|
|
||||||
|
private let composerFocusedSubject = PassthroughSubject<Bool, Never>()
|
||||||
|
|
||||||
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
|
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
|
||||||
private var canCurrentUserRedact = false
|
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
|
||||||
|
actionsSubject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
private var paginateBackwardsTask: Task<Void, Never>?
|
private var paginateBackwardsTask: Task<Void, Never>?
|
||||||
private var resumeVoiceMessagePlaybackAfterScrubbing = false
|
|
||||||
private var voiceMessageRecorderObserver: AnyCancellable?
|
|
||||||
|
|
||||||
init(timelineController: RoomTimelineControllerProtocol,
|
init(roomProxy: RoomProxyProtocol,
|
||||||
|
timelineController: RoomTimelineControllerProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
mediaProvider: MediaProviderProtocol,
|
||||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||||
roomProxy: RoomProxyProtocol,
|
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
|
||||||
appSettings: AppSettings,
|
|
||||||
analytics: AnalyticsService,
|
|
||||||
userIndicatorController: UserIndicatorControllerProtocol,
|
userIndicatorController: UserIndicatorControllerProtocol,
|
||||||
application: ApplicationProtocol,
|
application: ApplicationProtocol,
|
||||||
notificationCenterProtocol: NotificationCenterProtocol = NotificationCenter.default) {
|
appSettings: AppSettings,
|
||||||
self.roomProxy = roomProxy
|
analyticsService: AnalyticsService,
|
||||||
|
notificationCenter: NotificationCenterProtocol) {
|
||||||
self.timelineController = timelineController
|
self.timelineController = timelineController
|
||||||
self.appSettings = appSettings
|
|
||||||
self.analytics = analytics
|
|
||||||
self.userIndicatorController = userIndicatorController
|
|
||||||
self.notificationCenterProtocol = notificationCenterProtocol
|
|
||||||
self.mediaPlayerProvider = mediaPlayerProvider
|
self.mediaPlayerProvider = mediaPlayerProvider
|
||||||
|
self.roomProxy = roomProxy
|
||||||
|
self.appSettings = appSettings
|
||||||
|
self.analyticsService = analyticsService
|
||||||
|
self.userIndicatorController = userIndicatorController
|
||||||
self.application = application
|
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,
|
super.init(initialViewState: RoomScreenViewState(roomID: timelineController.roomID,
|
||||||
roomTitle: roomProxy.roomTitle,
|
roomTitle: roomProxy.roomTitle,
|
||||||
@ -76,13 +93,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
|
|
||||||
setupSubscriptions()
|
setupSubscriptions()
|
||||||
setupDirectRoomSubscriptionsIfNeeded()
|
setupDirectRoomSubscriptionsIfNeeded()
|
||||||
|
|
||||||
state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in
|
state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.timelineItemMenuActionsForItemId(itemId)
|
return self.roomScreenInteractionHandler.timelineItemMenuActionsForItemId(itemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.audioPlayerStateProvider = { [weak self] itemId -> AudioPlayerState? in
|
state.audioPlayerStateProvider = { [weak self] itemId -> AudioPlayerState? in
|
||||||
@ -90,7 +107,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.audioPlayerState(for: itemId)
|
return self.roomScreenInteractionHandler.audioPlayerState(for: itemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTimelineViews()
|
buildTimelineViews()
|
||||||
@ -101,10 +118,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
|
|
||||||
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
|
|
||||||
actionsSubject.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
|
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
|
||||||
@ -120,34 +133,27 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
case .itemDisappeared(let id):
|
case .itemDisappeared(let id):
|
||||||
Task { await timelineController.processItemDisappearance(id) }
|
Task { await timelineController.processItemDisappearance(id) }
|
||||||
case .itemTapped(let id):
|
case .itemTapped(let id):
|
||||||
Task { await itemTapped(with: id) }
|
Task { await handleItemTapped(with: id) }
|
||||||
case .toggleReaction(let emoji, let itemId):
|
case .toggleReaction(let emoji, let itemId):
|
||||||
Task { await timelineController.toggleReaction(emoji, to: itemId) }
|
Task { await timelineController.toggleReaction(emoji, to: itemId) }
|
||||||
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
|
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
|
||||||
Task { await sendReadReceiptIfNeeded(for: lastVisibleItemID) }
|
Task { await sendReadReceiptIfNeeded(for: lastVisibleItemID) }
|
||||||
case .timelineItemMenu(let itemID):
|
case .timelineItemMenu(let itemID):
|
||||||
Task {
|
roomScreenInteractionHandler.showTimelineItemActionMenu(for: itemID)
|
||||||
if case let .success(value) = await roomProxy.canUserRedact(userID: roomProxy.ownUserID) {
|
|
||||||
canCurrentUserRedact = value
|
|
||||||
} else {
|
|
||||||
canCurrentUserRedact = false
|
|
||||||
}
|
|
||||||
showTimelineItemActionMenu(for: itemID)
|
|
||||||
}
|
|
||||||
case .timelineItemMenuAction(let itemID, let action):
|
case .timelineItemMenuAction(let itemID, let action):
|
||||||
processTimelineItemMenuAction(action, itemID: itemID)
|
roomScreenInteractionHandler.processTimelineItemMenuAction(action, itemID: itemID)
|
||||||
case .handlePasteOrDrop(let provider):
|
case .handlePasteOrDrop(let provider):
|
||||||
handlePasteOrDrop(provider)
|
roomScreenInteractionHandler.handlePasteOrDrop(provider)
|
||||||
case .tappedOnUser(userID: let userID):
|
case .tappedOnUser(userID: let userID):
|
||||||
Task { await handleTappedUser(userID: userID) }
|
Task { await roomScreenInteractionHandler.handleTappedUser(userID: userID) }
|
||||||
case .displayEmojiPicker(let itemID):
|
case .displayEmojiPicker(let itemID):
|
||||||
showEmojiPicker(for: itemID)
|
roomScreenInteractionHandler.showEmojiPicker(for: itemID)
|
||||||
case .reactionSummary(let itemID, let key):
|
case .reactionSummary(let itemID, let key):
|
||||||
showReactionSummary(for: itemID, selectedKey: key)
|
showReactionSummary(for: itemID, selectedKey: key)
|
||||||
case .retrySend(let itemID):
|
case .retrySend(let itemID):
|
||||||
Task { await handleRetrySend(itemID: itemID) }
|
Task { await timelineController.retrySending(itemID: itemID) }
|
||||||
case .cancelSend(let itemID):
|
case .cancelSend(let itemID):
|
||||||
Task { await handleCancelSend(itemID: itemID) }
|
Task { await timelineController.cancelSending(itemID: itemID) }
|
||||||
case .paginateBackwards:
|
case .paginateBackwards:
|
||||||
paginateBackwards()
|
paginateBackwards()
|
||||||
case .scrolledToBottom:
|
case .scrolledToBottom:
|
||||||
@ -155,17 +161,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
renderPendingTimelineItems()
|
renderPendingTimelineItems()
|
||||||
}
|
}
|
||||||
case let .selectedPollOption(pollStartID, optionID):
|
case let .selectedPollOption(pollStartID, optionID):
|
||||||
sendPollResponse(pollStartID: pollStartID, optionID: optionID)
|
roomScreenInteractionHandler.sendPollResponse(pollStartID: pollStartID, optionID: optionID)
|
||||||
case .playPauseAudio(let itemID):
|
case .playPauseAudio(let itemID):
|
||||||
Task { await timelineController.playPauseAudio(for: itemID) }
|
Task { await roomScreenInteractionHandler.playPauseAudio(for: itemID) }
|
||||||
case .seekAudio(let itemID, let progress):
|
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):
|
case let .endPoll(pollStartID):
|
||||||
state.bindings.confirmationAlertInfo = .init(id: .init(),
|
state.bindings.confirmationAlertInfo = .init(id: .init(),
|
||||||
title: L10n.actionEndPoll,
|
title: L10n.actionEndPoll,
|
||||||
message: L10n.commonPollEndConfirmation,
|
message: L10n.commonPollEndConfirmation,
|
||||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
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:
|
case .presentCall:
|
||||||
actionsSubject.send(.displayCallScreen)
|
actionsSubject.send(.displayCallScreen)
|
||||||
}
|
}
|
||||||
@ -191,7 +197,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
case .displayPollForm:
|
case .displayPollForm:
|
||||||
actionsSubject.send(.displayPollForm)
|
actionsSubject.send(.displayPollForm)
|
||||||
case .handlePasteOrDrop(let provider):
|
case .handlePasteOrDrop(let provider):
|
||||||
handlePasteOrDrop(provider)
|
roomScreenInteractionHandler.handlePasteOrDrop(provider)
|
||||||
case .composerModeChanged(mode: let mode):
|
case .composerModeChanged(mode: let mode):
|
||||||
trackComposerMode(mode)
|
trackComposerMode(mode)
|
||||||
case .composerFocusedChanged(isFocused: let isFocused):
|
case .composerFocusedChanged(isFocused: let isFocused):
|
||||||
@ -199,24 +205,24 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
case .startVoiceMessageRecording:
|
case .startVoiceMessageRecording:
|
||||||
Task {
|
Task {
|
||||||
await mediaPlayerProvider.detachAllStates(except: nil)
|
await mediaPlayerProvider.detachAllStates(except: nil)
|
||||||
await startRecordingVoiceMessage()
|
await roomScreenInteractionHandler.startRecordingVoiceMessage()
|
||||||
}
|
}
|
||||||
case .stopVoiceMessageRecording:
|
case .stopVoiceMessageRecording:
|
||||||
Task { await stopRecordingVoiceMessage() }
|
Task { await roomScreenInteractionHandler.stopRecordingVoiceMessage() }
|
||||||
case .cancelVoiceMessageRecording:
|
case .cancelVoiceMessageRecording:
|
||||||
Task { await cancelRecordingVoiceMessage() }
|
Task { await roomScreenInteractionHandler.cancelRecordingVoiceMessage() }
|
||||||
case .deleteVoiceMessageRecording:
|
case .deleteVoiceMessageRecording:
|
||||||
Task { await deleteCurrentVoiceMessage() }
|
Task { await roomScreenInteractionHandler.deleteCurrentVoiceMessage() }
|
||||||
case .sendVoiceMessage:
|
case .sendVoiceMessage:
|
||||||
Task { await sendCurrentVoiceMessage() }
|
Task { await roomScreenInteractionHandler.sendCurrentVoiceMessage() }
|
||||||
case .startVoiceMessagePlayback:
|
case .startVoiceMessagePlayback:
|
||||||
Task { await startPlayingRecordedVoiceMessage() }
|
Task { await roomScreenInteractionHandler.startPlayingRecordedVoiceMessage() }
|
||||||
case .pauseVoiceMessagePlayback:
|
case .pauseVoiceMessagePlayback:
|
||||||
pausePlayingRecordedVoiceMessage()
|
roomScreenInteractionHandler.pausePlayingRecordedVoiceMessage()
|
||||||
case .seekVoiceMessagePlayback(let progress):
|
case .seekVoiceMessagePlayback(let progress):
|
||||||
Task { await seekRecordedVoiceMessage(to: progress) }
|
Task { await roomScreenInteractionHandler.seekRecordedVoiceMessage(to: progress) }
|
||||||
case .scrubVoiceMessagePlayback(let scrubbing):
|
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)
|
.receive(on: DispatchQueue.main)
|
||||||
.weakAssign(to: \.state.members, on: self)
|
.weakAssign(to: \.state.members, on: self)
|
||||||
.store(in: &cancellables)
|
.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() {
|
private func setupDirectRoomSubscriptionsIfNeeded() {
|
||||||
@ -342,7 +378,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
|
|
||||||
// Clear any notifications from notification center.
|
// Clear any notifications from notification center.
|
||||||
if lastVisibleItemID.timelineID == state.timelineViewState.timelineIDs.last {
|
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) {
|
switch await timelineController.sendReadReceipt(for: eventItemID) {
|
||||||
@ -364,9 +400,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
return nearestItemID
|
return nearestItemID
|
||||||
}
|
}
|
||||||
|
|
||||||
private func itemTapped(with itemID: TimelineItemIdentifier) async {
|
private func handleItemTapped(with itemID: TimelineItemIdentifier) async {
|
||||||
state.showLoading = true
|
state.showLoading = true
|
||||||
let action = await timelineController.processItemTap(itemID)
|
let action = await roomScreenInteractionHandler.processItemTap(itemID)
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case .displayMediaFile(let file, let title):
|
case .displayMediaFile(let file, let title):
|
||||||
@ -379,9 +415,65 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
state.showLoading = false
|
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() {
|
private func buildTimelineViews() {
|
||||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
|
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
|
||||||
|
|
||||||
|
timelineController.timelineItems.filter { $0 is RedactedRoomTimelineItem }.forEach { timelineItem in
|
||||||
|
// Stops the audio player when a voice message is redacted.
|
||||||
|
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
playerState.detachAudioPlayer()
|
||||||
|
mediaPlayerProvider.unregister(audioPlayerState: playerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in
|
let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in
|
||||||
canGroupItem(timelineItem: current, with: next)
|
canGroupItem(timelineItem: current, with: next)
|
||||||
@ -487,352 +579,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
return eventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender
|
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
|
// MARK: - Direct chats logics
|
||||||
|
|
||||||
private func showInviteAlert() {
|
private func showInviteAlert() {
|
||||||
@ -878,17 +624,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reactions
|
// 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) {
|
private func showReactionSummary(for itemID: TimelineItemIdentifier, selectedKey: String) {
|
||||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
|
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
|
||||||
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
|
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||||
@ -897,157 +633,22 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
|
|
||||||
state.bindings.reactionSummaryInfo = .init(reactions: eventTimelineItem.properties.reactions, selectedKey: selectedKey)
|
state.bindings.reactionSummaryInfo = .init(reactions: eventTimelineItem.properties.reactions, selectedKey: selectedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Polls
|
// MARK: - User Indicators
|
||||||
|
|
||||||
private func sendPollResponse(pollStartID: String, optionID: String) {
|
private func displayError(_ type: RoomScreenErrorType) {
|
||||||
Task {
|
switch type {
|
||||||
let sendPollResponseResult = await roomProxy.sendPollResponse(pollStartID: pollStartID, answers: [optionID])
|
case .alert(let message):
|
||||||
analytics.trackPollVote()
|
state.bindings.alertInfo = AlertInfo(id: type,
|
||||||
|
title: L10n.commonError,
|
||||||
switch sendPollResponseResult {
|
message: message)
|
||||||
case .success:
|
case .toast(let message):
|
||||||
break
|
userIndicatorController.submitIndicator(UserIndicator(id: Constants.toastErrorID,
|
||||||
case .failure:
|
type: .toast,
|
||||||
displayError(.toast(L10n.errorUnknown))
|
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 {
|
private extension RoomProxyProtocol {
|
||||||
@ -1057,35 +658,19 @@ private extension RoomProxyProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension RoomScreenViewModel.Context {
|
|
||||||
/// A function to make it easier to bind to reactions expand/collapsed state
|
|
||||||
/// - Parameter itemID: The id of the timeline item the reacted to
|
|
||||||
/// - Returns: Wether the reactions should show in the collapsed state, true by default.
|
|
||||||
func reactionsCollapsedBinding(for itemID: TimelineItemIdentifier) -> Binding<Bool> {
|
|
||||||
Binding(get: {
|
|
||||||
self.reactionsCollapsed[itemID] ?? true
|
|
||||||
}, set: {
|
|
||||||
self.reactionsCollapsed[itemID] = $0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Mocks
|
// MARK: - Mocks
|
||||||
|
|
||||||
extension RoomScreenViewModel {
|
extension RoomScreenViewModel {
|
||||||
static let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
static let mock = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
|
||||||
|
timelineController: MockRoomTimelineController(),
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||||
application: ApplicationMock.default)
|
application: ApplicationMock.default,
|
||||||
}
|
appSettings: ServiceLocator.shared.settings,
|
||||||
|
analyticsService: ServiceLocator.shared.analytics,
|
||||||
private struct ReplyInfo {
|
notificationCenter: NotificationCenterMock())
|
||||||
let type: EventBasedMessageTimelineItemContentType
|
|
||||||
let isThread: Bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct RoomContextKey: EnvironmentKey {
|
private struct RoomContextKey: EnvironmentKey {
|
||||||
|
@ -55,14 +55,16 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
static var bodyPlain: some View {
|
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(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name", avatarURL: URL.picturesDirectory)),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||||
application: ApplicationMock.default)
|
application: ApplicationMock.default,
|
||||||
|
appSettings: ServiceLocator.shared.settings,
|
||||||
|
analyticsService: ServiceLocator.shared.analytics,
|
||||||
|
notificationCenter: NotificationCenterMock())
|
||||||
|
|
||||||
RoomHeaderView(context: viewModel.context)
|
RoomHeaderView(context: viewModel.context)
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
@ -71,14 +73,16 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
static var bodyEncrypted: some View {
|
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(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name")),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||||
application: ApplicationMock.default)
|
application: ApplicationMock.default,
|
||||||
|
appSettings: ServiceLocator.shared.settings,
|
||||||
|
analyticsService: ServiceLocator.shared.analytics,
|
||||||
|
notificationCenter: NotificationCenterMock())
|
||||||
|
|
||||||
RoomHeaderView(context: viewModel.context)
|
RoomHeaderView(context: viewModel.context)
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
|
@ -187,14 +187,16 @@ struct RoomScreen: View {
|
|||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
struct RoomScreen_Previews: PreviewProvider, TestablePreview {
|
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(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room", isCallOngoing: true)),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
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 {
|
static var previews: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
@ -103,10 +103,10 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
|||||||
.timelineAccessibility(timelineItem)
|
.timelineAccessibility(timelineItem)
|
||||||
|
|
||||||
if !timelineItem.properties.reactions.isEmpty {
|
if !timelineItem.properties.reactions.isEmpty {
|
||||||
TimelineReactionsView(itemID: timelineItem.id,
|
TimelineReactionsView(context: context,
|
||||||
|
itemID: timelineItem.id,
|
||||||
reactions: timelineItem.properties.reactions,
|
reactions: timelineItem.properties.reactions,
|
||||||
isLayoutRTL: timelineItem.isOutgoing,
|
isLayoutRTL: timelineItem.isOutgoing)
|
||||||
collapsed: context.reactionsCollapsedBinding(for: timelineItem.id))
|
|
||||||
// Workaround to stop the message long press stealing the touch from the reaction buttons
|
// Workaround to stop the message long press stealing the touch from the reaction buttons
|
||||||
.onTapGesture { }
|
.onTapGesture { }
|
||||||
}
|
}
|
||||||
|
@ -129,9 +129,9 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !timelineItem.properties.reactions.isEmpty {
|
if !timelineItem.properties.reactions.isEmpty {
|
||||||
TimelineReactionsView(itemID: timelineItem.id,
|
TimelineReactionsView(context: context,
|
||||||
reactions: timelineItem.properties.reactions,
|
itemID: timelineItem.id,
|
||||||
collapsed: context.reactionsCollapsedBinding(for: timelineItem.id))
|
reactions: timelineItem.properties.reactions)
|
||||||
// Workaround to stop the message long press stealing the touch from the reaction buttons
|
// Workaround to stop the message long press stealing the touch from the reaction buttons
|
||||||
.onTapGesture { }
|
.onTapGesture { }
|
||||||
}
|
}
|
||||||
|
@ -16,17 +16,35 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct TimelineReactionsView: View {
|
struct TimelineReactionsView: View {
|
||||||
private static let horizontalSpacing: CGFloat = 4
|
private static let horizontalSpacing: CGFloat = 4
|
||||||
private static let verticalSpacing: CGFloat = 4
|
private static let verticalSpacing: CGFloat = 4
|
||||||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
|
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
|
||||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
|
||||||
@Environment(\.layoutDirection) private var layoutDirection: LayoutDirection
|
@Environment(\.layoutDirection) private var layoutDirection: LayoutDirection
|
||||||
|
|
||||||
|
let context: RoomScreenViewModel.Context
|
||||||
let itemID: TimelineItemIdentifier
|
let itemID: TimelineItemIdentifier
|
||||||
let reactions: [AggregatedReaction]
|
let reactions: [AggregatedReaction]
|
||||||
var isLayoutRTL = false
|
let isLayoutRTL: Bool
|
||||||
@Binding var collapsed: Bool
|
|
||||||
|
private var collapsed: Binding<Bool>
|
||||||
|
|
||||||
|
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 {
|
var reactionsLayoutDirection: LayoutDirection {
|
||||||
guard isLayoutRTL else { return layoutDirection }
|
guard isLayoutRTL else { return layoutDirection }
|
||||||
@ -48,9 +66,9 @@ struct TimelineReactionsView: View {
|
|||||||
|
|
||||||
if isCollapsible {
|
if isCollapsible {
|
||||||
Button {
|
Button {
|
||||||
collapsed.toggle()
|
collapsed.wrappedValue.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
TimelineCollapseButtonLabel(collapsed: collapsed)
|
TimelineCollapseButtonLabel(collapsed: collapsed.wrappedValue)
|
||||||
.transaction { $0.animation = nil }
|
.transaction { $0.animation = nil }
|
||||||
}
|
}
|
||||||
.reactionLayoutItem(.expandCollapse)
|
.reactionLayoutItem(.expandCollapse)
|
||||||
@ -79,7 +97,7 @@ struct TimelineReactionsView: View {
|
|||||||
if isCollapsible {
|
if isCollapsible {
|
||||||
return AnyLayout(CollapsibleReactionLayout(itemSpacing: 4,
|
return AnyLayout(CollapsibleReactionLayout(itemSpacing: 4,
|
||||||
rowSpacing: 4,
|
rowSpacing: 4,
|
||||||
collapsed: collapsed,
|
collapsed: collapsed.wrappedValue,
|
||||||
rowsBeforeCollapsible: 2))
|
rowsBeforeCollapsible: 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,21 +206,25 @@ struct TimelineReactionAddMoreButtonLabel: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TimelineReactionViewPreviewsContainer: View {
|
struct TimelineReactionViewPreviewsContainer: View {
|
||||||
@State private var collapseState1 = false
|
|
||||||
@State private var collapseState2 = true
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
TimelineReactionsView(itemID: .init(timelineID: "1"),
|
TimelineReactionsView(context: RoomScreenViewModel.mock.context,
|
||||||
|
itemID: .init(timelineID: "1"),
|
||||||
reactions: [AggregatedReaction.mockReactionWithLongText,
|
reactions: [AggregatedReaction.mockReactionWithLongText,
|
||||||
AggregatedReaction.mockReactionWithLongTextRTL],
|
AggregatedReaction.mockReactionWithLongTextRTL])
|
||||||
collapsed: .constant(true))
|
|
||||||
Divider()
|
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()
|
Divider()
|
||||||
TimelineReactionsView(itemID: .init(timelineID: "3"), reactions: AggregatedReaction.mockReactions, collapsed: $collapseState1)
|
TimelineReactionsView(context: RoomScreenViewModel.mock.context,
|
||||||
|
itemID: .init(timelineID: "3"),
|
||||||
|
reactions: AggregatedReaction.mockReactions)
|
||||||
Divider()
|
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)
|
.background(Color.red)
|
||||||
.frame(maxWidth: 250, alignment: .leading)
|
.frame(maxWidth: 250, alignment: .leading)
|
||||||
|
@ -58,15 +58,16 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
|
|||||||
.mockMe
|
.mockMe
|
||||||
]
|
]
|
||||||
|
|
||||||
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "Test", members: members)),
|
||||||
|
timelineController: MockRoomTimelineController(),
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "Test",
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
members: members)),
|
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
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 singleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now")]
|
||||||
static let doubleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),
|
static let doubleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),
|
||||||
|
@ -80,14 +80,16 @@ struct UITimelineView: UIViewControllerRepresentable {
|
|||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
struct UITimelineView_Previews: PreviewProvider, TestablePreview {
|
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(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
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 {
|
static var previews: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
@ -168,15 +168,17 @@ struct TimelineView: View {
|
|||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
struct TimelineView_Previews: PreviewProvider, TestablePreview {
|
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(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
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 {
|
static var previews: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
|
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
|
||||||
|
@ -63,8 +63,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
func processItemAppearance(_ itemID: TimelineItemIdentifier) async { }
|
func processItemAppearance(_ itemID: TimelineItemIdentifier) async { }
|
||||||
|
|
||||||
func processItemDisappearance(_ itemID: TimelineItemIdentifier) async { }
|
func processItemDisappearance(_ itemID: TimelineItemIdentifier) async { }
|
||||||
|
|
||||||
func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction { .none }
|
|
||||||
|
|
||||||
func sendMessage(_ message: String,
|
func sendMessage(_ message: String,
|
||||||
html: String?,
|
html: String?,
|
||||||
@ -79,8 +77,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
intentionalMentions: IntentionalMentions) async { }
|
intentionalMentions: IntentionalMentions) async { }
|
||||||
|
|
||||||
func redact(_ itemID: TimelineItemIdentifier) async { }
|
func redact(_ itemID: TimelineItemIdentifier) async { }
|
||||||
|
|
||||||
func cancelSend(_ itemID: TimelineItemIdentifier) async { }
|
|
||||||
|
|
||||||
func debugInfo(for itemID: TimelineItemIdentifier) -> TimelineItemDebugInfo {
|
func debugInfo(for itemID: TimelineItemIdentifier) -> TimelineItemDebugInfo {
|
||||||
.init(model: "Mock debug description", originalJSON: nil, latestEditJSON: nil)
|
.init(model: "Mock debug description", originalJSON: nil, latestEditJSON: nil)
|
||||||
@ -88,18 +84,21 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
|
|
||||||
func retryDecryption(for sessionID: String) async { }
|
func retryDecryption(for sessionID: String) async { }
|
||||||
|
|
||||||
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
|
func retrySending(itemID: TimelineItemIdentifier) async {
|
||||||
AudioPlayerState(id: .timelineItemIdentifier(itemID),
|
guard let transactionID = itemID.transactionID else {
|
||||||
duration: 10.0,
|
return
|
||||||
waveform: nil,
|
}
|
||||||
progress: 0.0)
|
|
||||||
|
await roomProxy?.retrySend(transactionID: transactionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func playPauseAudio(for itemID: TimelineItemIdentifier) async { }
|
func cancelSending(itemID: TimelineItemIdentifier) async {
|
||||||
|
guard let transactionID = itemID.transactionID else {
|
||||||
func pauseAudio() { }
|
return
|
||||||
|
}
|
||||||
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { }
|
|
||||||
|
await roomProxy?.cancelSend(transactionID: transactionID)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - UI Test signalling
|
// MARK: - UI Test signalling
|
||||||
|
|
||||||
|
@ -17,12 +17,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct MockRoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
|
struct MockRoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
|
||||||
// swiftlint:disable:next function_parameter_count
|
|
||||||
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
|
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
|
||||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
|
||||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
|
||||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
|
|
||||||
secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol {
|
secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol {
|
||||||
let timelineController = MockRoomTimelineController()
|
let timelineController = MockRoomTimelineController()
|
||||||
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
|
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
|
||||||
|
@ -22,9 +22,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
private let roomProxy: RoomProxyProtocol
|
private let roomProxy: RoomProxyProtocol
|
||||||
private let timelineProvider: RoomTimelineProviderProtocol
|
private let timelineProvider: RoomTimelineProviderProtocol
|
||||||
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
|
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
|
||||||
private let mediaProvider: MediaProviderProtocol
|
|
||||||
private let mediaPlayerProvider: MediaPlayerProviderProtocol
|
|
||||||
private let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
|
|
||||||
private let appSettings: AppSettings
|
private let appSettings: AppSettings
|
||||||
private let secureBackupController: SecureBackupControllerProtocol
|
private let secureBackupController: SecureBackupControllerProtocol
|
||||||
private let serialDispatchQueue: DispatchQueue
|
private let serialDispatchQueue: DispatchQueue
|
||||||
@ -46,17 +43,11 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
|
|
||||||
init(roomProxy: RoomProxyProtocol,
|
init(roomProxy: RoomProxyProtocol,
|
||||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
|
||||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
|
||||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
|
|
||||||
appSettings: AppSettings,
|
appSettings: AppSettings,
|
||||||
secureBackupController: SecureBackupControllerProtocol) {
|
secureBackupController: SecureBackupControllerProtocol) {
|
||||||
self.roomProxy = roomProxy
|
self.roomProxy = roomProxy
|
||||||
timelineProvider = roomProxy.timelineProvider
|
timelineProvider = roomProxy.timelineProvider
|
||||||
self.timelineItemFactory = timelineItemFactory
|
self.timelineItemFactory = timelineItemFactory
|
||||||
self.mediaProvider = mediaProvider
|
|
||||||
self.mediaPlayerProvider = mediaPlayerProvider
|
|
||||||
self.voiceMessageMediaManager = voiceMessageMediaManager
|
|
||||||
self.appSettings = appSettings
|
self.appSettings = appSettings
|
||||||
self.secureBackupController = secureBackupController
|
self.secureBackupController = secureBackupController
|
||||||
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility)
|
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility)
|
||||||
@ -117,20 +108,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func processItemDisappearance(_ itemID: TimelineItemIdentifier) { }
|
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,
|
func sendMessage(_ message: String,
|
||||||
html: String?,
|
html: String?,
|
||||||
@ -182,7 +159,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
let item = timelineItem as? EventBasedTimelineItemProtocol,
|
let item = timelineItem as? EventBasedTimelineItemProtocol,
|
||||||
item.hasFailedToSend {
|
item.hasFailedToSend {
|
||||||
MXLog.info("Editing a failed echo, will cancel and resend it as a new message")
|
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)
|
await sendMessage(newMessage, html: html, intentionalMentions: intentionalMentions)
|
||||||
} else if let eventID = itemID.eventID {
|
} else if let eventID = itemID.eventID {
|
||||||
switch await roomProxy.editMessage(newMessage,
|
switch await roomProxy.editMessage(newMessage,
|
||||||
@ -211,15 +188,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
MXLog.error("Failed redacting message with error: \(error)")
|
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
|
// Handle this parallel to the timeline items so we're not forced
|
||||||
// to bundle the Rust side objects within them
|
// to bundle the Rust side objects within them
|
||||||
@ -242,87 +210,24 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
await roomProxy.retryDecryption(for: sessionID)
|
await roomProxy.retryDecryption(for: sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
|
func retrySending(itemID: TimelineItemIdentifier) async {
|
||||||
guard let timelineItem = timelineItems.firstUsingStableID(itemID) else {
|
guard let transactionID = itemID.transactionID else {
|
||||||
fatalError("TimelineItem \(itemID) not found")
|
MXLog.error("Failed Retry Send: missing transaction ID")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else {
|
MXLog.info("Retry sending in \(roomID)")
|
||||||
fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
|
await roomProxy.retrySend(transactionID: transactionID)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func playPauseAudio(for itemID: TimelineItemIdentifier) async {
|
func cancelSending(itemID: TimelineItemIdentifier) async {
|
||||||
MXLog.info("Toggle play/pause audio for itemID \(itemID)")
|
guard let transactionID = itemID.transactionID else {
|
||||||
guard let timelineItem = timelineItems.firstUsingStableID(itemID) else {
|
MXLog.error("Failed Cancel Send: missing transaction ID")
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: source), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
|
MXLog.info("Cancelling send in \(roomID)")
|
||||||
MXLog.error("Cannot play a voice message without an audio player")
|
await roomProxy.cancelSend(transactionID: transactionID)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
@ -331,37 +236,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
// Recompute all attributed strings on content size changes -> DynamicType support
|
// Recompute all attributed strings on content size changes -> DynamicType support
|
||||||
updateTimelineItems()
|
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() {
|
private func updateTimelineItems() {
|
||||||
var newTimelineItems = [RoomTimelineItemProtocol]()
|
var newTimelineItems = [RoomTimelineItemProtocol]()
|
||||||
@ -404,17 +278,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
lastEncryptedHistoryItemIndex = newTimelineItems.endIndex
|
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)
|
newTimelineItems.append(timelineItem)
|
||||||
} else {
|
} else {
|
||||||
newTimelineItems.append(CollapsibleTimelineItem(items: items))
|
newTimelineItems.append(CollapsibleTimelineItem(items: items))
|
||||||
|
@ -17,18 +17,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
|
struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
|
||||||
// swiftlint:disable:next function_parameter_count
|
|
||||||
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
|
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
|
||||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
|
||||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
|
||||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
|
|
||||||
secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol {
|
secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol {
|
||||||
RoomTimelineController(roomProxy: roomProxy,
|
RoomTimelineController(roomProxy: roomProxy,
|
||||||
timelineItemFactory: timelineItemFactory,
|
timelineItemFactory: timelineItemFactory,
|
||||||
mediaProvider: mediaProvider,
|
|
||||||
mediaPlayerProvider: mediaPlayerProvider,
|
|
||||||
voiceMessageMediaManager: voiceMessageMediaManager,
|
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
secureBackupController: secureBackupController)
|
secureBackupController: secureBackupController)
|
||||||
}
|
}
|
||||||
|
@ -18,11 +18,7 @@ import Foundation
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol RoomTimelineControllerFactoryProtocol {
|
protocol RoomTimelineControllerFactoryProtocol {
|
||||||
// swiftlint:disable:next function_parameter_count
|
|
||||||
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
|
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
|
||||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
|
||||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
|
||||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
|
|
||||||
secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol
|
secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol
|
||||||
}
|
}
|
||||||
|
@ -44,8 +44,6 @@ protocol RoomTimelineControllerProtocol {
|
|||||||
func processItemAppearance(_ itemID: TimelineItemIdentifier) async
|
func processItemAppearance(_ itemID: TimelineItemIdentifier) async
|
||||||
|
|
||||||
func processItemDisappearance(_ itemID: TimelineItemIdentifier) async
|
func processItemDisappearance(_ itemID: TimelineItemIdentifier) async
|
||||||
|
|
||||||
func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction
|
|
||||||
|
|
||||||
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomTimelineControllerError>
|
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomTimelineControllerError>
|
||||||
|
|
||||||
@ -64,18 +62,14 @@ protocol RoomTimelineControllerProtocol {
|
|||||||
func toggleReaction(_ reaction: String, to itemID: TimelineItemIdentifier) async
|
func toggleReaction(_ reaction: String, to itemID: TimelineItemIdentifier) async
|
||||||
|
|
||||||
func redact(_ itemID: TimelineItemIdentifier) async
|
func redact(_ itemID: TimelineItemIdentifier) async
|
||||||
|
|
||||||
func cancelSend(_ itemID: TimelineItemIdentifier) async
|
|
||||||
|
|
||||||
func debugInfo(for itemID: TimelineItemIdentifier) -> TimelineItemDebugInfo
|
func debugInfo(for itemID: TimelineItemIdentifier) -> TimelineItemDebugInfo
|
||||||
|
|
||||||
func retryDecryption(for sessionID: String) async
|
func retryDecryption(for sessionID: String) async
|
||||||
|
|
||||||
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState
|
func retrySending(itemID: TimelineItemIdentifier) async
|
||||||
|
|
||||||
func playPauseAudio(for itemID: TimelineItemIdentifier) async
|
func cancelSending(itemID: TimelineItemIdentifier) async
|
||||||
|
|
||||||
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension RoomTimelineControllerProtocol {
|
extension RoomTimelineControllerProtocol {
|
||||||
|
@ -280,6 +280,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: MockRoomTimelineController(),
|
timelineController: MockRoomTimelineController(),
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -292,6 +293,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: MockRoomTimelineController(),
|
timelineController: MockRoomTimelineController(),
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -306,6 +308,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -320,6 +323,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -335,6 +339,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -352,6 +357,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -369,6 +375,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -386,6 +393,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -404,6 +412,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -421,6 +430,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -438,6 +448,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -455,6 +466,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
@ -472,6 +484,7 @@ class MockScreen: Identifiable {
|
|||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
emojiProvider: EmojiProvider(),
|
emojiProvider: EmojiProvider(),
|
||||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings)
|
appSettings: ServiceLocator.shared.settings)
|
||||||
|
@ -26,14 +26,16 @@ class PillContextTests: XCTestCase {
|
|||||||
let proxyMock = RoomProxyMock(with: .init(displayName: "Test"))
|
let proxyMock = RoomProxyMock(with: .init(displayName: "Test"))
|
||||||
let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
|
let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
|
||||||
proxyMock.members = subject.asCurrentValuePublisher()
|
proxyMock.members = subject.asCurrentValuePublisher()
|
||||||
let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
let mock = RoomScreenViewModel(roomProxy: proxyMock,
|
||||||
|
timelineController: MockRoomTimelineController(),
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: proxyMock,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
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)))
|
let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body)))
|
||||||
|
|
||||||
XCTAssertFalse(context.viewState.isOwnMention)
|
XCTAssertFalse(context.viewState.isOwnMention)
|
||||||
@ -53,14 +55,16 @@ class PillContextTests: XCTestCase {
|
|||||||
let proxyMock = RoomProxyMock(with: .init(displayName: "Test", ownUserID: id))
|
let proxyMock = RoomProxyMock(with: .init(displayName: "Test", ownUserID: id))
|
||||||
let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
|
let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
|
||||||
proxyMock.members = subject.asCurrentValuePublisher()
|
proxyMock.members = subject.asCurrentValuePublisher()
|
||||||
let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
let mock = RoomScreenViewModel(roomProxy: proxyMock,
|
||||||
|
timelineController: MockRoomTimelineController(),
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: proxyMock,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
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)))
|
let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body)))
|
||||||
|
|
||||||
XCTAssertTrue(context.viewState.isOwnMention)
|
XCTAssertTrue(context.viewState.isOwnMention)
|
||||||
@ -73,14 +77,16 @@ class PillContextTests: XCTestCase {
|
|||||||
let proxyMock = RoomProxyMock(with: .init(id: id, displayName: displayName, avatarURL: avatarURL))
|
let proxyMock = RoomProxyMock(with: .init(id: id, displayName: displayName, avatarURL: avatarURL))
|
||||||
let mockController = MockRoomTimelineController()
|
let mockController = MockRoomTimelineController()
|
||||||
mockController.roomProxy = proxyMock
|
mockController.roomProxy = proxyMock
|
||||||
let mock = RoomScreenViewModel(timelineController: mockController,
|
let mock = RoomScreenViewModel(roomProxy: proxyMock,
|
||||||
|
timelineController: mockController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: proxyMock,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
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)))
|
let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .allUsers, font: .preferredFont(forTextStyle: .body)))
|
||||||
|
|
||||||
XCTAssertTrue(context.viewState.isOwnMention)
|
XCTAssertTrue(context.viewState.isOwnMention)
|
||||||
|
@ -49,14 +49,16 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
// When showing them in a timeline.
|
// When showing them in a timeline.
|
||||||
let timelineController = MockRoomTimelineController()
|
let timelineController = MockRoomTimelineController()
|
||||||
timelineController.timelineItems = items
|
timelineController.timelineItems = items
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")),
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "")),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
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.
|
// Then the messages should be grouped together.
|
||||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
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.
|
// When showing them in a timeline.
|
||||||
let timelineController = MockRoomTimelineController()
|
let timelineController = MockRoomTimelineController()
|
||||||
timelineController.timelineItems = items
|
timelineController.timelineItems = items
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")),
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "")),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
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.
|
// 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.")
|
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.
|
// When showing them in a timeline.
|
||||||
let timelineController = MockRoomTimelineController()
|
let timelineController = MockRoomTimelineController()
|
||||||
timelineController.timelineItems = items
|
timelineController.timelineItems = items
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")),
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "")),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
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.
|
// 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.")
|
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.
|
// When showing them in a timeline.
|
||||||
let timelineController = MockRoomTimelineController()
|
let timelineController = MockRoomTimelineController()
|
||||||
timelineController.timelineItems = items
|
timelineController.timelineItems = items
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")),
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "")),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
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.
|
// 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.")
|
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.
|
// When showing them in a timeline.
|
||||||
let timelineController = MockRoomTimelineController()
|
let timelineController = MockRoomTimelineController()
|
||||||
timelineController.timelineItems = items
|
timelineController.timelineItems = items
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")),
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: RoomProxyMock(with: .init(displayName: "")),
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
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.
|
// Then the messages should be grouped together.
|
||||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
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"
|
roomMemberMock.userID = "bob"
|
||||||
roomProxyMock.getMemberUserIDReturnValue = .success(roomMemberMock)
|
roomProxyMock.getMemberUserIDReturnValue = .success(roomMemberMock)
|
||||||
|
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: roomProxyMock,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
userIndicatorController: userIndicatorControllerMock,
|
||||||
application: ApplicationMock.default)
|
application: ApplicationMock.default,
|
||||||
|
appSettings: ServiceLocator.shared.settings,
|
||||||
|
analyticsService: ServiceLocator.shared.analytics,
|
||||||
|
notificationCenter: NotificationCenterMock())
|
||||||
viewModel.actions
|
viewModel.actions
|
||||||
.sink { action in
|
.sink { action in
|
||||||
switch action {
|
switch action {
|
||||||
@ -243,14 +255,16 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
.success(roomMemberMock)
|
.success(roomMemberMock)
|
||||||
}
|
}
|
||||||
|
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: roomProxyMock,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
userIndicatorController: userIndicatorControllerMock,
|
||||||
application: ApplicationMock.default)
|
application: ApplicationMock.default,
|
||||||
|
appSettings: ServiceLocator.shared.settings,
|
||||||
|
analyticsService: ServiceLocator.shared.analytics,
|
||||||
|
notificationCenter: NotificationCenterMock())
|
||||||
|
|
||||||
viewModel.actions
|
viewModel.actions
|
||||||
.sink { action in
|
.sink { action in
|
||||||
@ -284,14 +298,16 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
.failure(.failedRetrievingMember)
|
.failure(.failedRetrievingMember)
|
||||||
}
|
}
|
||||||
|
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: roomProxyMock,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
userIndicatorController: userIndicatorControllerMock,
|
||||||
application: ApplicationMock.default)
|
application: ApplicationMock.default,
|
||||||
|
appSettings: ServiceLocator.shared.settings,
|
||||||
|
analyticsService: ServiceLocator.shared.analytics,
|
||||||
|
notificationCenter: NotificationCenterMock())
|
||||||
viewModel.actions
|
viewModel.actions
|
||||||
.sink { _ in
|
.sink { _ in
|
||||||
XCTFail("Should not receive any action")
|
XCTFail("Should not receive any action")
|
||||||
@ -316,15 +332,18 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
func testRetrySend() async throws {
|
func testRetrySend() async throws {
|
||||||
let timelineController = MockRoomTimelineController()
|
let timelineController = MockRoomTimelineController()
|
||||||
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
|
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
|
||||||
|
timelineController.roomProxy = roomProxyMock
|
||||||
|
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: roomProxyMock,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
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")))
|
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 timelineController = MockRoomTimelineController()
|
||||||
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
|
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
|
||||||
|
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: roomProxyMock,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
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))
|
viewModel.context.send(viewAction: .retrySend(itemID: .random))
|
||||||
|
|
||||||
@ -357,15 +378,18 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
func testCancelSend() async {
|
func testCancelSend() async {
|
||||||
let timelineController = MockRoomTimelineController()
|
let timelineController = MockRoomTimelineController()
|
||||||
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
|
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
|
||||||
|
timelineController.roomProxy = roomProxyMock
|
||||||
|
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: roomProxyMock,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
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")))
|
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 timelineController = MockRoomTimelineController()
|
||||||
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
|
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
|
||||||
|
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: roomProxyMock,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
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))
|
viewModel.context.send(viewAction: .cancelSend(itemID: .random))
|
||||||
|
|
||||||
@ -517,15 +543,16 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
timelineController.timelineItems = items
|
timelineController.timelineItems = items
|
||||||
timelineController.roomProxy = roomProxy
|
timelineController.roomProxy = roomProxy
|
||||||
|
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(roomProxy: roomProxy,
|
||||||
|
timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||||
roomProxy: roomProxy,
|
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
analytics: ServiceLocator.shared.analytics,
|
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
userIndicatorController: userIndicatorControllerMock,
|
||||||
application: ApplicationMock.default,
|
application: ApplicationMock.default,
|
||||||
notificationCenterProtocol: notificationCenter)
|
appSettings: ServiceLocator.shared.settings,
|
||||||
|
analyticsService: ServiceLocator.shared.analytics,
|
||||||
|
notificationCenter: notificationCenter)
|
||||||
|
|
||||||
return (viewModel, roomProxy, timelineController, notificationCenter)
|
return (viewModel, roomProxy, timelineController, notificationCenter)
|
||||||
}
|
}
|
||||||
|
@ -25,8 +25,8 @@ class TracingConfigurationTests: XCTestCase {
|
|||||||
let filterComponents = configuration.filter.components(separatedBy: ",")
|
let filterComponents = configuration.filter.components(separatedBy: ",")
|
||||||
XCTAssertEqual(filterComponents.first, "info")
|
XCTAssertEqual(filterComponents.first, "info")
|
||||||
XCTAssertTrue(filterComponents.contains("matrix_sdk_base::sliding_sync=trace"))
|
XCTAssertTrue(filterComponents.contains("matrix_sdk_base::sliding_sync=trace"))
|
||||||
XCTAssertTrue(filterComponents.contains("matrix_sdk::http_client=trace"))
|
XCTAssertTrue(filterComponents.contains("matrix_sdk::http_client=debug"))
|
||||||
XCTAssertTrue(filterComponents.contains("matrix_sdk_crypto=trace"))
|
XCTAssertTrue(filterComponents.contains("matrix_sdk_crypto=debug"))
|
||||||
XCTAssertTrue(filterComponents.contains("hyper=warn"))
|
XCTAssertTrue(filterComponents.contains("hyper=warn"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
UnitTests/__Snapshots__/PreviewTests/test_timelineReactionView.1.png
(Stored with Git LFS)
BIN
UnitTests/__Snapshots__/PreviewTests/test_timelineReactionView.1.png
(Stored with Git LFS)
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user