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

* Extract room screen action handling into separate component

* Fix tracing configuration unit tests

* Move the custom reactionsCollapsedBinding to the TimelineReactionsView

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

* Reorder methods

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

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

* Rename RoomScreenActionsHandler -> RoomScreenInteractionHandler

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

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

* Fix unit tests

* Switch back swiftlint file_length rule error to 1000 lines
This commit is contained in:
Stefan Ceriu
2023-11-08 18:31:39 +02:00
committed by GitHub
parent 07313926c9
commit abe2080ee2
25 changed files with 1085 additions and 925 deletions
@@ -63,8 +63,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func processItemAppearance(_ itemID: TimelineItemIdentifier) async { }
func processItemDisappearance(_ itemID: TimelineItemIdentifier) async { }
func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction { .none }
func sendMessage(_ message: String,
html: String?,
@@ -79,8 +77,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
intentionalMentions: IntentionalMentions) async { }
func redact(_ itemID: TimelineItemIdentifier) async { }
func cancelSend(_ itemID: TimelineItemIdentifier) async { }
func debugInfo(for itemID: TimelineItemIdentifier) -> TimelineItemDebugInfo {
.init(model: "Mock debug description", originalJSON: nil, latestEditJSON: nil)
@@ -88,18 +84,21 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func retryDecryption(for sessionID: String) async { }
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
AudioPlayerState(id: .timelineItemIdentifier(itemID),
duration: 10.0,
waveform: nil,
progress: 0.0)
func retrySending(itemID: TimelineItemIdentifier) async {
guard let transactionID = itemID.transactionID else {
return
}
await roomProxy?.retrySend(transactionID: transactionID)
}
func playPauseAudio(for itemID: TimelineItemIdentifier) async { }
func pauseAudio() { }
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { }
func cancelSending(itemID: TimelineItemIdentifier) async {
guard let transactionID = itemID.transactionID else {
return
}
await roomProxy?.cancelSend(transactionID: transactionID)
}
// MARK: - UI Test signalling
@@ -17,12 +17,8 @@
import Foundation
struct MockRoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
// swiftlint:disable:next function_parameter_count
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol {
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
@@ -22,9 +22,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private let roomProxy: RoomProxyProtocol
private let timelineProvider: RoomTimelineProviderProtocol
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
private let mediaProvider: MediaProviderProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
private let appSettings: AppSettings
private let secureBackupController: SecureBackupControllerProtocol
private let serialDispatchQueue: DispatchQueue
@@ -46,17 +43,11 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
init(roomProxy: RoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
appSettings: AppSettings,
secureBackupController: SecureBackupControllerProtocol) {
self.roomProxy = roomProxy
timelineProvider = roomProxy.timelineProvider
self.timelineItemFactory = timelineItemFactory
self.mediaProvider = mediaProvider
self.mediaPlayerProvider = mediaPlayerProvider
self.voiceMessageMediaManager = voiceMessageMediaManager
self.appSettings = appSettings
self.secureBackupController = secureBackupController
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility)
@@ -117,20 +108,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
}
func processItemDisappearance(_ itemID: TimelineItemIdentifier) { }
func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction {
guard let timelineItem = timelineItems.firstUsingStableID(itemID) else {
return .none
}
switch timelineItem {
case let item as LocationRoomTimelineItem:
guard let geoURI = item.content.geoURI else { return .none }
return .displayLocation(body: item.content.body, geoURI: geoURI, description: item.content.description)
default:
return await displayMediaActionIfPossible(timelineItem: timelineItem)
}
}
func sendMessage(_ message: String,
html: String?,
@@ -182,7 +159,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
let item = timelineItem as? EventBasedTimelineItemProtocol,
item.hasFailedToSend {
MXLog.info("Editing a failed echo, will cancel and resend it as a new message")
await cancelSend(itemID)
await cancelSending(itemID: itemID)
await sendMessage(newMessage, html: html, intentionalMentions: intentionalMentions)
} else if let eventID = itemID.eventID {
switch await roomProxy.editMessage(newMessage,
@@ -211,15 +188,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
MXLog.error("Failed redacting message with error: \(error)")
}
}
func cancelSend(_ itemID: TimelineItemIdentifier) async {
guard let transactionID = itemID.transactionID else {
MXLog.error("Failed cancelling send, missing transaction ID")
return
}
MXLog.info("Cancelling send in \(roomID)")
await roomProxy.cancelSend(transactionID: transactionID)
}
// Handle this parallel to the timeline items so we're not forced
// to bundle the Rust side objects within them
@@ -242,87 +210,24 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
await roomProxy.retryDecryption(for: sessionID)
}
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
guard let timelineItem = timelineItems.firstUsingStableID(itemID) else {
fatalError("TimelineItem \(itemID) not found")
func retrySending(itemID: TimelineItemIdentifier) async {
guard let transactionID = itemID.transactionID else {
MXLog.error("Failed Retry Send: missing transaction ID")
return
}
guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else {
fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
}
if let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) {
return playerState
}
let playerState = AudioPlayerState(id: .timelineItemIdentifier(itemID),
duration: voiceMessageRoomTimelineItem.content.duration,
waveform: voiceMessageRoomTimelineItem.content.waveform)
mediaPlayerProvider.register(audioPlayerState: playerState)
return playerState
MXLog.info("Retry sending in \(roomID)")
await roomProxy.retrySend(transactionID: transactionID)
}
func playPauseAudio(for itemID: TimelineItemIdentifier) async {
MXLog.info("Toggle play/pause audio for itemID \(itemID)")
guard let timelineItem = timelineItems.firstUsingStableID(itemID) else {
fatalError("TimelineItem \(itemID) not found")
}
guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else {
fatalError("Invalid TimelineItem type for itemID \(itemID) (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
}
guard let source = voiceMessageRoomTimelineItem.content.source else {
MXLog.error("Cannot start voice message playback, source is not defined for itemID \(itemID)")
func cancelSending(itemID: TimelineItemIdentifier) async {
guard let transactionID = itemID.transactionID else {
MXLog.error("Failed Cancel Send: missing transaction ID")
return
}
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: source), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
MXLog.error("Cannot play a voice message without an audio player")
return
}
let audioPlayerState = audioPlayerState(for: itemID)
// Ensure this one is attached
if !audioPlayerState.isAttached {
audioPlayerState.attachAudioPlayer(audioPlayer)
}
// Detach all other states
await mediaPlayerProvider.detachAllStates(except: audioPlayerState)
guard audioPlayer.mediaSource == source, audioPlayer.state != .error else {
// Load content
do {
MXLog.info("Loading voice message audio content from source for itemID \(itemID)")
let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(source, body: nil)
// Make sure that the player is still attached, as it may have been detached while waiting for the voice message to be loaded.
if audioPlayerState.isAttached {
audioPlayer.load(mediaSource: source, using: url, autoplay: true)
}
} catch {
MXLog.error("Failed to load voice message: \(error)")
audioPlayerState.reportError(error)
}
return
}
if audioPlayer.state == .playing {
audioPlayer.pause()
} else {
audioPlayer.play()
}
}
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async {
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else {
return
}
await mediaPlayerProvider.detachAllStates(except: playerState)
await playerState.updateState(progress: progress)
MXLog.info("Cancelling send in \(roomID)")
await roomProxy.cancelSend(transactionID: transactionID)
}
// MARK: - Private
@@ -331,37 +236,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
// Recompute all attributed strings on content size changes -> DynamicType support
updateTimelineItems()
}
private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> RoomTimelineControllerAction {
var source: MediaSourceProxy?
var body: String
switch timelineItem {
case let item as ImageRoomTimelineItem:
source = item.content.source
body = item.content.body
case let item as VideoRoomTimelineItem:
source = item.content.source
body = item.content.body
case let item as FileRoomTimelineItem:
source = item.content.source
body = item.content.body
case let item as AudioRoomTimelineItem:
// For now we are just displaying audio messages with the File preview until we create a timeline player for them.
source = item.content.source
body = item.content.body
default:
return .none
}
guard let source else { return .none }
switch await mediaProvider.loadFileFromSource(source, body: body) {
case .success(let file):
return .displayMediaFile(file: file, title: body)
case .failure:
return .none
}
}
private func updateTimelineItems() {
var newTimelineItems = [RoomTimelineItemProtocol]()
@@ -404,17 +278,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
lastEncryptedHistoryItemIndex = newTimelineItems.endIndex
}
// Stops the audio player when a voice message is redacted.
if timelineItem is RedactedRoomTimelineItem {
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
continue
}
Task { @MainActor in
playerState.detachAudioPlayer()
mediaPlayerProvider.unregister(audioPlayerState: playerState)
}
}
newTimelineItems.append(timelineItem)
} else {
newTimelineItems.append(CollapsibleTimelineItem(items: items))
@@ -17,18 +17,11 @@
import Foundation
struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
// swiftlint:disable:next function_parameter_count
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol {
RoomTimelineController(roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageMediaManager: voiceMessageMediaManager,
appSettings: ServiceLocator.shared.settings,
secureBackupController: secureBackupController)
}
@@ -18,11 +18,7 @@ import Foundation
@MainActor
protocol RoomTimelineControllerFactoryProtocol {
// swiftlint:disable:next function_parameter_count
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
secureBackupController: SecureBackupControllerProtocol) -> RoomTimelineControllerProtocol
}
@@ -44,8 +44,6 @@ protocol RoomTimelineControllerProtocol {
func processItemAppearance(_ itemID: TimelineItemIdentifier) async
func processItemDisappearance(_ itemID: TimelineItemIdentifier) async
func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomTimelineControllerError>
@@ -64,18 +62,14 @@ protocol RoomTimelineControllerProtocol {
func toggleReaction(_ reaction: String, to itemID: TimelineItemIdentifier) async
func redact(_ itemID: TimelineItemIdentifier) async
func cancelSend(_ itemID: TimelineItemIdentifier) async
func debugInfo(for itemID: TimelineItemIdentifier) -> TimelineItemDebugInfo
func retryDecryption(for sessionID: String) async
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState
func retrySending(itemID: TimelineItemIdentifier) async
func playPauseAudio(for itemID: TimelineItemIdentifier) async
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async
func cancelSending(itemID: TimelineItemIdentifier) async
}
extension RoomTimelineControllerProtocol {