mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
df6b7d0d21
The home screen's filter observation fires on every view state change, so setFilter was repeatedly pushing the same filter down into the SDK. Cache the last applied filter and skip the SDK call when it hasn't changed.
478 lines
22 KiB
Swift
478 lines
22 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
|
|
|
|
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() {
|
|
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.upperBound >= rooms.count {
|
|
listUpdatesSubscriptionResult?.controller().addOnePage()
|
|
} else if range.lowerBound == 0 {
|
|
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
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
}
|