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 displayUser(userID: String)
case forwardedMessageToRoom(roomID: String)
case displayRoomScreenWithFocussedPin(eventID: String)
case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?)
}
class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
@@ -87,8 +87,8 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
presentMapNavigator(geoURI: geoURI, description: description, timelineController: timelineController)
case .displayMessageForwarding(let forwardingItem):
presentMessageForwarding(with: forwardingItem)
case .displayRoomScreenWithFocussedPin(let eventID):
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID))
case .displayRoomScreenWithFocussedPin(let eventID, let threadRootEventID):
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID))
}
}
.store(in: &cancellables)
@@ -165,10 +165,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
case .thread(let roomID, let threadRootEventID, let focusEventID):
Task {
let focusEvent: FocusEvent? = if let focusEventID {
.init(eventID: focusEventID, shouldSetPin: false)
} else {
nil
}
await handleRoomRoute(roomID: roomID,
via: [],
presentationAction: .thread(rootEventID: threadRootEventID,
focusEventID: focusEventID),
focusEvent: focusEvent),
animated: animated)
}
case .event(let eventID, let roomID, let via):
@@ -304,7 +309,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) {
case .success(let event):
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 {
stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated))
}
@@ -570,9 +578,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .share(.text(_, let text)):
roomScreenCoordinator?.shareText(text)
case .thread(let rootEventID, let focusEventID):
roomScreenCoordinator?.focusOnEvent(.init(eventID: rootEventID, shouldSetPin: false))
stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID))
case .thread(let rootEventID, let focusEvent):
if let focusEvent, let roomScreenCoordinator {
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:
break
}
@@ -607,8 +621,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .share(.mediaFiles(_, let mediaFiles)):
stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .thread(let rootEventID, let focusEventID):
stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID))
case .thread(let rootEventID, let focusEvent):
stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEvent?.eventID))
case .share(.text), .eventFocus:
break // These are both handled in the coordinator's init.
case .none:
@@ -693,11 +707,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
sendHandle: sendHandle))
case .presentKnockRequestsList:
stateMachine.tryEvent(.presentKnockRequestsListScreen)
case .presentThread(let itemID):
guard let threadRootEventID = itemID.eventID else {
fatalError("A thread root has always an eventID")
}
stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: nil))
case .presentThread(let threadRootEventID, let focussedEventID):
stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: focussedEventID))
case .presentRoom(let roomID, let via):
stateMachine.tryEvent(.startChildFlow(roomID: roomID,
via: via,
@@ -1478,9 +1489,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .forwardedMessageToRoom(let roomID):
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room))
case .displayRoomScreenWithFocussedPin(let eventID):
case .displayRoomScreenWithFocussedPin(let eventID, let threadRootEventID):
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)
@@ -25,18 +25,18 @@ extension RoomFlowCoordinator {
enum PresentationAction: Hashable {
case eventFocus(FocusEvent)
case share(ShareExtensionPayload)
case thread(rootEventID: String, focusEventID: String?)
case thread(rootEventID: String, focusEvent: FocusEvent?)
var focusedEvent: FocusEvent? {
switch self {
case .eventFocus(let 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,
// we will focus the thread root event id, and not the event id itself
// which will be done at the thread presentation level
if focusEventID != nil {
.init(eventID: rootEventID, shouldSetPin: false)
if let focusEvent {
.init(eventID: rootEventID, shouldSetPin: focusEvent.shouldSetPin)
} else {
nil
}
@@ -28,11 +28,10 @@ enum PinnedEventsTimelineScreenCoordinatorAction {
case displayUser(userID: String)
case presentLocationViewer(geoURI: GeoURI, description: String?)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayRoomScreenWithFocussedPin(eventID: String)
case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?)
}
final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
private let parameters: PinnedEventsTimelineScreenCoordinatorParameters
private let viewModel: PinnedEventsTimelineScreenViewModelProtocol
private let timelineViewModel: TimelineViewModelProtocol
@@ -44,9 +43,10 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
}
init(parameters: PinnedEventsTimelineScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = PinnedEventsTimelineScreenViewModel(analyticsService: parameters.analytics)
viewModel = PinnedEventsTimelineScreenViewModel(roomProxy: parameters.roomProxy,
userIndicatorController: parameters.userIndicatorController,
appSettings: parameters.appSettings,
analyticsService: parameters.analytics)
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
timelineController: parameters.timelineController,
userSession: parameters.userSession,
@@ -68,9 +68,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
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))
case .viewInRoomTimeline(let eventID, let threadRootEventID):
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID))
case .dismiss:
self.actionsSubject.send(.dismiss)
}
@@ -90,8 +89,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
viewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocation(_, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description))
case .viewInRoomTimeline(let eventID):
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID))
case .viewInRoomTimeline(let eventID, let threadRootEventID):
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID))
// These other actions will not be handled in this view
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
@@ -9,7 +9,7 @@
import Foundation
enum PinnedEventsTimelineScreenViewModelAction {
case viewInRoomTimeline(itemID: TimelineItemIdentifier)
case viewInRoomTimeline(eventID: String, threadRootEventID: String?)
case displayMessageForwarding(MessageForwardingItem)
case dismiss
}
@@ -12,14 +12,23 @@ import SwiftUI
typealias PinnedEventsTimelineScreenViewModelType = StateStoreViewModel<PinnedEventsTimelineScreenViewState, PinnedEventsTimelineScreenViewAction>
class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelType, PinnedEventsTimelineScreenViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let actionsSubject: PassthroughSubject<PinnedEventsTimelineScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<PinnedEventsTimelineScreenViewModelAction, Never> {
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
super.init(initialViewState: PinnedEventsTimelineScreenViewState())
}
@@ -52,7 +61,10 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy
self.actionsSubject.send(.displayMessageForwarding(forwardingItem))
}
case .viewInRoomTimeline(let itemID):
actionsSubject.send(.viewInRoomTimeline(itemID: itemID))
guard let eventID = itemID.eventID else {
return
}
Task { await self.viewInRoomTimeline(eventID: eventID) }
case .dismiss:
state.bindings.mediaPreviewViewModel = nil
}
@@ -61,4 +73,18 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy
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
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 = {
let timelineController = MockTimelineController(timelineKind: .pinned)
timelineController.timelineItems = []
@@ -47,7 +47,7 @@ enum RoomScreenCoordinatorAction {
case presentPinnedEventsTimeline
case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case presentKnockRequestsList
case presentThread(itemID: TimelineItemIdentifier)
case presentThread(threadRootEventID: String, focussedEventID: String?)
case presentRoom(roomID: String, via: [String])
}
@@ -142,7 +142,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
case .displayResolveSendFailure(let failure, let sendHandle):
actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle))
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):
composerViewModel.process(timelineAction: action)
case .hasScrolled(direction: let direction):
@@ -184,6 +187,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoom(roomID: roomID, via: via))
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
case .displayThread(let threadRootEventID, let focussedEventID):
actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID))
}
}
.store(in: &cancellables)
@@ -201,6 +206,11 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
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) {
composerViewModel.process(timelineAction: .setMode(mode: .default)) // Make sure we're not e.g. replying.
composerViewModel.process(timelineAction: .setText(plainText: string, htmlText: nil))
@@ -11,6 +11,7 @@ import OrderedCollections
enum RoomScreenViewModelAction: Equatable {
case focusEvent(eventID: String)
case displayThread(threadRootEventID: String, focussedEventID: String)
case displayPinnedEventsTimeline
case displayRoomDetails
case displayCall
@@ -85,11 +85,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
override func process(viewAction: RoomScreenViewAction) {
switch viewAction {
case .tappedPinnedEventsBanner:
analyticsService.trackInteraction(name: .PinnedMessageBannerClick)
if let eventID = state.pinnedEventsBannerState.selectedPinnedEventID {
actionsSubject.send(.focusEvent(eventID: eventID))
}
state.pinnedEventsBannerState.previousPin()
handleTappedPinnedEventsBanner()
case .viewAllPins:
analyticsService.trackInteraction(name: .PinnedMessageBannerViewAllButton)
actionsSubject.send(.displayPinnedEventsTimeline)
@@ -402,6 +398,27 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
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
private static let loadingIndicatorIdentifier = "\(RoomScreenViewModel.self)-Loading"
@@ -28,7 +28,7 @@ enum TimelineViewModelAction {
case displayThread(itemID: TimelineItemIdentifier)
case composer(action: TimelineComposerAction)
case hasScrolled(direction: ScrollDirection)
case viewInRoomTimeline(eventID: String)
case viewInRoomTimeline(eventID: String, threadRootEventID: String?)
case displayRoom(roomID: String, via: [String])
case displayMediaDetails(item: EventBasedMessageTimelineItemProtocol)
}
@@ -495,7 +495,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
case .showDebugInfo(let debugInfo):
state.bindings.debugInfo = debugInfo
case .viewInRoomTimeline(let eventID):
actionsSubject.send(.viewInRoomTimeline(eventID: eventID))
Task { await self.viewInRoomTimeline(eventID: eventID) }
case .displayThread(let itemID):
actionsSubject.send(.displayThread(itemID: itemID))
}
@@ -503,6 +503,20 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
.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() {
appSettings.$sharePresence
.weakAssign(to: \.state.showReadReceipts, on: self)
@@ -22,6 +22,7 @@ class RoomScreenViewModelTests: XCTestCase {
override func tearDown() {
viewModel = nil
AppSettings.resetAllSettings()
}
func testPinnedEventsBanner() async throws {
@@ -109,6 +110,7 @@ class RoomScreenViewModelTests: XCTestCase {
func testPinnedEventsBannerSelection() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init())
roomProxyMock.loadOrFetchEventDetailsForReturnValue = .success(TimelineEventSDKMock())
// setup a way to inject the mock of the pinned events timeline
let pinnedTimelineMock = TimelineProxyMock()
let pinnedTimelineItemProviderMock = TimelineItemProviderMock()
@@ -160,6 +162,67 @@ class RoomScreenViewModelTests: XCTestCase {
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 {
var configuration = JoinedRoomProxyMockConfiguration(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false)
let roomProxyMock = JoinedRoomProxyMock(configuration)