Add a new RoomThreadListScreen and hook it up to the RoomThreadListService
It will automatically paginate to fill the screen and update the list as updates come in.
This commit is contained in:
committed by
Stefan Ceriu
parent
d27b6697a9
commit
ba810116a0
@@ -720,6 +720,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
sendHandle: sendHandle))
|
||||
case .presentKnockRequestsList:
|
||||
stateMachine.tryEvent(.presentKnockRequestsListScreen)
|
||||
case .presentThreadList:
|
||||
Task {
|
||||
await self.presentThreadList(animated: true)
|
||||
}
|
||||
case .presentThread(let threadRootEventID, let focussedEventID):
|
||||
stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: focussedEventID))
|
||||
case .presentRoom(let roomID, let via):
|
||||
@@ -733,6 +737,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
return coordinator
|
||||
}
|
||||
|
||||
private func presentThreadList(animated: Bool) async {
|
||||
let coordinator = await RoomThreadListScreenCoordinator(parameters: .init(threadListServiceProxy: roomProxy.threadListService(),
|
||||
mediaProvider: userSession.mediaProvider))
|
||||
|
||||
coordinator.actionsPublisher.sink { [weak self] _ in }.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in }
|
||||
}
|
||||
|
||||
private func presentThread(threadRootEventID: String, focusEventID: String?, animated: Bool) async {
|
||||
showLoadingIndicator()
|
||||
defer { hideLoadingIndicator() }
|
||||
|
||||
@@ -83,6 +83,7 @@ enum UserAvatarSizeOnScreen {
|
||||
case mediaPreviewDetails
|
||||
case sendInviteConfirmation
|
||||
case sessionVerification
|
||||
case threadList
|
||||
case threadSummary
|
||||
case map
|
||||
|
||||
@@ -106,7 +107,7 @@ enum UserAvatarSizeOnScreen {
|
||||
case .roomDetails:
|
||||
44
|
||||
case .inviteUsers, .knockingUserList, .sessionVerification,
|
||||
.settings:
|
||||
.settings, .threadList:
|
||||
52
|
||||
case .roomChangeRoles:
|
||||
56
|
||||
|
||||
@@ -155,6 +155,7 @@ enum TestablePreviewsDictionary {
|
||||
"RoomScreenFooterView_Previews" : RoomScreenFooterView_Previews.self,
|
||||
"RoomScreen_Previews" : RoomScreen_Previews.self,
|
||||
"RoomSelectionScreen_Previews" : RoomSelectionScreen_Previews.self,
|
||||
"RoomThreadListScreen_Previews" : RoomThreadListScreen_Previews.self,
|
||||
"SFNumberedListView_Previews" : SFNumberedListView_Previews.self,
|
||||
"SecureBackupKeyBackupScreen_Previews" : SecureBackupKeyBackupScreen_Previews.self,
|
||||
"SecureBackupLogoutConfirmationScreen_Previews" : SecureBackupLogoutConfirmationScreen_Previews.self,
|
||||
|
||||
@@ -47,6 +47,7 @@ enum RoomScreenCoordinatorAction {
|
||||
case presentPinnedEventsTimeline
|
||||
case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
|
||||
case presentKnockRequestsList
|
||||
case presentThreadList
|
||||
case presentThread(threadRootEventID: String, focussedEventID: String?)
|
||||
case presentRoom(roomID: String, via: [String])
|
||||
}
|
||||
@@ -187,6 +188,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentRoom(roomID: roomID, via: via))
|
||||
case .displayMessageForwarding(let forwardingItem):
|
||||
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
|
||||
case .displayThreadList:
|
||||
actionsSubject.send(.presentThreadList)
|
||||
case .displayThread(let threadRootEventID, let focussedEventID):
|
||||
actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import OrderedCollections
|
||||
|
||||
enum RoomScreenViewModelAction: Equatable {
|
||||
case focusEvent(eventID: String)
|
||||
case displayThreadList
|
||||
case displayThread(threadRootEventID: String, focussedEventID: String)
|
||||
case displayPinnedEventsTimeline
|
||||
case displayRoomDetails
|
||||
@@ -32,6 +33,7 @@ enum RoomScreenViewAction {
|
||||
case dismissKnockRequests
|
||||
case viewKnockRequests
|
||||
case displaySuccessorRoom
|
||||
case displayThreadList
|
||||
}
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
|
||||
@@ -119,6 +119,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
guard let successorID = roomProxy.infoPublisher.value.successor?.roomId else { return }
|
||||
let serverNames = roomProxy.knownServerNames(maxCount: 50) // Limit to the same number used by ClientProxy.resolveRoomAlias(_:)
|
||||
actionsSubject.send(.displayRoom(roomID: successorID, via: Array(serverNames)))
|
||||
case .displayThreadList:
|
||||
actionsSubject.send(.displayThreadList)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,17 @@ struct RoomScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if #available(iOS 26, *) {
|
||||
ToolbarSpacer(.fixed, placement: .primaryAction)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
context.send(viewAction: .displayThreadList)
|
||||
} label: {
|
||||
CompoundIcon(\.threads)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
// periphery:ignore:all - this is just a roomThreadList remove this comment once generating the final file
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct RoomThreadListScreenCoordinatorParameters {
|
||||
let threadListServiceProxy: RoomThreadListServiceProxyProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
}
|
||||
|
||||
enum RoomThreadListScreenCoordinatorAction { }
|
||||
|
||||
final class RoomThreadListScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: RoomThreadListScreenCoordinatorParameters
|
||||
private let viewModel: RoomThreadListScreenViewModelProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<RoomThreadListScreenCoordinatorAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<RoomThreadListScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: RoomThreadListScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = RoomThreadListScreenViewModel(threadListServiceProxy: parameters.threadListServiceProxy,
|
||||
mediaProvider: parameters.mediaProvider)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actionsPublisher.sink { [weak self] action in
|
||||
MXLog.info("Coordinator: received view model action: \(action)")
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(RoomThreadListScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations 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 RoomThreadListScreenViewModelAction { }
|
||||
|
||||
struct RoomThreadListScreenViewState: BindableState {
|
||||
var items = [RoomThreadListItem]()
|
||||
|
||||
var isPaginating = false
|
||||
|
||||
var bindings: RoomThreadListScreenViewStateBindings
|
||||
}
|
||||
|
||||
struct RoomThreadListScreenViewStateBindings { }
|
||||
|
||||
enum RoomThreadListScreenViewAction {
|
||||
case oldestItemDidAppear
|
||||
case oldestItemDidDisappear
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations 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 RoomThreadListScreenViewModelType = StateStoreViewModelV2<RoomThreadListScreenViewState, RoomThreadListScreenViewAction>
|
||||
|
||||
class RoomThreadListScreenViewModel: RoomThreadListScreenViewModelType, RoomThreadListScreenViewModelProtocol {
|
||||
private let threadListServiceProxy: RoomThreadListServiceProxyProtocol
|
||||
|
||||
private var isOldestItemVisible = false
|
||||
|
||||
private let actionsSubject: PassthroughSubject<RoomThreadListScreenViewModelAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<RoomThreadListScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(threadListServiceProxy: RoomThreadListServiceProxyProtocol, mediaProvider: MediaProviderProtocol) {
|
||||
self.threadListServiceProxy = threadListServiceProxy
|
||||
|
||||
super.init(initialViewState: .init(bindings: .init()), mediaProvider: mediaProvider)
|
||||
|
||||
updateItems(self.threadListServiceProxy.itemsPublisher.value)
|
||||
|
||||
threadListServiceProxy.itemsPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] items in
|
||||
self?.updateItems(items)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
threadListServiceProxy.paginationStatePublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] paginationState in
|
||||
guard let self else { return }
|
||||
state.isPaginating = paginationState == .loading
|
||||
Task { await self.paginateIfNecessary(paginationState: paginationState) }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: RoomThreadListScreenViewAction) {
|
||||
MXLog.info("View model: received view action: \(viewAction)")
|
||||
|
||||
switch viewAction {
|
||||
case .oldestItemDidAppear:
|
||||
isOldestItemVisible = true
|
||||
|
||||
Task {
|
||||
await paginateIfNecessary(paginationState: threadListServiceProxy.paginationStatePublisher.value)
|
||||
}
|
||||
case .oldestItemDidDisappear:
|
||||
isOldestItemVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func paginateIfNecessary(paginationState: RoomThreadListPaginationState) async {
|
||||
if isOldestItemVisible, case .idle(endReached: false) = paginationState {
|
||||
_ = await threadListServiceProxy.paginate()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateItems(_ items: [RoomThreadListItem]) {
|
||||
state.items = items
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations 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 RoomThreadListScreenViewModelProtocol {
|
||||
var actionsPublisher: AnyPublisher<RoomThreadListScreenViewModelAction, Never> { get }
|
||||
var context: RoomThreadListScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations 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 RoomThreadListScreen: View {
|
||||
@Bindable var context: RoomThreadListScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(context.viewState.items) { item in
|
||||
RoomThreadListCell(item: item, mediaProvider: context.mediaProvider)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
footer
|
||||
}
|
||||
}
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.navigationTitle(L10n.commonThreads)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
// Needs to be wrapped in a LazyStack otherwise appearance calls don't trigger
|
||||
LazyVStack(spacing: 0) {
|
||||
ProgressView()
|
||||
.padding()
|
||||
.opacity(context.viewState.isPaginating ? 1 : 0)
|
||||
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.foregroundStyle(.compound.bgCanvasDefault)
|
||||
.onAppear {
|
||||
context.send(viewAction: .oldestItemDidAppear)
|
||||
}
|
||||
.onDisappear {
|
||||
context.send(viewAction: .oldestItemDidDisappear)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RoomThreadListCell: View {
|
||||
let item: RoomThreadListItem
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
LoadableAvatarImage(url: item.rootMessageDetails.sender.avatarURL,
|
||||
name: item.rootMessageDetails.sender.displayName,
|
||||
contentID: item.rootMessageDetails.sender.id,
|
||||
avatarSize: .user(on: .threadList),
|
||||
mediaProvider: mediaProvider)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
creatorDetails
|
||||
Spacer()
|
||||
timestamp
|
||||
}
|
||||
|
||||
rootMessageDetails
|
||||
latestMessageDetails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var creatorDetails: some View {
|
||||
Text(item.rootMessageDetails.sender.disambiguatedDisplayName ?? item.rootMessageDetails.sender.id)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var rootMessageDetails: some View {
|
||||
if let message = item.rootMessageDetails.message {
|
||||
Text(message)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var latestMessageDetails: some View {
|
||||
if let latestMessageDetails = item.latestMessageDetails {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Label {
|
||||
Text("\(item.numberOfReplies)")
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
} icon: {
|
||||
CompoundIcon(\.threads, size: .small, relativeTo: .compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
}
|
||||
.labelStyle(.custom(spacing: 4, alignment: .center, iconLayout: .trailing))
|
||||
|
||||
LoadableAvatarImage(url: latestMessageDetails.sender.avatarURL,
|
||||
name: latestMessageDetails.sender.displayName,
|
||||
contentID: latestMessageDetails.sender.id,
|
||||
avatarSize: .user(on: .threadSummary),
|
||||
mediaProvider: mediaProvider)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
if let message = latestMessageDetails.message {
|
||||
Text(message)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var timestamp: some View {
|
||||
if let latestMessageDetails = item.latestMessageDetails {
|
||||
Text(latestMessageDetails.timestamp.formattedMinimal())
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
} else {
|
||||
Text(item.rootMessageDetails.timestamp.formattedTime())
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct RoomThreadListScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = makeViewModel()
|
||||
|
||||
static var previews: some View {
|
||||
RoomThreadListScreen(context: viewModel.context)
|
||||
}
|
||||
|
||||
static func makeViewModel() -> RoomThreadListScreenViewModel {
|
||||
RoomThreadListScreenViewModel(threadListServiceProxy: RoomThreadListServiceProxyMock(.init()),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user