Handle threaded pinned events (#4704)

* implemented focussing a threaded event through the banner tap

* view in timeline implementation for threaded events

* added a test and improved the existing one

* pr suggestions

* put the logic for fetching the event in the view models
This commit is contained in:
Mauro
2025-11-10 12:36:42 +01:00
committed by GitHub
parent 3e922ba80e
commit 2ccbe52bc6
13 changed files with 194 additions and 45 deletions
@@ -13,7 +13,7 @@ enum PinnedEventsTimelineFlowCoordinatorAction {
case finished case finished
case displayUser(userID: String) case displayUser(userID: String)
case forwardedMessageToRoom(roomID: String) case forwardedMessageToRoom(roomID: String)
case displayRoomScreenWithFocussedPin(eventID: String) case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?)
} }
class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
@@ -87,8 +87,8 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
presentMapNavigator(geoURI: geoURI, description: description, timelineController: timelineController) presentMapNavigator(geoURI: geoURI, description: description, timelineController: timelineController)
case .displayMessageForwarding(let forwardingItem): case .displayMessageForwarding(let forwardingItem):
presentMessageForwarding(with: forwardingItem) presentMessageForwarding(with: forwardingItem)
case .displayRoomScreenWithFocussedPin(let eventID): case .displayRoomScreenWithFocussedPin(let eventID, let threadRootEventID):
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID)) actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -165,10 +165,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} }
case .thread(let roomID, let threadRootEventID, let focusEventID): case .thread(let roomID, let threadRootEventID, let focusEventID):
Task { Task {
let focusEvent: FocusEvent? = if let focusEventID {
.init(eventID: focusEventID, shouldSetPin: false)
} else {
nil
}
await handleRoomRoute(roomID: roomID, await handleRoomRoute(roomID: roomID,
via: [], via: [],
presentationAction: .thread(rootEventID: threadRootEventID, presentationAction: .thread(rootEventID: threadRootEventID,
focusEventID: focusEventID), focusEvent: focusEvent),
animated: animated) animated: animated)
} }
case .event(let eventID, let roomID, let via): case .event(let eventID, let roomID, let via):
@@ -304,7 +309,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) { switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) {
case .success(let event): case .success(let event):
if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() { if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() {
stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID, focusEventID: focusEvent.eventID)), userInfo: EventUserInfo(animated: animated)) stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID,
focusEvent: .init(eventID: focusEvent.eventID,
shouldSetPin: focusEvent.shouldSetPin))),
userInfo: EventUserInfo(animated: animated))
} else { } else {
stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated)) stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated))
} }
@@ -570,9 +578,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .share(.text(_, let text)): case .share(.text(_, let text)):
roomScreenCoordinator?.shareText(text) roomScreenCoordinator?.shareText(text)
case .thread(let rootEventID, let focusEventID): case .thread(let rootEventID, let focusEvent):
roomScreenCoordinator?.focusOnEvent(.init(eventID: rootEventID, shouldSetPin: false)) if let focusEvent, let roomScreenCoordinator {
stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID)) roomScreenCoordinator.focusOnEvent(.init(eventID: rootEventID, shouldSetPin: false))
// Since the root is not the message we want to show in the pinned banner, but the actual event
if focusEvent.shouldSetPin {
roomScreenCoordinator.setSelectedPin(eventID: focusEvent.eventID)
}
}
stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEvent?.eventID))
case .none: case .none:
break break
} }
@@ -607,8 +621,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .share(.mediaFiles(_, let mediaFiles)): case .share(.mediaFiles(_, let mediaFiles)):
stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)), stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .thread(let rootEventID, let focusEventID): case .thread(let rootEventID, let focusEvent):
stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID)) stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEvent?.eventID))
case .share(.text), .eventFocus: case .share(.text), .eventFocus:
break // These are both handled in the coordinator's init. break // These are both handled in the coordinator's init.
case .none: case .none:
@@ -693,11 +707,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
sendHandle: sendHandle)) sendHandle: sendHandle))
case .presentKnockRequestsList: case .presentKnockRequestsList:
stateMachine.tryEvent(.presentKnockRequestsListScreen) stateMachine.tryEvent(.presentKnockRequestsListScreen)
case .presentThread(let itemID): case .presentThread(let threadRootEventID, let focussedEventID):
guard let threadRootEventID = itemID.eventID else { stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: focussedEventID))
fatalError("A thread root has always an eventID")
}
stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: nil))
case .presentRoom(let roomID, let via): case .presentRoom(let roomID, let via):
stateMachine.tryEvent(.startChildFlow(roomID: roomID, stateMachine.tryEvent(.startChildFlow(roomID: roomID,
via: via, via: via,
@@ -1478,9 +1489,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .forwardedMessageToRoom(let roomID): case .forwardedMessageToRoom(let roomID):
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room)) stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room))
case .displayRoomScreenWithFocussedPin(let eventID): case .displayRoomScreenWithFocussedPin(let eventID, let threadRootEventID):
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: true)))) if let threadRootEventID {
stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID, focusEvent: .init(eventID: eventID, shouldSetPin: true))))
} else {
stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: true))))
}
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -25,18 +25,18 @@ extension RoomFlowCoordinator {
enum PresentationAction: Hashable { enum PresentationAction: Hashable {
case eventFocus(FocusEvent) case eventFocus(FocusEvent)
case share(ShareExtensionPayload) case share(ShareExtensionPayload)
case thread(rootEventID: String, focusEventID: String?) case thread(rootEventID: String, focusEvent: FocusEvent?)
var focusedEvent: FocusEvent? { var focusedEvent: FocusEvent? {
switch self { switch self {
case .eventFocus(let focusEvent): case .eventFocus(let focusEvent):
focusEvent focusEvent
case .thread(let rootEventID, let focusEventID): case .thread(let rootEventID, let focusEvent):
// Since this enum is for the room and not the threaded timeline, // Since this enum is for the room and not the threaded timeline,
// we will focus the thread root event id, and not the event id itself // we will focus the thread root event id, and not the event id itself
// which will be done at the thread presentation level // which will be done at the thread presentation level
if focusEventID != nil { if let focusEvent {
.init(eventID: rootEventID, shouldSetPin: false) .init(eventID: rootEventID, shouldSetPin: focusEvent.shouldSetPin)
} else { } else {
nil nil
} }
@@ -28,11 +28,10 @@ enum PinnedEventsTimelineScreenCoordinatorAction {
case displayUser(userID: String) case displayUser(userID: String)
case presentLocationViewer(geoURI: GeoURI, description: String?) case presentLocationViewer(geoURI: GeoURI, description: String?)
case displayMessageForwarding(forwardingItem: MessageForwardingItem) case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayRoomScreenWithFocussedPin(eventID: String) case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?)
} }
final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
private let parameters: PinnedEventsTimelineScreenCoordinatorParameters
private let viewModel: PinnedEventsTimelineScreenViewModelProtocol private let viewModel: PinnedEventsTimelineScreenViewModelProtocol
private let timelineViewModel: TimelineViewModelProtocol private let timelineViewModel: TimelineViewModelProtocol
@@ -44,9 +43,10 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
} }
init(parameters: PinnedEventsTimelineScreenCoordinatorParameters) { init(parameters: PinnedEventsTimelineScreenCoordinatorParameters) {
self.parameters = parameters viewModel = PinnedEventsTimelineScreenViewModel(roomProxy: parameters.roomProxy,
userIndicatorController: parameters.userIndicatorController,
viewModel = PinnedEventsTimelineScreenViewModel(analyticsService: parameters.analytics) appSettings: parameters.appSettings,
analyticsService: parameters.analytics)
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
timelineController: parameters.timelineController, timelineController: parameters.timelineController,
userSession: parameters.userSession, userSession: parameters.userSession,
@@ -68,9 +68,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
switch action { switch action {
case .displayMessageForwarding(let forwardingItem): case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem)) actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
case .viewInRoomTimeline(let itemID): case .viewInRoomTimeline(let eventID, let threadRootEventID):
guard let eventID = itemID.eventID else { fatalError("A pinned event must have an event ID.") } actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID))
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID))
case .dismiss: case .dismiss:
self.actionsSubject.send(.dismiss) self.actionsSubject.send(.dismiss)
} }
@@ -90,8 +89,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
viewModel.displayMediaPreview(mediaPreviewViewModel) viewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocation(_, let geoURI, let description): case .displayLocation(_, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description)) actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description))
case .viewInRoomTimeline(let eventID): case .viewInRoomTimeline(let eventID, let threadRootEventID):
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID)) actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID))
// These other actions will not be handled in this view // These other actions will not be handled in this view
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen, .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
@@ -9,7 +9,7 @@
import Foundation import Foundation
enum PinnedEventsTimelineScreenViewModelAction { enum PinnedEventsTimelineScreenViewModelAction {
case viewInRoomTimeline(itemID: TimelineItemIdentifier) case viewInRoomTimeline(eventID: String, threadRootEventID: String?)
case displayMessageForwarding(MessageForwardingItem) case displayMessageForwarding(MessageForwardingItem)
case dismiss case dismiss
} }
@@ -12,14 +12,23 @@ import SwiftUI
typealias PinnedEventsTimelineScreenViewModelType = StateStoreViewModel<PinnedEventsTimelineScreenViewState, PinnedEventsTimelineScreenViewAction> typealias PinnedEventsTimelineScreenViewModelType = StateStoreViewModel<PinnedEventsTimelineScreenViewState, PinnedEventsTimelineScreenViewAction>
class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelType, PinnedEventsTimelineScreenViewModelProtocol { class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelType, PinnedEventsTimelineScreenViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService private let analyticsService: AnalyticsService
private let actionsSubject: PassthroughSubject<PinnedEventsTimelineScreenViewModelAction, Never> = .init() private let actionsSubject: PassthroughSubject<PinnedEventsTimelineScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<PinnedEventsTimelineScreenViewModelAction, Never> { var actionsPublisher: AnyPublisher<PinnedEventsTimelineScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
init(analyticsService: AnalyticsService) { init(roomProxy: JoinedRoomProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
appSettings: AppSettings,
analyticsService: AnalyticsService) {
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
self.appSettings = appSettings
self.analyticsService = analyticsService self.analyticsService = analyticsService
super.init(initialViewState: PinnedEventsTimelineScreenViewState()) super.init(initialViewState: PinnedEventsTimelineScreenViewState())
} }
@@ -52,7 +61,10 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy
self.actionsSubject.send(.displayMessageForwarding(forwardingItem)) self.actionsSubject.send(.displayMessageForwarding(forwardingItem))
} }
case .viewInRoomTimeline(let itemID): case .viewInRoomTimeline(let itemID):
actionsSubject.send(.viewInRoomTimeline(itemID: itemID)) guard let eventID = itemID.eventID else {
return
}
Task { await self.viewInRoomTimeline(eventID: eventID) }
case .dismiss: case .dismiss:
state.bindings.mediaPreviewViewModel = nil state.bindings.mediaPreviewViewModel = nil
} }
@@ -61,4 +73,18 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy
state.bindings.mediaPreviewViewModel = mediaPreviewViewModel state.bindings.mediaPreviewViewModel = mediaPreviewViewModel
} }
private func viewInRoomTimeline(eventID: String) async {
switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
case .success(let event):
let threadRootEventID: String? = if appSettings.threadsEnabled {
event.threadRootEventId()
} else {
nil
}
actionsSubject.send(.viewInRoomTimeline(eventID: eventID, threadRootEventID: threadRootEventID))
case .failure:
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
} }
@@ -66,7 +66,11 @@ struct PinnedEventsTimelineScreen: View {
// MARK: - Previews // MARK: - Previews
struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = PinnedEventsTimelineScreenViewModel(analyticsService: ServiceLocator.shared.analytics) static let viewModel = PinnedEventsTimelineScreenViewModel(roomProxy: JoinedRoomProxyMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
appSettings: AppSettings(),
analyticsService: ServiceLocator.shared.analytics)
static let emptyTimelineViewModel: TimelineViewModel = { static let emptyTimelineViewModel: TimelineViewModel = {
let timelineController = MockTimelineController(timelineKind: .pinned) let timelineController = MockTimelineController(timelineKind: .pinned)
timelineController.timelineItems = [] timelineController.timelineItems = []
@@ -47,7 +47,7 @@ enum RoomScreenCoordinatorAction {
case presentPinnedEventsTimeline case presentPinnedEventsTimeline
case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case presentKnockRequestsList case presentKnockRequestsList
case presentThread(itemID: TimelineItemIdentifier) case presentThread(threadRootEventID: String, focussedEventID: String?)
case presentRoom(roomID: String, via: [String]) case presentRoom(roomID: String, via: [String])
} }
@@ -142,7 +142,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
case .displayResolveSendFailure(let failure, let sendHandle): case .displayResolveSendFailure(let failure, let sendHandle):
actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle)) actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle))
case .displayThread(let itemID): case .displayThread(let itemID):
actionsSubject.send(.presentThread(itemID: itemID)) guard let eventID = itemID.eventID else {
fatalError("A thread root has always an eventID")
}
actionsSubject.send(.presentThread(threadRootEventID: eventID, focussedEventID: nil))
case .composer(let action): case .composer(let action):
composerViewModel.process(timelineAction: action) composerViewModel.process(timelineAction: action)
case .hasScrolled(direction: let direction): case .hasScrolled(direction: let direction):
@@ -184,6 +187,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoom(roomID: roomID, via: via)) actionsSubject.send(.presentRoom(roomID: roomID, via: via))
case .displayMessageForwarding(let forwardingItem): case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem)) actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
case .displayThread(let threadRootEventID, let focussedEventID):
actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -201,6 +206,11 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
Task { await timelineViewModel.focusOnEvent(eventID: eventID) } Task { await timelineViewModel.focusOnEvent(eventID: eventID) }
} }
/// Sets the banner to selection to a specific event ID, even if not visible in the main timeline (like a threaded event).
func setSelectedPin(eventID: String) {
roomViewModel.setSelectedPinnedEventID(eventID)
}
func shareText(_ string: String) { func shareText(_ string: String) {
composerViewModel.process(timelineAction: .setMode(mode: .default)) // Make sure we're not e.g. replying. composerViewModel.process(timelineAction: .setMode(mode: .default)) // Make sure we're not e.g. replying.
composerViewModel.process(timelineAction: .setText(plainText: string, htmlText: nil)) composerViewModel.process(timelineAction: .setText(plainText: string, htmlText: nil))
@@ -11,6 +11,7 @@ import OrderedCollections
enum RoomScreenViewModelAction: Equatable { enum RoomScreenViewModelAction: Equatable {
case focusEvent(eventID: String) case focusEvent(eventID: String)
case displayThread(threadRootEventID: String, focussedEventID: String)
case displayPinnedEventsTimeline case displayPinnedEventsTimeline
case displayRoomDetails case displayRoomDetails
case displayCall case displayCall
@@ -85,11 +85,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
override func process(viewAction: RoomScreenViewAction) { override func process(viewAction: RoomScreenViewAction) {
switch viewAction { switch viewAction {
case .tappedPinnedEventsBanner: case .tappedPinnedEventsBanner:
analyticsService.trackInteraction(name: .PinnedMessageBannerClick) handleTappedPinnedEventsBanner()
if let eventID = state.pinnedEventsBannerState.selectedPinnedEventID {
actionsSubject.send(.focusEvent(eventID: eventID))
}
state.pinnedEventsBannerState.previousPin()
case .viewAllPins: case .viewAllPins:
analyticsService.trackInteraction(name: .PinnedMessageBannerViewAllButton) analyticsService.trackInteraction(name: .PinnedMessageBannerViewAllButton)
actionsSubject.send(.displayPinnedEventsTimeline) actionsSubject.send(.displayPinnedEventsTimeline)
@@ -402,6 +398,27 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.handledEventIDs.subtract(failedIDs) state.handledEventIDs.subtract(failedIDs)
} }
private func handleTappedPinnedEventsBanner() {
analyticsService.trackInteraction(name: .PinnedMessageBannerClick)
if let eventID = state.pinnedEventsBannerState.selectedPinnedEventID {
Task {
switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
case .success(let event):
if appSettings.threadsEnabled,
let threadRootEventID = event.threadRootEventId() {
actionsSubject.send(.focusEvent(eventID: threadRootEventID))
actionsSubject.send(.displayThread(threadRootEventID: threadRootEventID, focussedEventID: eventID))
} else {
actionsSubject.send(.focusEvent(eventID: eventID))
}
case .failure:
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
}
state.pinnedEventsBannerState.previousPin()
}
// MARK: Loading indicators // MARK: Loading indicators
private static let loadingIndicatorIdentifier = "\(RoomScreenViewModel.self)-Loading" private static let loadingIndicatorIdentifier = "\(RoomScreenViewModel.self)-Loading"
@@ -28,7 +28,7 @@ enum TimelineViewModelAction {
case displayThread(itemID: TimelineItemIdentifier) case displayThread(itemID: TimelineItemIdentifier)
case composer(action: TimelineComposerAction) case composer(action: TimelineComposerAction)
case hasScrolled(direction: ScrollDirection) case hasScrolled(direction: ScrollDirection)
case viewInRoomTimeline(eventID: String) case viewInRoomTimeline(eventID: String, threadRootEventID: String?)
case displayRoom(roomID: String, via: [String]) case displayRoom(roomID: String, via: [String])
case displayMediaDetails(item: EventBasedMessageTimelineItemProtocol) case displayMediaDetails(item: EventBasedMessageTimelineItemProtocol)
} }
@@ -495,7 +495,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
case .showDebugInfo(let debugInfo): case .showDebugInfo(let debugInfo):
state.bindings.debugInfo = debugInfo state.bindings.debugInfo = debugInfo
case .viewInRoomTimeline(let eventID): case .viewInRoomTimeline(let eventID):
actionsSubject.send(.viewInRoomTimeline(eventID: eventID)) Task { await self.viewInRoomTimeline(eventID: eventID) }
case .displayThread(let itemID): case .displayThread(let itemID):
actionsSubject.send(.displayThread(itemID: itemID)) actionsSubject.send(.displayThread(itemID: itemID))
} }
@@ -503,6 +503,20 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
.store(in: &cancellables) .store(in: &cancellables)
} }
func viewInRoomTimeline(eventID: String) async {
switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
case .success(let event):
let threadRootEventID: String? = if appSettings.threadsEnabled {
event.threadRootEventId()
} else {
nil
}
actionsSubject.send(.viewInRoomTimeline(eventID: eventID, threadRootEventID: threadRootEventID))
case .failure:
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
private func setupAppSettingsSubscriptions() { private func setupAppSettingsSubscriptions() {
appSettings.$sharePresence appSettings.$sharePresence
.weakAssign(to: \.state.showReadReceipts, on: self) .weakAssign(to: \.state.showReadReceipts, on: self)
@@ -22,6 +22,7 @@ class RoomScreenViewModelTests: XCTestCase {
override func tearDown() { override func tearDown() {
viewModel = nil viewModel = nil
AppSettings.resetAllSettings()
} }
func testPinnedEventsBanner() async throws { func testPinnedEventsBanner() async throws {
@@ -109,6 +110,7 @@ class RoomScreenViewModelTests: XCTestCase {
func testPinnedEventsBannerSelection() async throws { func testPinnedEventsBannerSelection() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init()) let roomProxyMock = JoinedRoomProxyMock(.init())
roomProxyMock.loadOrFetchEventDetailsForReturnValue = .success(TimelineEventSDKMock())
// setup a way to inject the mock of the pinned events timeline // setup a way to inject the mock of the pinned events timeline
let pinnedTimelineMock = TimelineProxyMock() let pinnedTimelineMock = TimelineProxyMock()
let pinnedTimelineItemProviderMock = TimelineItemProviderMock() let pinnedTimelineItemProviderMock = TimelineItemProviderMock()
@@ -160,6 +162,67 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill() try await deferred.fulfill()
} }
func testPinnedEventsBannerThreadedSelection() async throws {
ServiceLocator.shared.settings.threadsEnabled = true
let roomProxyMock = JoinedRoomProxyMock(.init())
let eventMock = TimelineEventSDKMock()
eventMock.threadRootEventIdReturnValue = "thread"
roomProxyMock.loadOrFetchEventDetailsForReturnValue = .success(eventMock)
// setup a way to inject the mock of the pinned events timeline
let pinnedTimelineMock = TimelineProxyMock()
let pinnedTimelineItemProviderMock = TimelineItemProviderMock()
pinnedTimelineMock.timelineItemProvider = pinnedTimelineItemProviderMock
pinnedTimelineItemProviderMock.underlyingUpdatePublisher = Empty<([TimelineItemProxy], PaginationState), Never>().eraseToAnyPublisher()
pinnedTimelineItemProviderMock.itemProxies = [.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test1")), uniqueID: .init("1"))),
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test2")), uniqueID: .init("2"))),
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test3")), uniqueID: .init("3")))]
roomProxyMock.pinnedEventsTimelineReturnValue = .success(pinnedTimelineMock)
let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()),
roomProxy: roomProxyMock,
initialSelectedPinnedEventID: "test1",
ongoingCallRoomIDPublisher: .init(.init(nil)),
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks(),
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
self.viewModel = viewModel
// check if the banner is now in a loaded state and is showing the counter
var deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
!viewState.pinnedEventsBannerState.isLoading
}
try await deferred.fulfill()
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 3)
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
// And that is actually displaying the `initialSelectedPinEventID` which is gthe first one in the list
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 0)
// check if the banner scrolls when tapping the previous pin
deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
viewState.pinnedEventsBannerState.selectedPinnedIndex == 2
}
let deferredAction1 = deferFulfillment(viewModel.actions) { action in
if case let .focusEvent(threadRootEventID) = action {
return threadRootEventID == "thread"
}
return false
}
let deferredAction2 = deferFulfillment(viewModel.actions) { action in
if case let .displayThread(threadRootEventID, focussedEventID) = action {
return threadRootEventID == "thread" && focussedEventID == "test1"
}
return false
}
viewModel.context.send(viewAction: .tappedPinnedEventsBanner)
try await deferred.fulfill()
try await deferredAction1.fulfill()
try await deferredAction2.fulfill()
}
func testRoomInfoUpdate() async throws { func testRoomInfoUpdate() async throws {
var configuration = JoinedRoomProxyMockConfiguration(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false) var configuration = JoinedRoomProxyMockConfiguration(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false)
let roomProxyMock = JoinedRoomProxyMock(configuration) let roomProxyMock = JoinedRoomProxyMock(configuration)