Files
element-x-ios/ElementX/Sources/Screens/Spaces/SpaceAddRoomsScreen/SpaceAddRoomsScreenViewModel.swift
Ivan Magda ad88f0da7c Update room suggestions and search when space membership changes (#6316)
* Avoid repeated space membership scans in room search

Signed-off-by: Ivan Magda <imagda15@gmail.com>

* Refresh room candidates when space membership changes

Keep known space room IDs in a set updated by the room publisher and by
successful additions. Filter both suggestions and search results through
that set, retaining exclusions while the space list resets and reloads.

Preserve the initial recent-room eligibility filter so existing children
do not consume the five suggestion slots. Cover live membership updates
and partial-save filtering in both candidate lists.

---------

Signed-off-by: Ivan Magda <imagda15@gmail.com>
2026-10-12 14:03:44 +00:00

198 lines
7.9 KiB
Swift

//
// 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 SwiftUI
typealias SpaceAddRoomsScreenViewModelType = StateStoreViewModelV2<SpaceAddRoomsScreenViewState, SpaceAddRoomsScreenViewAction>
class SpaceAddRoomsScreenViewModel: SpaceAddRoomsScreenViewModelType, SpaceAddRoomsScreenViewModelProtocol {
private let spaceRoomListProxy: SpaceRoomListProxyProtocol
private let spaceServiceProxy: SpaceServiceProxyProtocol
private let roomSummaryProvider: RoomSummaryProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private var suggestedRooms: [SpaceAddRoomsScreenRoom] = []
/// Keep known children excluded while the space list resets and reloads after saving.
private var existingRoomIDs: Set<String>
private var actionsSubject: PassthroughSubject<SpaceAddRoomsScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<SpaceAddRoomsScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(spaceRoomListProxy: SpaceRoomListProxyProtocol,
userSession: UserSessionProtocol,
roomSummaryProvider: RoomSummaryProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.spaceRoomListProxy = spaceRoomListProxy
existingRoomIDs = Set(spaceRoomListProxy.spaceRoomsPublisher.value.map(\.id))
spaceServiceProxy = userSession.clientProxy.spaceService
self.roomSummaryProvider = roomSummaryProvider
self.userIndicatorController = userIndicatorController
super.init(initialViewState: SpaceAddRoomsScreenViewState(roomsSection: .init(type: .suggestions, rooms: [])),
mediaProvider: userSession.mediaProvider)
spaceRoomListProxy.spaceRoomsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] spaceRooms in
guard let self else { return }
existingRoomIDs.formUnion(spaceRooms.map(\.id))
updateRooms()
}
.store(in: &cancellables)
roomSummaryProvider.roomListPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateRooms()
}
.store(in: &cancellables)
Task { [existingRoomIDs] in
// Filter before the recent-room limit so existing children do not displace suggestions.
suggestedRooms = await userSession.clientProxy
.recentlyVisitedRooms { @MainActor roomProxy in
!roomProxy.infoPublisher.value.isDirect
&& !roomProxy.infoPublisher.value.isSpace
&& roomProxy.infoPublisher.value.membership == .joined
&& !existingRoomIDs.contains(roomProxy.id)
}
.map { .init(roomProxy: $0) }
updateRooms()
}
}
override func process(viewAction: SpaceAddRoomsScreenViewAction) {
switch viewAction {
case .cancel:
actionsSubject.send(.dismiss)
case .reachedTop:
updateVisibleRange(edge: .top)
case .reachedBottom:
updateVisibleRange(edge: .bottom)
case .searchQueryChanged where state.bindings.searchQuery.isEmpty:
roomSummaryProvider.setFilter(.all(filters: []))
case .searchQueryChanged:
roomSummaryProvider.setFilter(.search(query: state.bindings.searchQuery))
case .toggleRoom(let room):
toggleRoom(room)
case .save:
Task { await save() }
}
}
func stop() {
// This is a shared provider so we should reset the filtering when we are done with the view
roomSummaryProvider.setFilter(.all(filters: []))
}
// MARK: - Private
private func updateRooms() {
guard !state.bindings.searchQuery.isEmpty else {
state.roomsSection = .init(type: .suggestions, rooms: suggestedRooms.filter { !existingRoomIDs.contains($0.id) })
return
}
let rooms = roomSummaryProvider.roomListPublisher.value
.lazy
.filter { [existingRoomIDs] summary in
!summary.isDirect && !existingRoomIDs.contains(summary.id)
}
.map(SpaceAddRoomsScreenRoom.init)
state.roomsSection = .init(type: .searchResults, rooms: Array(rooms))
}
/// The actual range values don't matter as long as they contain the lower
/// or upper bounds. updateVisibleRange is a hybrid API that powers both
/// sliding sync visible range update and list paginations
/// For lists other than the home screen one we don't care about visible ranges,
/// we just need the respective bounds to be there to trigger a next page load or
/// a reset to just one page
private func updateVisibleRange(edge: UIRectEdge) {
switch edge {
case .top:
roomSummaryProvider.updateVisibleRange(0..<0)
case .bottom:
let roomCount = roomSummaryProvider.roomListPublisher.value.count
roomSummaryProvider.updateVisibleRange(roomCount..<roomCount)
default:
break
}
}
private func toggleRoom(_ room: SpaceAddRoomsScreenRoom) {
if state.selectedRooms.contains(room) {
state.selectedRooms.removeAll { $0.id == room.id }
} else {
state.selectedRooms.append(room)
withElementAnimation(.easeInOut) { state.bindings.selectedRoomsPosition = room.id }
}
}
private func save() async {
showSavingIndicator()
defer { hideSavingIndicator() }
MXLog.info("Adding \(state.selectedRooms.count) rooms to space \(spaceRoomListProxy.id)")
for room in state.selectedRooms {
switch await spaceServiceProxy.addChild(room.id, to: spaceRoomListProxy.id) {
case .success:
existingRoomIDs.insert(room.id)
case .failure(let error):
MXLog.error("Failed adding room to space: \(error)")
showErrorIndicator()
// Hide rooms that were successfully added.
state.selectedRooms.removeAll { existingRoomIDs.contains($0.id) }
updateRooms()
return
}
}
MXLog.info("\(state.selectedRooms.count) rooms added to space \(spaceRoomListProxy.id)")
await spaceRoomListProxy.resetAndWaitForFullReload(timeout: .seconds(10))
actionsSubject.send(.dismiss)
}
// MARK: User Indicators
private var savingIndicatorID: String {
"\(Self.self)-Saving"
}
private var failureIndicatorID: String {
"\(Self.self)-Failure"
}
private func showSavingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: savingIndicatorID,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
title: L10n.commonSaving,
persistent: true))
}
private func hideSavingIndicator() {
userIndicatorController.retractIndicatorWithId(savingIndicatorID)
}
private func showErrorIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: failureIndicatorID,
type: .toast,
title: L10n.errorUnknown,
icon: \.close))
}
}