Files
element-x-ios/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift
T
Stefan Ceriu 9dd9fd9176 Prefetch the next room list page before reaching the bottom
Growing the list only once the last row was already visible, on top of the half a
second visible range throttle, meant hitting and bouncing off the end of the list
while the next page loaded. Ask for the next page half a page early instead, so
the growth happens while the user is still scrolling towards the boundary.

The threshold is clamped so that it stays positive for accounts with fewer rooms
than half a page, where it would otherwise always match and leave the list unable
to shrink back to a single page.
2026-08-21 12:26:02 +03:00

499 lines
23 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 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 Foundation
import MatrixRustSDK
class RoomSummaryProvider: RoomSummaryProviderProtocol {
private let roomListService: RoomListServiceProtocol
private let eventStringBuilder: RoomEventStringBuilder
private let name: String
private let shouldUpdateVisibleRange: Bool
private let notificationSettings: NotificationSettingsProxyProtocol
private let appSettings: AppSettings
private let roomListPageSize: UInt32
/// Remember how many rooms we had on the previous requests so we can deduplicate
private var roomCountOnLastPageAddRequest = -1
private let visibleItemRangePublisher = CurrentValueSubject<Range<Int>, Never>(0..<0)
// periphery:ignore - retaining purpose
private var roomList: RoomListProtocol?
private var cancellables = Set<AnyCancellable>()
private var listUpdatesSubscriptionResult: RoomListEntriesWithDynamicAdaptersResult?
private var currentFilter: RoomSummaryProviderFilter?
private var stateUpdatesTaskHandle: TaskHandle?
private let roomListSubject = CurrentValueSubject<[RoomSummary], Never>([])
private let stateSubject = CurrentValueSubject<RoomSummaryProviderState, Never>(.notLoaded)
/// Bridge from the SDK's synchronous callback into Swift Concurrency. Yielding is safe from any
/// thread; a single long-lived `for await` consumer (set up in `init`) applies the diffs on the
/// main actor in FIFO order, guaranteeing one in-flight update at a time.
private let diffsContinuation: AsyncStream<[RoomListEntriesUpdate]>.Continuation
/// Same bridging pattern as `diffsContinuation`, for the room list loading state.
private let loadingStateContinuation: AsyncStream<RoomListLoadingState>.Continuation
var roomListPublisher: CurrentValuePublisher<[RoomSummary], Never> {
roomListSubject.asCurrentValuePublisher()
}
var statePublisher: CurrentValuePublisher<RoomSummaryProviderState, Never> {
stateSubject.asCurrentValuePublisher()
}
private var rooms: [RoomSummary] = [] {
didSet {
roomListSubject.send(rooms)
}
}
/// Build a new summary provider with the given parameters
/// - Parameters:
/// - shouldUpdateVisibleRange: whether this summary provider should forward visible ranges
/// to the room list service through the `applyInput(input: .viewport(ranges` api. Only useful for
/// lists that need to update the visible range on Sliding Sync
init(roomListService: RoomListServiceProtocol,
eventStringBuilder: RoomEventStringBuilder,
name: String,
shouldUpdateVisibleRange: Bool = false,
roomListPageSize: UInt32 = 100,
notificationSettings: NotificationSettingsProxyProtocol,
appSettings: AppSettings) {
self.roomListService = roomListService
self.eventStringBuilder = eventStringBuilder
self.name = name
self.shouldUpdateVisibleRange = shouldUpdateVisibleRange
self.notificationSettings = notificationSettings
self.appSettings = appSettings
self.roomListPageSize = roomListPageSize
let (diffsStream, diffsContinuation) = AsyncStream<[RoomListEntriesUpdate]>.makeStream()
self.diffsContinuation = diffsContinuation
let (loadingStateStream, loadingStateContinuation) = AsyncStream<RoomListLoadingState>.makeStream()
self.loadingStateContinuation = loadingStateContinuation
Task { [weak self] in
for await diffs in diffsStream {
await self?.updateRoomsWithDiffs(diffs)
}
}
Task { [weak self, name] in
for await state in loadingStateStream {
MXLog.info("\(name): Received state update: \(state)")
self?.stateSubject.send(RoomSummaryProviderState(roomListState: state))
}
}
setupVisibleRangeObservers()
setupNotificationSettingsSubscription()
}
deinit {
diffsContinuation.finish()
loadingStateContinuation.finish()
}
func setRoomList(_ roomList: RoomList) {
guard stateUpdatesTaskHandle == nil else {
return
}
self.roomList = roomList
do {
listUpdatesSubscriptionResult = roomList.entriesWithDynamicAdapters(pageSize: UInt32(roomListPageSize),
listener: SDKListener { [diffsContinuation] updates in
diffsContinuation.yield(updates)
})
// Forces the listener above to be called with the current state
currentFilter = nil
setFilter(.all(filters: []))
let stateUpdatesSubscriptionResult = try roomList.loadingState(listener: SDKListener { [loadingStateContinuation] state in
loadingStateContinuation.yield(state)
})
stateUpdatesTaskHandle = stateUpdatesSubscriptionResult.stateStream
stateSubject.send(RoomSummaryProviderState(roomListState: stateUpdatesSubscriptionResult.state))
} catch {
MXLog.error("Failed setting up room list entry listener with error: \(error)")
}
}
func updateVisibleRange(_ range: Range<Int>) {
visibleItemRangePublisher.send(range)
}
func setFilter(_ filter: RoomSummaryProviderFilter) {
guard filter != currentFilter else {
return
}
currentFilter = filter
let baseFilter: [RoomListEntriesDynamicFilterKind] = [.any(filters: [.all(filters: [.nonSpace, .nonLeft]),
.all(filters: [.space, .invite])]),
.deduplicateVersions]
switch filter {
case .excludeAll:
_ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .none)
case let .search(query):
let filters = if appSettings.fuzzyRoomListSearchEnabled {
[.fuzzyMatchRoomName(pattern: query)] + baseFilter
} else {
[.normalizedMatchRoomName(pattern: query)] + baseFilter
}
_ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .all(filters: filters))
case .rooms(let roomIDs, let filters):
var rustFilters = filters.map(\.rustFilter) + baseFilter
rustFilters.append(.identifiers(identifiers: Array(roomIDs)))
if !filters.contains(.lowPriority), appSettings.lowPriorityFilterEnabled {
rustFilters.append(.nonLowPriority)
}
_ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .all(filters: rustFilters))
case let .all(filters):
var rustFilters = filters.map(\.rustFilter) + baseFilter
if !filters.contains(.lowPriority), appSettings.lowPriorityFilterEnabled {
rustFilters.append(.nonLowPriority)
}
_ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .all(filters: rustFilters))
}
}
// MARK: - Private
private func setupVisibleRangeObservers() {
// Unthrottled to add another page half way through the last one
visibleItemRangePublisher
.sink { [weak self] range in
guard let self,
!range.isEmpty,
range.upperBound >= rooms.count - Int(roomListPageSize) / 2,
rooms.count != roomCountOnLastPageAddRequest else {
return
}
roomCountOnLastPageAddRequest = rooms.count
MXLog.info("\(self.name): Adding a page at \(rooms.count) rooms, visible range: \(range)")
listUpdatesSubscriptionResult?.controller().addOnePage()
}
.store(in: &cancellables)
visibleItemRangePublisher
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
.removeDuplicates()
.sink { [weak self] range in
guard let self else { return }
MXLog.info("\(self.name): Updating visible range: \(range)")
if range.lowerBound == 0, range.upperBound < rooms.count {
roomCountOnLastPageAddRequest = -1
listUpdatesSubscriptionResult?.controller().resetToOnePage()
}
}
.store(in: &cancellables)
visibleItemRangePublisher
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
.filter { [weak self] range in
guard let self else { return false }
return !range.isEmpty && shouldUpdateVisibleRange
}
.compactMap { [weak self] (range: Range) -> [String]? in
guard let self else { return nil }
// The scroll view content size based visible range calculations might create large ranges
// This is just a safety check to not overload the backend
var range = range
if range.upperBound - range.lowerBound > SlidingSyncConstants.maximumVisibleRangeSize {
let upperBound = range.lowerBound + SlidingSyncConstants.maximumVisibleRangeSize
range = range.lowerBound..<upperBound
}
MXLog.info("\(self.name): Subscribing to rooms in range: \(range)")
return range
.filter { $0 < self.rooms.count }
.map { self.rooms[$0].id }
}
.removeDuplicates()
.sink { [weak self] roomIDs in
guard let self else { return }
Task { [weak self] in
do {
try await self?.roomListService.subscribeToRooms(roomIds: roomIDs)
} catch {
MXLog.error("Failed subscribing to rooms with error: \(error)")
}
}
}
.store(in: &cancellables)
}
fileprivate func updateRoomsWithDiffs(_ diffs: [RoomListEntriesUpdate]) async {
// Building the room summaries (SDK fetches + string building) and applying the
// CollectionDifference can be expensive, so compute off the main actor and only
// hop back to publish the result.
rooms = await Self.updatedRooms(from: diffs, on: rooms, eventStringBuilder: eventStringBuilder, name: name)
}
@concurrent
private static func updatedRooms(from diffs: [RoomListEntriesUpdate],
on currentRooms: [RoomSummary],
eventStringBuilder: RoomEventStringBuilder,
name: String) async -> [RoomSummary] {
var updatedRooms = currentRooms
for diff in diffs {
updatedRooms = await processDiff(diff, on: updatedRooms, eventStringBuilder: eventStringBuilder, name: name)
}
return updatedRooms
}
private nonisolated static func processDiff(_ diff: RoomListEntriesUpdate, on currentItems: [RoomSummary], eventStringBuilder: RoomEventStringBuilder, name: String) async -> [RoomSummary] {
guard let collectionDiff = await buildDiff(from: diff, on: currentItems, eventStringBuilder: eventStringBuilder) else {
MXLog.error("\(name): Failed building CollectionDifference from \(diff)")
return currentItems
}
guard let updatedItems = currentItems.applying(collectionDiff) else {
MXLog.error("\(name): Failed applying diff: \(collectionDiff)")
return currentItems
}
return updatedItems
}
private nonisolated static func fetchRoomDetails(from room: Room) async -> (roomInfo: RoomInfo?, latestEvent: LatestEventValue?) {
do {
let latestEvent = await room.latestEvent()
let roomInfo = try await room.roomInfo()
return (roomInfo, latestEvent)
} catch {
MXLog.error("Failed fetching room info with error: \(error)")
return (nil, nil)
}
}
private nonisolated static func buildRoomSummary(from room: Room, eventStringBuilder: RoomEventStringBuilder) async -> RoomSummary {
let roomDetails = await fetchRoomDetails(from: room)
guard let roomInfo = roomDetails.roomInfo else {
// The room info can be momentarily unavailable while the client tears down (e.g. on logout),
// where the SDK still delivers a diff but `room.roomInfo()` throws. Return a placeholder to
// keep the diff indices aligned with the SDK instead of crashing.
MXLog.error("Missing room info for \(room.id())")
return .placeholder(room: room)
}
var attributedLastMessage: AttributedString?
var lastMessageDate: Date?
var lastMessageState: RoomSummary.LastMessageState?
if let latestRoomMessage = roomDetails.latestEvent {
switch latestRoomMessage {
case .local(let timestamp, let senderID, let profile, let content, let state):
let sender = TimelineItemSender(senderID: senderID, senderProfile: profile)
attributedLastMessage = eventStringBuilder.buildAttributedString(for: content, sender: sender, isOutgoing: true)
lastMessageDate = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
switch state {
case .isSending:
lastMessageState = .sending
case .cannotBeSent:
lastMessageState = .failed
case .hasBeenSent:
lastMessageState = nil
}
case .remote(let timestamp, let senderID, let isOwn, let profile, let content):
let sender = TimelineItemSender(senderID: senderID, senderProfile: profile)
attributedLastMessage = eventStringBuilder.buildAttributedString(for: content, sender: sender, isOutgoing: isOwn)
lastMessageDate = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
case .remoteInvite(let timestamp, let senderID, let profile):
lastMessageDate = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
if let senderID {
let sender = TimelineItemSender(senderID: senderID, senderProfile: profile)
let senderDisplayName = sender.displayName ?? sender.id
let invitedYouString = eventStringBuilder.stateEventStringBuilder.buildInvitedYouString(senderDisplayName)
attributedLastMessage = AttributedString(invitedYouString)
}
case .none:
break
}
}
var inviterProxy: RoomMemberProxyProtocol?
if let inviter = roomInfo.inviter {
inviterProxy = RoomMemberProxy(member: inviter)
}
let notificationMode = roomInfo.cachedUserDefinedNotificationMode.flatMap { RoomNotificationModeProxy.from(roomNotificationMode: $0) }
let joinRequestType: RoomSummary.JoinRequestType? = switch roomInfo.membership {
case .invited: .invite(inviter: inviterProxy)
case .knocked: .knock
default: nil
}
let activeCallIntent: RtcCallIntent? = switch roomInfo.activeRoomCallConsensusIntent {
case .full(let intent):
intent
case .partial(intent: let intent, _, _):
intent
case .none:
nil
}
return RoomSummary(room: room,
id: roomInfo.id,
joinRequestType: joinRequestType,
name: roomInfo.displayName ?? roomInfo.id,
isDirect: roomInfo.isDirect,
isSpace: roomInfo.isSpace,
avatarURL: roomInfo.avatarUrl.flatMap(URL.init(string:)),
heroes: roomInfo.heroes.map(UserProfile.init),
activeMembersCount: UInt(roomInfo.activeMembersCount),
lastMessage: attributedLastMessage,
lastMessageDate: lastMessageDate,
lastMessageState: lastMessageState,
unreadMessagesCount: UInt(roomInfo.numUnreadMessages),
unreadMentionsCount: UInt(roomInfo.numUnreadMentions),
unreadNotificationsCount: UInt(roomInfo.numUnreadNotifications),
notificationMode: notificationMode,
canonicalAlias: roomInfo.canonicalAlias,
alternativeAliases: .init(roomInfo.alternativeAliases),
hasOngoingCall: roomInfo.hasRoomCall,
activeCallIntent: activeCallIntent.map { .init(rustCallIntent: $0) },
isMarkedUnread: roomInfo.isMarkedUnread,
isFavourite: roomInfo.isFavourite,
isTombstoned: roomInfo.successorRoom != nil)
}
private nonisolated static func buildDiff(from diff: RoomListEntriesUpdate, on rooms: [RoomSummary], eventStringBuilder: RoomEventStringBuilder) async -> CollectionDifference<RoomSummary>? {
var changes = [CollectionDifference<RoomSummary>.Change]()
switch diff {
case .append(let values):
for (index, value) in values.enumerated() {
let summary = await buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder)
changes.append(.insert(offset: rooms.count + index, element: summary, associatedWith: nil))
}
case .clear:
for (index, value) in rooms.enumerated() {
changes.append(.remove(offset: index, element: value, associatedWith: nil))
}
case .insert(let index, let value):
let summary = await buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder)
changes.append(.insert(offset: Int(index), element: summary, associatedWith: nil))
case .popBack:
guard let value = rooms.last else {
fatalError()
}
changes.append(.remove(offset: rooms.count - 1, element: value, associatedWith: nil))
case .popFront:
let summary = rooms[0]
changes.append(.remove(offset: 0, element: summary, associatedWith: nil))
case .pushBack(let value):
let summary = await buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder)
changes.append(.insert(offset: rooms.count, element: summary, associatedWith: nil))
case .pushFront(let value):
let summary = await buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder)
changes.append(.insert(offset: 0, element: summary, associatedWith: nil))
case .remove(let index):
let summary = rooms[Int(index)]
changes.append(.remove(offset: Int(index), element: summary, associatedWith: nil))
case .reset(let values):
for (index, summary) in rooms.enumerated() {
changes.append(.remove(offset: index, element: summary, associatedWith: nil))
}
for (index, value) in values.enumerated() {
await changes.append(.insert(offset: index, element: buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder), associatedWith: nil))
}
case .set(let index, let value):
let summary = await buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder)
changes.append(.remove(offset: Int(index), element: summary, associatedWith: nil))
changes.append(.insert(offset: Int(index), element: summary, associatedWith: nil))
case .truncate(let length):
for (index, value) in rooms.enumerated() {
if index < length {
continue
}
changes.append(.remove(offset: index, element: value, associatedWith: nil))
}
}
return CollectionDifference(changes)
}
private func setupNotificationSettingsSubscription() {
notificationSettings.callbacks
.receive(on: DispatchQueue.main)
.dropFirst() // drop the first one to avoid rebuilding the summaries during the first synchronization
.sink { [weak self] callback in
guard let self else { return }
switch callback {
case .settingsDidChange:
Task { await self.rebuildRoomSummaries() }
}
}
.store(in: &cancellables)
}
private func rebuildRoomSummaries() async {
MXLog.info("\(name): Rebuilding room summaries for \(rooms.count) rooms")
rooms = await Self.rebuiltRoomSummaries(from: rooms, eventStringBuilder: eventStringBuilder)
MXLog.info("\(name): Finished rebuilding room summaries (\(rooms.count) rooms)")
}
@concurrent
private static func rebuiltRoomSummaries(from rooms: [RoomSummary], eventStringBuilder: RoomEventStringBuilder) async -> [RoomSummary] {
var rebuiltRooms = [RoomSummary]()
rebuiltRooms.reserveCapacity(rooms.count)
for room in rooms {
await rebuiltRooms.append(buildRoomSummary(from: room.room, eventStringBuilder: eventStringBuilder))
}
return rebuiltRooms
}
}
extension RoomSummaryProviderState {
init(roomListState: RoomListLoadingState) {
switch roomListState {
case .notLoaded:
self = .notLoaded
case .loaded(let maximumNumberOfRooms):
self = .loaded(totalNumberOfRooms: UInt(maximumNumberOfRooms ?? 0))
}
}
}