Introduce the RoomThreadListServiceProxy and expose it from the RoomProxy

Also update the sdk and app mocks and add a simple `RoomThreadListServiceProxyMock`

# Conflicts:
#	ElementX.xcodeproj/project.pbxproj
#	ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
#	SDKMocks/Sources/Generated/SDKGeneratedMocks.swift
#	project.yml
This commit is contained in:
Stefan Ceriu
2026-03-23 17:43:38 +02:00
committed by Stefan Ceriu
parent 823e2afa97
commit e97ed6ec5c
10 changed files with 377 additions and 24 deletions
@@ -6930,6 +6930,70 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
return threadTimelineEventIDReturnValue
}
}
//MARK: - threadListService
var threadListServiceUnderlyingCallsCount = 0
var threadListServiceCallsCount: Int {
get {
if Thread.isMainThread {
return threadListServiceUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = threadListServiceUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
threadListServiceUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
threadListServiceUnderlyingCallsCount = newValue
}
}
}
}
var threadListServiceCalled: Bool {
return threadListServiceCallsCount > 0
}
var threadListServiceUnderlyingReturnValue: RoomThreadListServiceProxyProtocol!
var threadListServiceReturnValue: RoomThreadListServiceProxyProtocol! {
get {
if Thread.isMainThread {
return threadListServiceUnderlyingReturnValue
} else {
var returnValue: RoomThreadListServiceProxyProtocol? = nil
DispatchQueue.main.sync {
returnValue = threadListServiceUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
threadListServiceUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
threadListServiceUnderlyingReturnValue = newValue
}
}
}
}
var threadListServiceClosure: (() async -> RoomThreadListServiceProxyProtocol)?
func threadListService() async -> RoomThreadListServiceProxyProtocol {
threadListServiceCallsCount += 1
if let threadListServiceClosure = threadListServiceClosure {
return await threadListServiceClosure()
} else {
return threadListServiceReturnValue
}
}
//MARK: - loadOrFetchEventDetails
var loadOrFetchEventDetailsForUnderlyingCallsCount = 0
@@ -15957,6 +16021,83 @@ class RoomSummaryProviderMock: RoomSummaryProviderProtocol, @unchecked Sendable
setRoomListClosure?(roomList)
}
}
class RoomThreadListServiceProxyMock: RoomThreadListServiceProxyProtocol, @unchecked Sendable {
var itemsPublisher: CurrentValuePublisher<[RoomThreadListItem], Never> {
get { return underlyingItemsPublisher }
set(value) { underlyingItemsPublisher = value }
}
var underlyingItemsPublisher: CurrentValuePublisher<[RoomThreadListItem], Never>!
var paginationStatePublisher: CurrentValuePublisher<RoomThreadListPaginationState, Never> {
get { return underlyingPaginationStatePublisher }
set(value) { underlyingPaginationStatePublisher = value }
}
var underlyingPaginationStatePublisher: CurrentValuePublisher<RoomThreadListPaginationState, Never>!
//MARK: - paginate
var paginateUnderlyingCallsCount = 0
var paginateCallsCount: Int {
get {
if Thread.isMainThread {
return paginateUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = paginateUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
paginateUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
paginateUnderlyingCallsCount = newValue
}
}
}
}
var paginateCalled: Bool {
return paginateCallsCount > 0
}
var paginateUnderlyingReturnValue: Result<Void, RoomProxyError>!
var paginateReturnValue: Result<Void, RoomProxyError>! {
get {
if Thread.isMainThread {
return paginateUnderlyingReturnValue
} else {
var returnValue: Result<Void, RoomProxyError>? = nil
DispatchQueue.main.sync {
returnValue = paginateUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
paginateUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
paginateUnderlyingReturnValue = newValue
}
}
}
}
var paginateClosure: (() async -> Result<Void, RoomProxyError>)?
func paginate() async -> Result<Void, RoomProxyError> {
paginateCallsCount += 1
if let paginateClosure = paginateClosure {
return await paginateClosure()
} else {
return paginateReturnValue
}
}
}
class SecureBackupControllerMock: SecureBackupControllerProtocol, @unchecked Sendable {
var recoveryState: CurrentValuePublisher<SecureBackupRecoveryState, Never> {
get { return underlyingRecoveryState }
@@ -0,0 +1,43 @@
//
// Copyright 2026 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
struct RoomThreadListProxyMockConfiguration {
var items: [RoomThreadListItem] = .mocks
}
extension RoomThreadListServiceProxyMock {
convenience init(_ configuration: RoomThreadListProxyMockConfiguration) {
self.init()
itemsPublisher = CurrentValueSubject(configuration.items).asCurrentValuePublisher()
paginateReturnValue = .success(())
}
}
extension Array where Element == RoomThreadListItem {
static let mocks: [RoomThreadListItem] = [
.init(id: "1",
rootMessageDetails: .init(sender: .init(id: "@alice:matrix.org", displayName: "Alice", avatarURL: .mockMXCUserAvatar),
timestamp: .distantPast,
message: .init("Ping")),
latestMessageDetails: .init(sender: .init(id: "@bob:matrix.org"),
timestamp: .distantFuture,
message: .init("Pong")),
numberOfReplies: 5),
.init(id: "2",
rootMessageDetails: .init(sender: .init(id: "@alice:matrix.org", displayName: "Alice", avatarURL: .mockMXCUserAvatar),
timestamp: .distantPast,
message: .init("Can we schedule a meeting for next week?")),
latestMessageDetails: .init(sender: .init(id: "@bob:matrix.org"),
timestamp: .distantFuture,
message: .init("Looking forward to our next steps!")),
numberOfReplies: 10)
]
}
+12
View File
@@ -197,6 +197,18 @@ extension SDKListener: KnockRequestsListener where T == [KnockRequest] {
}
}
extension SDKListener: ThreadListEntriesListener where T == [ThreadListUpdate] {
func onUpdate(diff: [ThreadListUpdate]) {
onUpdateClosure(diff)
}
}
extension SDKListener: ThreadListPaginationStateListener where T == ThreadListPaginationState {
func onUpdate(state: ThreadListPaginationState) {
onUpdateClosure(state)
}
}
// MARK: TimelineProxy
extension SDKListener: PaginationStatusListener where T == PaginationStatus {
@@ -64,6 +64,8 @@ class ClientProxy: ClientProxyProtocol {
let spaceService: SpaceServiceProxyProtocol
let eventStringBuilder: RoomEventStringBuilder
private static var roomCreationPowerLevelOverrides: PowerLevels {
.init(usersDefault: nil,
eventsDefault: nil,
@@ -216,6 +218,7 @@ class ClientProxy: ClientProxyProtocol {
roomSummaryProvider = configuredAppService.roomSummaryProvider
alternateRoomSummaryProvider = configuredAppService.alternateRoomSummaryProvider
staticRoomSummaryProvider = configuredAppService.staticRoomSummaryProvider
eventStringBuilder = configuredAppService.eventStringBuilder
syncServiceStateUpdateTaskHandle = createSyncServiceStateObserver(syncService)
roomListStateUpdateTaskHandle = createRoomListServiceObserver(roomListService)
@@ -1169,7 +1172,8 @@ class ClientProxy: ClientProxyProtocol {
let roomProxy = try await JoinedRoomProxy(roomListService: roomListService,
room: room,
appSettings: appSettings,
analyticsService: analyticsService)
analyticsService: analyticsService,
eventStringBuilder: eventStringBuilder)
return .joined(roomProxy)
case .left:
@@ -1318,6 +1322,7 @@ private struct ClientProxyServices {
let roomSummaryProvider: RoomSummaryProviderProtocol
let alternateRoomSummaryProvider: RoomSummaryProviderProtocol
let staticRoomSummaryProvider: StaticRoomSummaryProviderProtocol
let eventStringBuilder: RoomEventStringBuilder
init(client: ClientProtocol,
actionsSubject: PassthroughSubject<ClientProxyAction, Never>,
@@ -1334,10 +1339,11 @@ private struct ClientProxyServices {
let roomMessageEventStringBuilder = RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(cacheKey: "roomList",
mentionBuilder: PlainMentionBuilder()),
style: .senderPrefixed)
let eventStringBuilder = try RoomEventStringBuilder(stateEventStringBuilder: RoomStateEventStringBuilder(userID: client.userId(), shouldDisambiguateDisplayNames: false),
messageEventStringBuilder: roomMessageEventStringBuilder,
shouldDisambiguateDisplayNames: false,
shouldPrefixSenderName: true)
eventStringBuilder = try RoomEventStringBuilder(stateEventStringBuilder: RoomStateEventStringBuilder(userID: client.userId(), shouldDisambiguateDisplayNames: false),
messageEventStringBuilder: roomMessageEventStringBuilder,
shouldDisambiguateDisplayNames: false,
shouldPrefixSenderName: true)
roomSummaryProvider = RoomSummaryProvider(roomListService: roomListService,
eventStringBuilder: eventStringBuilder,
@@ -16,6 +16,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
private let room: RoomProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let eventStringBuilder: RoomEventStringBuilder
// periphery:ignore - required for instance retention in the rust codebase
private var roomInfoObservationToken: TaskHandle?
@@ -78,11 +79,13 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
init(roomListService: RoomListServiceProtocol,
room: RoomProtocol,
appSettings: AppSettings,
analyticsService: AnalyticsService) async throws {
analyticsService: AnalyticsService,
eventStringBuilder: RoomEventStringBuilder) async throws {
self.roomListService = roomListService
self.room = room
self.appSettings = appSettings
self.analyticsService = analyticsService
self.eventStringBuilder = eventStringBuilder
infoSubject = try await .init(RoomInfoProxy(roomInfo: room.roomInfo()))
@@ -200,6 +203,10 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
}
}
func threadListService() async -> RoomThreadListServiceProxyProtocol {
await RoomThreadListProxy(threadListService: room.threadListService(), eventStringBuilder: eventStringBuilder)
}
func loadOrFetchEventDetails(for eventID: String) async -> Result<TimelineEvent, RoomProxyError> {
do {
let event = try await room.loadOrFetchEvent(eventId: eventID)
@@ -98,6 +98,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
func threadTimeline(eventID: String) async -> Result<TimelineProxyProtocol, RoomProxyError>
func threadListService() async -> RoomThreadListServiceProxyProtocol
func loadOrFetchEventDetails(for eventID: String) async -> Result<TimelineEvent, RoomProxyError>
func messageFilteredTimeline(focus: TimelineFocus,
@@ -0,0 +1,101 @@
//
// Copyright 2026 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 Foundation
import MatrixRustSDK
class RoomThreadListProxy: RoomThreadListServiceProxyProtocol {
private let threadListService: ThreadListServiceProtocol
private let eventStringBuilder: RoomEventStringBuilder
private var itemUpdatesHandle: TaskHandle?
private let itemsSubject = CurrentValueSubject<[RoomThreadListItem], Never>([])
var itemsPublisher: CurrentValuePublisher<[RoomThreadListItem], Never> {
itemsSubject.asCurrentValuePublisher()
}
private var paginationStateHandle: TaskHandle?
let paginationStatePublisher: CurrentValuePublisher<RoomThreadListPaginationState, Never>
init(threadListService: ThreadListServiceProtocol, eventStringBuilder: RoomEventStringBuilder, paginationToken: String? = nil) {
self.threadListService = threadListService
self.eventStringBuilder = eventStringBuilder
let paginationStateSubject = CurrentValueSubject<RoomThreadListPaginationState, Never>(.init(sdkState: threadListService.paginationState()))
paginationStatePublisher = paginationStateSubject.asCurrentValuePublisher()
paginationStateHandle = threadListService.subscribeToPaginationStateUpdates(listener: SDKListener { state in
paginationStateSubject.send(.init(sdkState: state))
})
updateItems()
itemUpdatesHandle = self.threadListService.subscribeToItemsUpdates(listener: SDKListener { [weak self] _ in
self?.updateItems()
})
}
func paginate() async -> Result<Void, RoomProxyError> {
do {
try await threadListService.paginate()
return .success(())
} catch {
MXLog.error("Failed paginating with error: \(error)")
return .failure(.sdkError(error))
}
}
// MARK: - Private
private func updateItems() {
let items = threadListService.items().map { item in
guard let rootMessageDetails = buildMessageDetails(item.rootEvent) else {
fatalError("This should never fail™")
}
let latestMessageDetails = buildMessageDetails(item.latestEvent)
return RoomThreadListItem(id: item.rootEvent.eventId,
rootMessageDetails: rootMessageDetails,
latestMessageDetails: latestMessageDetails,
numberOfReplies: UInt(item.numReplies))
}
itemsSubject.send(items)
}
private func buildMessageDetails(_ threadListItemEvent: ThreadListItemEvent?) -> RoomThreadListItem.MessageDetails? {
guard let threadListItemEvent else {
return nil
}
let sender = TimelineItemSender(senderID: threadListItemEvent.sender, senderProfile: threadListItemEvent.senderProfile)
let timestamp = Date(timeIntervalSince1970: TimeInterval(threadListItemEvent.timestamp / 1000))
let message: AttributedString? = if let content = threadListItemEvent.content {
eventStringBuilder.buildAttributedString(for: content,
sender: sender,
isOutgoing: threadListItemEvent.isOwn)
} else {
nil
}
return .init(sender: sender, timestamp: timestamp, message: message)
}
}
extension RoomThreadListPaginationState {
init(sdkState: MatrixRustSDK.ThreadListPaginationState) {
switch sdkState {
case .loading:
self = .loading
case .idle(let endReached):
self = .idle(endReached: endReached)
}
}
}
@@ -0,0 +1,38 @@
//
// Copyright 2026 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 Foundation
// sourcery: AutoMockable
protocol RoomThreadListServiceProxyProtocol {
var itemsPublisher: CurrentValuePublisher<[RoomThreadListItem], Never> { get }
var paginationStatePublisher: CurrentValuePublisher<RoomThreadListPaginationState, Never> { get }
func paginate() async -> Result<Void, RoomProxyError>
}
struct RoomThreadListItem: Identifiable {
struct MessageDetails {
let sender: TimelineItemSender
let timestamp: Date
let message: AttributedString?
}
let id: String
let rootMessageDetails: MessageDetails
let latestMessageDetails: MessageDetails?
let numberOfReplies: UInt
}
enum RoomThreadListPaginationState: Equatable {
case idle(endReached: Bool)
case loading
}