Implemented message forwarding for media previews and media timelines (#4579)

* implemented message forwarding for media previews and media timelines

* updated tests

* pr suggestion

* fix tests

* fix tests
This commit is contained in:
Mauro
2025-10-07 14:34:14 +02:00
committed by GitHub
parent f67908015e
commit d005243e31
42 changed files with 166 additions and 50 deletions

View File

@@ -10,6 +10,7 @@ import Foundation
enum MediaEventsTimelineFlowCoordinatorAction {
case viewInRoomTimeline(TimelineItemIdentifier)
case displayMessageForwarding(MessageForwardingItem)
case finished
}
@@ -91,10 +92,13 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.displayMessageForwarding(forwardingItem))
case .viewInRoomTimeline(let itemID):
self?.navigationStackCoordinator.pop(animated: false)
self?.actionsSubject.send(.viewInRoomTimeline(itemID))
navigationStackCoordinator.pop(animated: false)
actionsSubject.send(.viewInRoomTimeline(itemID))
}
}
.store(in: &cancellables)

View File

@@ -1592,12 +1592,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .viewInRoomTimeline(let itemID):
guard let eventID = itemID.eventID else {
MXLog.error("Unable to present room timeline for event \(itemID)")
return
}
stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: false))),
userInfo: EventUserInfo(animated: false)) // No animation so the timeline visible when the preview animates away.
case .finished:
stateMachine.tryEvent(.dismissMediaEventsTimeline)
case .displayMessageForwarding(let forwardingItem):
stateMachine.tryEvent(.presentMessageForwarding(forwardingItem: forwardingItem))
}
}
.store(in: &cancellables)

View File

@@ -198,6 +198,8 @@ extension RoomFlowCoordinator {
case (.room, .presentMessageForwarding(let forwardingItem)):
return .messageForwarding(forwardingItem: forwardingItem, previousState: fromState)
case (.mediaEventsTimeline, .presentMessageForwarding(forwardingItem: let forwardingItem)):
return .messageForwarding(forwardingItem: forwardingItem, previousState: fromState)
case (.room, .presentMapNavigator(_)):
return .mapNavigator(previousState: fromState)

View File

@@ -10,6 +10,7 @@ import SwiftUI
enum TimelineMediaPreviewViewModelAction: Equatable {
case viewInRoomTimeline(TimelineItemIdentifier)
case displayMessageForwarding(MessageForwardingItem)
case dismiss
}

View File

@@ -11,6 +11,8 @@ import Foundation
typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaPreviewViewState, TimelineMediaPreviewViewAction>
class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
static let displayMessageForwardingDelay: TimeInterval = 1.0
let instanceID = UUID()
private let timelineViewModel: TimelineViewModelProtocol
@@ -86,6 +88,8 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
Task { await saveCurrentItem() }
case .redact:
state.bindings.redactConfirmationItem = item
case .forward(let itemID):
Task { await forwardItem(itemID: itemID) }
default:
MXLog.error("Received unexpected action: \(action)")
}
@@ -96,6 +100,12 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
}
}
private func forwardItem(itemID: TimelineItemIdentifier) async {
guard let forwardingItem = await timelineViewModel.makeForwardingItem(for: itemID) else { return }
state.previewControllerDriver.send(.dismissDetailsSheet)
actionsSubject.send(.displayMessageForwarding(forwardingItem))
}
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
if case let .media(item) = previewItem {
item.downloadError = nil // Clear any existing error.

View File

@@ -25,6 +25,7 @@ struct MediaEventsTimelineScreenCoordinatorParameters {
enum MediaEventsTimelineScreenCoordinatorAction {
case viewInRoomTimeline(TimelineItemIdentifier)
case displayMessageForwarding(MessageForwardingItem)
}
final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
@@ -73,9 +74,12 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
viewModel.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.displayMessageForwarding(forwardingItem))
case .viewInRoomTimeline(let itemID):
self?.actionsSubject.send(.viewInRoomTimeline(itemID))
actionsSubject.send(.viewInRoomTimeline(itemID))
}
}
.store(in: &cancellables)

View File

@@ -8,6 +8,7 @@
import SwiftUI
enum MediaEventsTimelineScreenViewModelAction {
case displayMessageForwarding(MessageForwardingItem)
case viewInRoomTimeline(TimelineItemIdentifier)
}

View File

@@ -155,6 +155,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
sheetModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .displayMessageForwarding(let forwardingItem):
displayMessageForwarding(forwardingItem: forwardingItem)
case .viewInRoomTimeline(let itemID):
actionsSubject.send(.viewInRoomTimeline(itemID))
case .dismiss:
@@ -222,6 +224,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .displayMessageForwarding(let forwardingItem):
displayMessageForwarding(forwardingItem: forwardingItem)
case .viewInRoomTimeline(let itemID):
state.bindings.mediaPreviewViewModel = nil
actionsSubject.send(.viewInRoomTimeline(itemID))
@@ -241,4 +245,13 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
date.formatted(.dateTime.month(.wide).year())
}
}
private func displayMessageForwarding(forwardingItem: MessageForwardingItem) {
state.bindings.mediaPreviewViewModel = nil
state.bindings.mediaPreviewSheetViewModel = nil
// We need a small delay because we need to wait for the presented sheet to be fully dismissed.
DispatchQueue.main.asyncAfter(deadline: .now() + TimelineMediaPreviewViewModel.displayMessageForwardingDelay) {
self.actionsSubject.send(.displayMessageForwarding(forwardingItem))
}
}
}

View File

@@ -65,6 +65,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
case .viewInRoomTimeline(let itemID):
guard let eventID = itemID.eventID else { fatalError("A pinned event must have an event ID.") }
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID))

View File

@@ -9,6 +9,7 @@ import Foundation
enum PinnedEventsTimelineScreenViewModelAction {
case viewInRoomTimeline(itemID: TimelineItemIdentifier)
case displayMessageForwarding(MessageForwardingItem)
case dismiss
}

View File

@@ -42,11 +42,18 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) {
mediaPreviewViewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .displayMessageForwarding(let forwardingItem):
state.bindings.mediaPreviewViewModel = nil
// We need a small delay because we need to wait for the media preview to be fully dismissed.
DispatchQueue.main.asyncAfter(deadline: .now() + TimelineMediaPreviewViewModel.displayMessageForwardingDelay) {
self.actionsSubject.send(.displayMessageForwarding(forwardingItem))
}
case .viewInRoomTimeline(let itemID):
self?.actionsSubject.send(.viewInRoomTimeline(itemID: itemID))
actionsSubject.send(.viewInRoomTimeline(itemID: itemID))
case .dismiss:
self?.state.bindings.mediaPreviewViewModel = nil
state.bindings.mediaPreviewViewModel = nil
}
}
.store(in: &cancellables)

View File

@@ -181,6 +181,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentKnockRequestsList)
case .displayRoom(let roomID, let via):
actionsSubject.send(.presentRoom(roomID: roomID, via: via))
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
}
}
.store(in: &cancellables)

View File

@@ -16,6 +16,7 @@ enum RoomScreenViewModelAction: Equatable {
case removeComposerFocus
case displayKnockRequests
case displayRoom(roomID: String, via: [String])
case displayMessageForwarding(MessageForwardingItem)
}
enum RoomScreenViewAction {

View File

@@ -136,11 +136,18 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) {
mediaPreviewViewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .viewInRoomTimeline:
fatalError("viewInRoomTimeline should not be visible on a room preview.")
case .dismiss:
self?.state.bindings.mediaPreviewViewModel = nil
state.bindings.mediaPreviewViewModel = nil
case .displayMessageForwarding(let forwardingItem):
state.bindings.mediaPreviewViewModel = nil
// We need a small delay because we need to wait for the media preview to be fully dismissed.
DispatchQueue.main.asyncAfter(deadline: .now() + TimelineMediaPreviewViewModel.displayMessageForwardingDelay) {
self.actionsSubject.send(.displayMessageForwarding(forwardingItem))
}
case .viewInRoomTimeline:
fatalError("\(action) should not be visible on a room preview.")
}
}
.store(in: &cancellables)

View File

@@ -88,6 +88,16 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
}
func start() {
viewModel.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
}
}
.store(in: &cancellables)
timelineViewModel.actions
.sink { [weak self] action in
guard let self else { return }

View File

@@ -7,7 +7,9 @@
import Foundation
enum ThreadTimelineScreenViewModelAction { }
enum ThreadTimelineScreenViewModelAction {
case displayMessageForwarding(MessageForwardingItem)
}
struct ThreadTimelineScreenViewState: BindableState {
var roomTitle: String

View File

@@ -61,11 +61,18 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) {
mediaPreviewViewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .viewInRoomTimeline:
fatalError("viewInRoomTimeline should not be visible on a thread preview.")
fatalError("\(action) should not be visible on a thread preview.")
case .displayMessageForwarding(let forwardingItem):
state.bindings.mediaPreviewViewModel = nil
// We need a small delay because we need to wait for the media preview to be fully dismissed.
DispatchQueue.main.asyncAfter(deadline: .now() + TimelineMediaPreviewViewModel.displayMessageForwardingDelay) {
self.actionsSubject.send(.displayMessageForwarding(forwardingItem))
}
case .dismiss:
self?.state.bindings.mediaPreviewViewModel = nil
state.bindings.mediaPreviewViewModel = nil
}
}
.store(in: &cancellables)

View File

@@ -270,6 +270,11 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
}
}
func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem? {
guard let content = await timelineController.messageEventContent(for: itemID) else { return nil }
return .init(id: itemID, roomID: roomProxy.id, content: content)
}
// MARK: - Private
private func handleTappedOnSenderDetails(sender: TimelineItemSender) {
@@ -899,8 +904,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
// MARK: - Message forwarding
private func forwardMessage(itemID: TimelineItemIdentifier) async {
guard let content = await timelineController.messageEventContent(for: itemID) else { return }
actionsSubject.send(.displayMessageForwarding(forwardingItem: .init(id: itemID, roomID: roomProxy.id, content: content)))
guard let forwardingItem = await makeForwardingItem(for: itemID) else { return }
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
}
// MARK: Pills

View File

@@ -17,4 +17,7 @@ protocol TimelineViewModelProtocol {
func process(composerAction: ComposerToolbarViewModelAction)
/// Updates the timeline to show and highlight the item with the corresponding event ID.
func focusOnEvent(eventID: String) async
/// Handles getting the content to forward an item given its item ID.
func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem?
}

View File

@@ -131,7 +131,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
var canAppearInMediaDetails: Bool {
switch self {
case .viewInRoomTimeline, .share, .save, .redact:
case .viewInRoomTimeline, .share, .save, .redact, .forward:
true
default:
false

View File

@@ -323,12 +323,27 @@ private extension View {
struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview {
static let viewModel: TimelineViewModel = {
ServiceLocator.shared.settings.threadsEnabled = true
return TimelineViewModel.mock
let appSettings = AppSettings()
appSettings.threadsEnabled = true
let roomProxy = JoinedRoomProxyMock(.init())
return TimelineViewModel(roomProxy: roomProxy,
focussedEventID: nil,
timelineController: MockTimelineController(),
userSession: UserSessionMock(.init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: appSettings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
}()
static let viewModelWithPins: TimelineViewModel = {
ServiceLocator.shared.settings.threadsEnabled = true
let appSettings = AppSettings()
appSettings.threadsEnabled = true
let roomProxy = JoinedRoomProxyMock(.init(name: "Preview Room", pinnedEventIDs: ["pinned"]))
return TimelineViewModel(roomProxy: roomProxy,
@@ -338,7 +353,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
appSettings: appSettings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),

View File

@@ -15,6 +15,10 @@ class AnalyticsSettingsScreenViewModelTests: XCTestCase {
private var viewModel: AnalyticsSettingsScreenViewModelProtocol!
private var context: AnalyticsSettingsScreenViewModelType.Context!
override func setUp() {
AppSettings.resetAllSettings()
}
override func tearDown() {
AppSettings.resetAllSettings()
}