Add mechanism for opening up a threaded timeline

This commit is contained in:
Stefan Ceriu
2025-04-11 15:41:06 +03:00
committed by Stefan Ceriu
parent 2f938d3cbf
commit 10fb2b5dda
22 changed files with 505 additions and 13 deletions
@@ -302,6 +302,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case (_, .dismissFlow):
return .complete
case (_, .presentThread(let itemID)):
return .thread(itemID: itemID)
case (_, .dismissThread):
return .room
case (.initial, .presentRoomDetails):
return .roomDetails(isRoot: true)
case (.room, .presentRoomDetails):
@@ -462,6 +467,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
case (_, .dismissFlow, .complete):
dismissFlow(animated: animated)
case (.room, .presentThread(let itemID), .thread):
Task { await self.presentThread(itemID: itemID) }
case (.thread, .dismissThread, .room):
break
case (.initial, .presentRoomDetails, .roomDetails(let isRoot)),
(.room, .presentRoomDetails, .roomDetails(let isRoot)),
@@ -618,7 +628,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
presentDeclineAndBlockScreen(userID: userID)
case (.declineAndBlockScreen, .dismissDeclineAndBlockScreen, .joinRoomScreen):
break
// Child flow
case (_, .startChildFlow(let roomID, let via, let entryPoint), .presentingChild):
Task { await self.startChildFlow(for: roomID, via: via, entryPoint: entryPoint) }
@@ -782,6 +792,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle))
case .presentKnockRequestsList:
stateMachine.tryEvent(.presentKnockRequestsListScreen)
case .presentThread(let itemID):
stateMachine.tryEvent(.presentThread(itemID: itemID))
}
}
.store(in: &cancellables)
@@ -789,6 +801,41 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
return coordinator
}
private func presentThread(itemID: TimelineItemIdentifier) async {
showLoadingIndicator()
defer { hideLoadingIndicator() }
let timelineItemFactory = RoomTimelineItemFactory(userID: userSession.clientProxy.userID,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userSession.clientProxy.userID))
guard let eventID = itemID.eventID else {
fatalError("Invalid thread event ID")
}
guard case let .success(timelineController) = await timelineControllerFactory.buildThreadTimelineController(eventID: eventID,
roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider) else {
MXLog.error("Failed presenting media timeline")
return
}
let coordinator = ThreadTimelineScreenCoordinator(parameters: .init(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: MediaPlayerProvider(),
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
appMediator: appMediator,
emojiProvider: emojiProvider,
timelineControllerFactory: timelineControllerFactory,
clientProxy: userSession.clientProxy))
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissThread)
}
}
private func presentJoinRoomScreen(via: [String], animated: Bool) {
let coordinator = JoinRoomScreenCoordinator(parameters: .init(roomID: roomID,
via: via,
@@ -1747,6 +1794,7 @@ private extension RoomFlowCoordinator {
case initial
case joinRoomScreen
case room
case thread(itemID: TimelineItemIdentifier)
case roomDetails(isRoot: Bool)
case roomDetailsEditScreen
case notificationSettings
@@ -1790,6 +1838,9 @@ private extension RoomFlowCoordinator {
case presentRoom(presentationAction: PresentationAction?)
case dismissFlow
case presentThread(itemID: TimelineItemIdentifier)
case dismissThread
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String)
case dismissReportContent
@@ -6438,6 +6438,76 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
return timelineFocusedOnEventEventIDNumberOfEventsReturnValue
}
}
//MARK: - threadTimeline
var threadTimelineEventIDUnderlyingCallsCount = 0
var threadTimelineEventIDCallsCount: Int {
get {
if Thread.isMainThread {
return threadTimelineEventIDUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = threadTimelineEventIDUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
threadTimelineEventIDUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
threadTimelineEventIDUnderlyingCallsCount = newValue
}
}
}
}
var threadTimelineEventIDCalled: Bool {
return threadTimelineEventIDCallsCount > 0
}
var threadTimelineEventIDReceivedEventID: String?
var threadTimelineEventIDReceivedInvocations: [String] = []
var threadTimelineEventIDUnderlyingReturnValue: Result<TimelineProxyProtocol, RoomProxyError>!
var threadTimelineEventIDReturnValue: Result<TimelineProxyProtocol, RoomProxyError>! {
get {
if Thread.isMainThread {
return threadTimelineEventIDUnderlyingReturnValue
} else {
var returnValue: Result<TimelineProxyProtocol, RoomProxyError>? = nil
DispatchQueue.main.sync {
returnValue = threadTimelineEventIDUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
threadTimelineEventIDUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
threadTimelineEventIDUnderlyingReturnValue = newValue
}
}
}
}
var threadTimelineEventIDClosure: ((String) async -> Result<TimelineProxyProtocol, RoomProxyError>)?
func threadTimeline(eventID: String) async -> Result<TimelineProxyProtocol, RoomProxyError> {
threadTimelineEventIDCallsCount += 1
threadTimelineEventIDReceivedEventID = eventID
DispatchQueue.main.async {
self.threadTimelineEventIDReceivedInvocations.append(eventID)
}
if let threadTimelineEventIDClosure = threadTimelineEventIDClosure {
return await threadTimelineEventIDClosure(eventID)
} else {
return threadTimelineEventIDReturnValue
}
}
//MARK: - messageFilteredTimeline
var messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = 0
@@ -14969,6 +15039,76 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck
return buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReturnValue
}
}
//MARK: - buildThreadTimelineController
var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0
var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int {
get {
if Thread.isMainThread {
return buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
}
}
}
}
var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderCalled: Bool {
return buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0
}
var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (eventID: String, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)?
var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(eventID: String, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = []
var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>!
var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReturnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>! {
get {
if Thread.isMainThread {
return buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
} else {
var returnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>? = nil
DispatchQueue.main.sync {
returnValue = buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
}
}
}
}
var buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderClosure: ((String, JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError>)?
func buildThreadTimelineController(eventID: String, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError> {
buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1
buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (eventID: eventID, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)
DispatchQueue.main.async {
self.buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((eventID: eventID, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider))
}
if let buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderClosure = buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderClosure {
return await buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderClosure(eventID, roomProxy, timelineItemFactory, mediaProvider)
} else {
return buildThreadTimelineControllerEventIDRoomProxyTimelineItemFactoryMediaProviderReturnValue
}
}
//MARK: - buildPinnedEventsTimelineController
var buildPinnedEventsTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0
@@ -81,7 +81,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
.displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.composer, .hasScrolled, .viewInRoomTimeline:
.displayThread, .composer, .hasScrolled, .viewInRoomTimeline:
break
}
}
@@ -103,7 +103,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
.displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.composer, .hasScrolled, .viewInRoomTimeline:
.displayThread, .composer, .hasScrolled, .viewInRoomTimeline:
break
}
}
@@ -91,7 +91,7 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
// These other actions will not be handled in this view
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
.displayResolveSendFailure, .composer, .hasScrolled:
.displayResolveSendFailure, .displayThread, .composer, .hasScrolled:
// These actions are not handled in this coordinator
break
}
@@ -44,6 +44,7 @@ enum RoomScreenCoordinatorAction {
case presentPinnedEventsTimeline
case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case presentKnockRequestsList
case presentThread(itemID: TimelineItemIdentifier)
}
final class RoomScreenCoordinator: CoordinatorProtocol {
@@ -144,6 +145,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description))
case .displayResolveSendFailure(let failure, let sendHandle):
actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle))
case .displayThread(let itemID):
actionsSubject.send(.presentThread(itemID: itemID))
case .composer(let action):
composerViewModel.process(timelineAction: action)
case .hasScrolled(direction: let direction):
@@ -0,0 +1,93 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
struct ThreadTimelineScreenCoordinatorParameters {
let roomProxy: JoinedRoomProxyProtocol
let timelineController: TimelineControllerProtocol
let mediaProvider: MediaProviderProtocol
let mediaPlayerProvider: MediaPlayerProviderProtocol
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
let appMediator: AppMediatorProtocol
let emojiProvider: EmojiProviderProtocol
let timelineControllerFactory: TimelineControllerFactoryProtocol
let clientProxy: ClientProxyProtocol
}
enum ThreadTimelineScreenCoordinatorAction {
case dismiss
case displayUser(userID: String)
case presentLocationViewer(geoURI: GeoURI, description: String?)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayRoomScreenWithFocussedPin(eventID: String)
}
final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
private let parameters: ThreadTimelineScreenCoordinatorParameters
private let viewModel: ThreadTimelineScreenViewModelProtocol
private let timelineViewModel: TimelineViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<ThreadTimelineScreenCoordinatorAction, Never> = .init()
var actions: AnyPublisher<ThreadTimelineScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: ThreadTimelineScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = ThreadTimelineScreenViewModel()
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
timelineController: parameters.timelineController,
mediaProvider: parameters.mediaProvider,
mediaPlayerProvider: parameters.mediaPlayerProvider,
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: parameters.appMediator,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: parameters.emojiProvider,
timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy)
}
func start() {
timelineViewModel.actions.sink { [weak self] action in
MXLog.info("Coordinator: received timeline view model action: \(action)")
guard let self else { return }
switch action {
case .displaySenderDetails(let userID):
actionsSubject.send(.displayUser(userID: userID))
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
case .displayLocation(_, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description))
case .viewInRoomTimeline(let eventID):
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID))
// These other actions will not be handled in this view
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaPreview,
.displayMediaUploadPreviewScreen, .displayResolveSendFailure, .displayThread, .composer, .hasScrolled:
// These actions are not handled in this coordinator
break
}
}
.store(in: &cancellables)
}
func stop() {
viewModel.stop()
}
func toPresentable() -> AnyView {
AnyView(ThreadTimelineScreen(context: viewModel.context, timelineContext: timelineViewModel.context))
}
}
@@ -0,0 +1,21 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Foundation
enum ThreadTimelineScreenViewModelAction { }
struct ThreadTimelineScreenViewState: BindableState {
var bindings = ThreadTimelineScreenViewStateBindings()
}
struct ThreadTimelineScreenViewStateBindings {
/// The view model used to present a QuickLook media preview.
var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
}
enum ThreadTimelineScreenViewAction { }
@@ -0,0 +1,31 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
typealias ThreadTimelineScreenViewModelType = StateStoreViewModel<ThreadTimelineScreenViewState, ThreadTimelineScreenViewAction>
class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTimelineScreenViewModelProtocol {
private let actionsSubject: PassthroughSubject<ThreadTimelineScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<ThreadTimelineScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init() {
super.init(initialViewState: ThreadTimelineScreenViewState())
}
// MARK: - Public
override func process(viewAction: ThreadTimelineScreenViewAction) { }
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewViewModel = nil
}
}
@@ -0,0 +1,16 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
@MainActor
protocol ThreadTimelineScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<ThreadTimelineScreenViewModelAction, Never> { get }
var context: ThreadTimelineScreenViewModelType.Context { get }
func stop()
}
@@ -0,0 +1,51 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct ThreadTimelineScreen: View {
@ObservedObject var context: ThreadTimelineScreenViewModel.Context
@ObservedObject var timelineContext: TimelineViewModel.Context
var body: some View {
content
.navigationTitle("Thread")
.navigationBarTitleDisplayMode(.inline)
.background(.compound.bgCanvasDefault)
.interactiveDismissDisabled()
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
.sheet(item: $timelineContext.manageMemberViewModel) {
ManageRoomMemberSheetView(context: $0.context)
}
.sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) }
.sheet(item: $timelineContext.actionMenuInfo) { info in
let actions = TimelineItemMenuActionProvider(timelineItem: info.item,
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isDM: timelineContext.viewState.isDirectOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
timelineKind: timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider)
.makeActions()
if let actions {
TimelineItemMenu(item: info.item, actions: actions)
.environmentObject(timelineContext)
}
}
}
@ViewBuilder
private var content: some View {
TimelineView()
.id(timelineContext.viewState.roomID)
.environmentObject(timelineContext)
.environment(\.focussedEventID, timelineContext.viewState.timelineState.focussedEvent?.eventID)
}
}
@@ -554,7 +554,7 @@ class TimelineInteractionHandler {
case .pinned:
newTimelineFocus = .pinned
newTimelinePresentation = .pinnedEventsScreen
case .media:
case .media, .thread:
break // We don't need to create a new timeline as it is already filtered.
}
@@ -24,6 +24,7 @@ enum TimelineViewModelAction {
case displayMediaPreview(TimelineMediaPreviewViewModel)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case displayThread(itemID: TimelineItemIdentifier)
case composer(action: TimelineComposerAction)
case hasScrolled(direction: ScrollDirection)
case viewInRoomTimeline(eventID: String)
@@ -191,8 +191,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
displayReactionSummary(for: itemID, selectedKey: key)
case .displayReadReceipts(let itemID):
displayReadReceipts(for: itemID)
case .displayThread:
break
case .displayThread(let itemID):
actionsSubject.send(.displayThread(itemID: itemID))
case .handlePasteOrDrop(let provider):
timelineInteractionHandler.handlePasteOrDrop(provider)
case .handlePollAction(let pollAction):
@@ -111,7 +111,7 @@ struct TimelineItemMenuActionProvider {
actions.append(.save)
actions = actions.filter(\.canAppearInMediaDetails)
secondaryActions = secondaryActions.filter(\.canAppearInMediaDetails)
case .live, .detached:
case .live, .detached, .thread:
break // viewInRoomTimeline is the only non-room item and was added conditionally.
}
@@ -120,7 +120,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.onTapGesture { }
}
if let threadSummary = timelineItem.properties.threadSummary {
if context.viewState.timelineKind != .thread, let threadSummary = timelineItem.properties.threadSummary {
TimelineThreadSummaryView(threadSummary: threadSummary) {
context.send(viewAction: .displayThread(itemID: timelineItem.id))
}
@@ -177,13 +177,13 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder
var contentWithReply: some View {
TimelineBubbleLayout(spacing: 8) {
if timelineItem.properties.isThreaded {
if context.viewState.timelineKind != .thread, timelineItem.properties.isThreaded {
ThreadDecorator()
.padding(.leading, 4)
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
}
if let replyDetails = timelineItem.properties.replyDetails {
if shouldShowReplyDetails, let replyDetails = timelineItem.properties.replyDetails {
// The rendered reply bubble with a greedy width. The custom layout prevents
// the infinite width from increasing the overall width of the view.
@@ -212,6 +212,10 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
}
}
private var shouldShowReplyDetails: Bool {
!timelineItem.properties.isThreaded || (timelineItem.properties.isThreaded && context.viewState.timelineKind != .thread)
}
private var messageBubbleTopPadding: CGFloat {
guard timelineItem.isOutgoing || isDirectOneToOneRoom else { return 0 }
return timelineGroupStyle == .single || timelineGroupStyle == .first ? 8 : 0
@@ -190,6 +190,24 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
}
}
func threadTimeline(eventID: String) async -> Result<TimelineProxyProtocol, RoomProxyError> {
do {
let sdkTimeline = try await room.timelineWithConfiguration(configuration: .init(focus: .thread(rootEventId: eventID, numEvents: 20),
filter: .all,
internalIdPrefix: UUID().uuidString,
dateDividerMode: .daily,
trackReadReceipts: true))
let timeline = TimelineProxy(timeline: sdkTimeline, kind: .thread)
await timeline.subscribeForUpdates()
return .success(timeline)
} catch {
MXLog.error("Unexpected error: \(error)")
return .failure(.sdkError(error))
}
}
func messageFilteredTimeline(focus: TimelineFocus,
allowedMessageTypes: [TimelineAllowedMessageType],
presentation: TimelineKind.MediaPresentation) async -> Result<any TimelineProxyProtocol, RoomProxyError> {
@@ -197,6 +215,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
let rustFocus: MatrixRustSDK.TimelineFocus = switch focus {
case .live: .live
case .eventID(let eventID): .event(eventId: eventID, numContextEvents: 100)
case .thread(let eventID): .thread(rootEventId: eventID, numEvents: 20)
case .pinned: .pinnedEvents(maxEventsToLoad: 100, maxConcurrentRequests: 10)
}
@@ -83,6 +83,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
func timelineFocusedOnEvent(eventID: String, numberOfEvents: UInt16) async -> Result<TimelineProxyProtocol, RoomProxyError>
func threadTimeline(eventID: String) async -> Result<TimelineProxyProtocol, RoomProxyError>
func messageFilteredTimeline(focus: TimelineFocus,
allowedMessageTypes: [TimelineAllowedMessageType],
presentation: TimelineKind.MediaPresentation) async -> Result<TimelineProxyProtocol, RoomProxyError>
@@ -21,6 +21,23 @@ struct TimelineControllerFactory: TimelineControllerFactoryProtocol {
appSettings: ServiceLocator.shared.settings)
}
func buildThreadTimelineController(eventID: String,
roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError> {
switch await roomProxy.threadTimeline(eventID: eventID) {
case .success(let timelineProxy):
return .success(TimelineController(roomProxy: roomProxy,
timelineProxy: timelineProxy,
initialFocussedEventID: nil,
timelineItemFactory: timelineItemFactory,
mediaProvider: mediaProvider,
appSettings: ServiceLocator.shared.settings))
case .failure(let error):
return .failure(.roomProxyError(error))
}
}
func buildPinnedEventsTimelineController(roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> TimelineControllerProtocol? {
@@ -19,6 +19,11 @@ protocol TimelineControllerFactoryProtocol {
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) -> TimelineControllerProtocol
func buildThreadTimelineController(eventID: String,
roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError>
func buildPinnedEventsTimelineController(roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> TimelineControllerProtocol?
@@ -97,7 +97,7 @@ final class TimelineProxy: TimelineProxyProtocol {
switch kind {
case .live:
return await paginateBackwardsOnLive(requestSize: requestSize)
case .detached, .media:
case .detached, .media, .thread:
return await focussedPaginate(.backwards, requestSize: requestSize)
case .pinned:
return .success(())
@@ -596,7 +596,7 @@ final class TimelineProxy: TimelineProxyProtocol {
MXLog.error("Failed to subscribe to back pagination status with error: \(error)")
}
forwardPaginationStatusSubject.send(.timelineEndReached)
case .detached:
case .detached, .thread:
// Detached timelines don't support observation, set the initial state ourself.
backPaginationStatusSubject.send(.idle)
forwardPaginationStatusSubject.send(.idle)
@@ -13,6 +13,7 @@ enum TimelineKind: Equatable {
case live
case detached
case pinned
case thread
enum MediaPresentation { case roomScreenLive, roomScreenDetached, pinnedEventsScreen, mediaFilesScreen }
case media(MediaPresentation)
@@ -21,6 +22,7 @@ enum TimelineKind: Equatable {
enum TimelineFocus {
case live
case eventID(String)
case thread(eventID: String)
case pinned
}