From 88b5426e97a793f71ba5b5270314b089511dca94 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Thu, 12 Dec 2024 09:22:36 +0000 Subject: [PATCH] Hook up the actions in the media details sheet. (#3607) --- .../MediaEventsTimelineFlowCoordinator.swift | 10 ++++ .../RoomFlowCoordinator.swift | 7 +++ .../TimelineMediaPreviewController.swift | 4 +- .../TimelineMediaPreviewModels.swift | 5 +- .../TimelineMediaPreviewViewModel.swift | 6 ++- ...MediaEventsTimelineScreenCoordinator.swift | 13 +++++- .../MediaEventsTimelineScreenModels.swift | 4 +- .../MediaEventsTimelineScreenViewModel.swift | 17 +++++++ .../View/MediaEventsTimelineScreen.swift | 1 + .../Screens/Timeline/TimelineViewModel.swift | 4 +- .../MockRoomTimelineController.swift | 5 +- .../TimelineMediaPreviewViewModelTests.swift | 46 ++++++++++++++++++- 12 files changed, 109 insertions(+), 13 deletions(-) diff --git a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift index 14d78941df..2aaddeb292 100644 --- a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift @@ -9,6 +9,7 @@ import Combine import Foundation enum MediaEventsTimelineFlowCoordinatorAction { + case viewInRoomTimeline(TimelineItemIdentifier) case finished } @@ -91,6 +92,15 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { let coordinator = MediaEventsTimelineScreenCoordinator(parameters: parameters) + coordinator.actions + .sink { [weak self] action in + switch action { + case .viewInRoomTimeline(let itemID): + self?.actionsSubject.send(.viewInRoomTimeline(itemID)) + } + } + .store(in: &cancellables) + navigationStackCoordinator.push(coordinator) { [weak self] in self?.actionsSubject.send(.finished) } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index c4caf39814..45a40ba3ef 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -1546,6 +1546,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { guard let self else { return } switch action { + case .viewInRoomTimeline(let itemID): + guard let eventID = itemID.eventID else { + MXLog.error("Unable to present room timeline for event \(itemID)") + return + } + stateMachine.tryEvent(.dismissMediaEventsTimeline) + stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: false)))) case .finished: stateMachine.tryEvent(.dismissMediaEventsTimeline) } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift index b8b48352f1..b3332fead7 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewController.swift @@ -55,9 +55,9 @@ class TimelineMediaPreviewController: QLPreviewController, QLPreviewControllerDa switch action { case .loadedMediaFile: self?.refreshCurrentPreviewItem() - case .viewInTimeline: + case .viewInRoomTimeline, .dismiss: self?.dismiss(animated: true) // Dismiss the details sheet. - // Errrr, hmmmmm, do something else here. + // And let the view model handle the rest. } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift index 607b4d7abb..62400f799a 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -7,9 +7,10 @@ import QuickLook -enum TimelineMediaPreviewViewModelAction { +enum TimelineMediaPreviewViewModelAction: Equatable { case loadedMediaFile - case viewInTimeline + case viewInRoomTimeline(TimelineItemIdentifier) + case dismiss } struct TimelineMediaPreviewViewState: BindableState { diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index 11b406be7e..f29a59da4d 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -51,14 +51,16 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { case .menuAction(let action): switch action { case .viewInRoomTimeline: - actionsSubject.send(.viewInTimeline) + actionsSubject.send(.viewInRoomTimeline(state.currentItem.id)) case .redact: state.bindings.isPresentingRedactConfirmation = true default: MXLog.error("Received unexpected action: \(action)") } case .redactConfirmation: - break // Do it here?? + timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: state.currentItem.id, action: .redact)) + state.bindings.isPresentingRedactConfirmation = false + actionsSubject.send(.dismiss) // Will dismiss the details sheet and the QuickLook view. } } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift index 98523013e0..7ef7b9d843 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift @@ -20,7 +20,9 @@ struct MediaEventsTimelineScreenCoordinatorParameters { let userIndicatorController: UserIndicatorControllerProtocol } -enum MediaEventsTimelineScreenCoordinatorAction { } +enum MediaEventsTimelineScreenCoordinatorAction { + case viewInRoomTimeline(TimelineItemIdentifier) +} final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol { private let parameters: MediaEventsTimelineScreenCoordinatorParameters @@ -62,6 +64,15 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol { filesTimelineViewModel: filesTimelineViewModel, mediaProvider: parameters.mediaProvider, userIndicatorController: parameters.userIndicatorController) + + viewModel.actionsPublisher + .sink { [weak self] action in + switch action { + case .viewInRoomTimeline(let itemID): + self?.actionsSubject.send(.viewInRoomTimeline(itemID)) + } + } + .store(in: &cancellables) } func toPresentable() -> AnyView { diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift index c0135fd720..71e9411e68 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift @@ -7,7 +7,9 @@ import Foundation -enum MediaEventsTimelineScreenViewModelAction { } +enum MediaEventsTimelineScreenViewModelAction { + case viewInRoomTimeline(TimelineItemIdentifier) +} enum MediaEventsTimelineScreenMode { case media diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift index fde67ca115..f6fac60781 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift @@ -26,6 +26,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType } } + private var mediaPreviewCancellable: AnyCancellable? + private let actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { actionsSubject.eraseToAnyPublisher() @@ -154,6 +156,21 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType timelineViewModel: activeTimelineViewModel, mediaProvider: mediaProvider, userIndicatorController: userIndicatorController) + + mediaPreviewCancellable = viewModel.actions + .sink { [weak self] action in + guard let self else { return } + switch action { + case .viewInRoomTimeline(let itemID): + state.bindings.mediaPreviewViewModel = nil + actionsSubject.send(.viewInRoomTimeline(itemID)) + case .dismiss: + state.bindings.mediaPreviewViewModel = nil + case .loadedMediaFile: + break // Handled by the preview controller + } + } + state.bindings.mediaPreviewViewModel = viewModel } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift index 43996ebd2f..0df71168ea 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift @@ -121,6 +121,7 @@ struct MediaEventsTimelineScreen: View { ProgressView() .padding() .opacity(context.viewState.isBackPaginating ? 1 : 0) + .scaleEffect(.init(width: 1, height: -1)) // Make sure it spins the right way around 🙃 Rectangle() .frame(height: 1) diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 76bef6d7aa..3456c2a45f 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -870,10 +870,10 @@ private extension RoomInfoProxy { extension TimelineViewModel { static let mock = mock(timelineKind: .live) - static func mock(timelineKind: TimelineKind = .live) -> TimelineViewModel { + static func mock(timelineKind: TimelineKind = .live, timelineController: MockRoomTimelineController? = nil) -> TimelineViewModel { TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), focussedEventID: nil, - timelineController: MockRoomTimelineController(timelineKind: timelineKind), + timelineController: timelineController ?? MockRoomTimelineController(timelineKind: timelineKind), mediaProvider: MediaProviderMock(configuration: .init()), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index a4873858da..922bda39ae 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -113,7 +113,10 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func removeCaption(_ eventOrTransactionID: EventOrTransactionId) async { } - func redact(_ eventOrTransactionID: EventOrTransactionId) async { } + private(set) var redactCalled = false + func redact(_ eventOrTransactionID: EventOrTransactionId) async { + redactCalled = true + } func pin(eventID: String) async { } diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index dade5840e8..65d2ac3ec7 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -16,12 +16,14 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { var viewModel: TimelineMediaPreviewViewModel! var context: TimelineMediaPreviewViewModel.Context { viewModel.context } var mediaProvider: MediaProviderMock! + var timelineController: MockRoomTimelineController! - func testLoadingItem() async throws { + func testLoadingItem() async { // Given a fresh view model. setupViewModel() XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0]) + XCTAssertNotNil(context.viewState.currentItemActions) // When the preview controller sets the current item. await viewModel.updateCurrentItem(context.viewState.previewItems[0]) @@ -29,6 +31,42 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // Then the view model should load the item and update its view state. XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled) XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0]) + XCTAssertNotNil(context.viewState.currentItemActions) + } + + func testViewInRoomTimeline() async throws { + // Given a view model with a loaded item. + await testLoadingItem() + + // When choosing to view the current item in the timeline. + let currentItemID = context.viewState.currentItem.id + let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(currentItemID) } + context.send(viewAction: .menuAction(.viewInRoomTimeline)) + + // Then the action should be sent upwards to make this happen. + try await deferred.fulfill() + } + + func testRedactConfirmation() async throws { + // Given a view model with a loaded item. + await testLoadingItem() + XCTAssertFalse(context.isPresentingRedactConfirmation) + XCTAssertFalse(timelineController.redactCalled) + + // When choosing to redact the current item. + context.send(viewAction: .menuAction(.redact)) + + // Then the confirmation sheet should be presented. + XCTAssertTrue(context.isPresentingRedactConfirmation) + XCTAssertFalse(timelineController.redactCalled) + + // When confirming the redaction. + let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } + context.send(viewAction: .redactConfirmation) + + // Then the item should be redacted and the view should be dismissed. + try await deferred.fulfill() + XCTAssertTrue(timelineController.redactCalled) } // MARK: - Helpers @@ -46,9 +84,13 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { imageInfo: .mockImage, thumbnailInfo: .mockThumbnail)) + timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen)) + timelineController.timelineItems = [item] + mediaProvider = MediaProviderMock(configuration: .init()) viewModel = TimelineMediaPreviewViewModel(initialItem: item, - timelineViewModel: TimelineViewModel.mock, + timelineViewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen), + timelineController: timelineController), mediaProvider: mediaProvider, userIndicatorController: UserIndicatorControllerMock()) }