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
@@ -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