mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Add mechanism for opening up a threaded timeline
This commit is contained in:
committed by
Stefan Ceriu
parent
2f938d3cbf
commit
10fb2b5dda
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
+16
@@ -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):
|
||||
|
||||
+1
-1
@@ -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? {
|
||||
|
||||
+5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user