Files
element-x-ios/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift
T
Copilot c41a38159b Only offer joined rooms in the forward and share pickers (#6255)
* Initial plan

* Offer only joined rooms in destination pickers

Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>

* Clean whitespace in share picker test

Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>

* Restrict the shared picker room list to joined rooms

The alternate room summary provider, which backs the forward, share and
Add rooms pickers and global search, now always includes the SDK's
joined filter in its base filter. This replaces the per-call joinedOnly
flags, so Add rooms no longer offers invited rooms or spaces and no
screen can forget the flag.

A filter set before the provider has its room list is now applied once
the list is set, instead of being reset to the default. The share picker
hit this on a cold launch from the share extension.

The forward and share pickers also hide upgraded rooms, matching the
Suggestions section. The SDK has no filter for these.

Also registers the share picker's test file in the project, fixes the
blank-line indentation SwiftFormat flagged, and re-records the picker
snapshots without the upgraded room.

* Check room membership for forwarding suggestions and search breadcrumbs

RoomSummary now has isJoined, built from the room's membership. The
forwarding suggestions and the search breadcrumbs used a missing
joinRequestType to mean joined, which is also the case for rooms that
have been left or banned from. It only worked because the room list's
base filter already drops those.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>
Co-authored-by: Mauro Romito <mauro.romito@element.io>
2026-10-02 12:44:33 +00:00

504 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 userSettings: UserSettings
private let baseFilter: [RoomListEntriesDynamicFilterKind]
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
/// - includesOnlyJoinedRooms: whether to leave out invites and other rooms the user hasn't joined, whatever the filter
init(roomListService: RoomListServiceProtocol,
eventStringBuilder: RoomEventStringBuilder,
name: String,
shouldUpdateVisibleRange: Bool = false,
includesOnlyJoinedRooms: Bool = false,
roomListPageSize: UInt32 = 100,
notificationSettings: NotificationSettingsProxyProtocol,
userSettings: UserSettings) {
self.roomListService = roomListService
self.eventStringBuilder = eventStringBuilder
self.name = name
self.shouldUpdateVisibleRange = shouldUpdateVisibleRange
self.notificationSettings = notificationSettings
self.userSettings = userSettings
self.roomListPageSize = roomListPageSize
baseFilter = [.any(filters: [.all(filters: [.nonSpace, .nonLeft]),
.all(filters: [.space, .invite])]),
.deduplicateVersions] + (includesOnlyJoinedRooms ? [.joined] : [])
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
let filter = currentFilter ?? .all(filters: [])
currentFilter = nil
setFilter(filter)
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
switch filter {
case .excludeAll:
_ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .none)
case let .search(query):
let filters = nameFilter(for: 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), userSettings.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), userSettings.lowPriorityFilterEnabled {
rustFilters.append(.nonLowPriority)
}
_ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .all(filters: rustFilters))
}
}
// MARK: - Private
private func nameFilter(for query: String) -> [RoomListEntriesDynamicFilterKind] {
userSettings.fuzzyRoomListSearchEnabled ? [.fuzzyMatchRoomName(pattern: query)] : [.normalizedMatchRoomName(pattern: query)]
}
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.setRoomSubscriptions(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,
isJoined: roomInfo.membership == .joined,
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))
}
}
}