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:
Stefan Ceriu
2026-03-23 17:55:50 +02:00
committed by Stefan Ceriu
parent d27b6697a9
commit ba810116a0
16 changed files with 413 additions and 8 deletions

View File

@@ -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() }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()))
}
}