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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
|
||||
enum TimelineMediaPreviewViewModelAction: Equatable {
|
||||
case viewInRoomTimeline(TimelineItemIdentifier)
|
||||
case displayMessageForwarding(MessageForwardingItem)
|
||||
case dismiss
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import SwiftUI
|
||||
|
||||
enum MediaEventsTimelineScreenViewModelAction {
|
||||
case displayMessageForwarding(MessageForwardingItem)
|
||||
case viewInRoomTimeline(TimelineItemIdentifier)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -9,6 +9,7 @@ import Foundation
|
||||
|
||||
enum PinnedEventsTimelineScreenViewModelAction {
|
||||
case viewInRoomTimeline(itemID: TimelineItemIdentifier)
|
||||
case displayMessageForwarding(MessageForwardingItem)
|
||||
case dismiss
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,6 +16,7 @@ enum RoomScreenViewModelAction: Equatable {
|
||||
case removeComposerFocus
|
||||
case displayKnockRequests
|
||||
case displayRoom(roomID: String, via: [String])
|
||||
case displayMessageForwarding(MessageForwardingItem)
|
||||
}
|
||||
|
||||
enum RoomScreenViewAction {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ThreadTimelineScreenViewModelAction { }
|
||||
enum ThreadTimelineScreenViewModelAction {
|
||||
case displayMessageForwarding(MessageForwardingItem)
|
||||
}
|
||||
|
||||
struct ThreadTimelineScreenViewState: BindableState {
|
||||
var roomTitle: String
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user