mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
a520d94760
* Enable Swift 6.2 with default main actor isolation for the app target Turn on SWIFT_VERSION 6.2, SWIFT_APPROACHABLE_CONCURRENCY and SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor for the ElementX target, matching the NSE and Share Extension (which already use 6.2 without the main actor default). # Conflicts: # ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved * Update the code generation templates for Swift 6 Emit nonisolated/@unchecked Sendable mocks and nonisolated string enums, and mark async mock methods @concurrent so they keep suspending like the real implementations (otherwise intermediate states are unobservable in tests). Includes the regenerated mocks and strings. # Conflicts: # ElementX/Sources/Mocks/Generated/GeneratedMocks.swift * Make the data layer and services nonisolated and Sendable Annotate the value-type models, Rust SDK proxy wrappers, timeline item graph, settings, logging and NSE-shared types as nonisolated, and conform the service protocols to Sendable so their existentials can cross isolation boundaries. These types run off the main actor and no longer inherit the target's default main actor isolation. * Adopt the new isolation across call sites, mocks and signatures Update call sites, closure signatures and mock configuration helpers to match the nonisolated/Sendable data layer, including the main actor isolated mock inits and the @Sendable filter on recentlyVisitedRooms. ^ Conflicts: ^ ElementX/Sources/Services/SecureBackup/SecureBackupController.swift * Remove redundant @MainActor annotations With the app target defaulting to main actor isolation, the explicit @MainActor on view models, coordinators, views and their protocols is no longer required. * Fix concurrency hotspots surfaced by the migration Resolve the room summary main-thread deadlock by moving diff processing onto AsyncStreams, add the SDKListener.onMainActor FIFO bridge for SDK callbacks, hop KVO/timer/UIKit callbacks to the main actor via assumeIsolated, add isolated deinits, and apply the few justified nonisolated(unsafe) escapes for queue-confined Apple APIs. * Fix the unit tests for Swift 6 concurrency Hop the waitForConfirmation body onto the caller's actor (Swift Testing runs confirmation closures off-actor), and adjust the affected suites and async signatures. * offload diffs on concurrent threads instead of main actor * make room info protocols sendable * use a task group for fetching data so that everything runs concurrently in the TextRoomTimelineView when getting the preview. * use the generic onMainActor implementation, which already makes a stream and send the continuation on the main actor. * small pr comments issues being addressed * update enterprise * Update StaticCode to 1.1.0 and specify the required Swift isolation. * fix ElementCallServiceTests * update enterprise * re-generate mocks * update enterprise * enterprise new code conformed to swift 6 --------- Co-authored-by: Doug <douglase@element.io>
196 lines
7.9 KiB
Swift
196 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] = []
|
|
/// A place to track rooms that were sucessfully added to the space in order to filter them out from
|
|
/// the roomsSection when a failure occurs part way through the array.
|
|
private var addedRoomIDs: 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
|
|
spaceServiceProxy = userSession.clientProxy.spaceService
|
|
self.roomSummaryProvider = roomSummaryProvider
|
|
self.userIndicatorController = userIndicatorController
|
|
|
|
super.init(initialViewState: SpaceAddRoomsScreenViewState(roomsSection: .init(type: .suggestions, rooms: [])),
|
|
mediaProvider: userSession.mediaProvider)
|
|
|
|
roomSummaryProvider.roomListPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.updateRooms()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
Task {
|
|
let existingRooms = spaceRoomListProxy.spaceRoomsPublisher.value
|
|
suggestedRooms = await userSession.clientProxy
|
|
.recentlyVisitedRooms { roomProxy in
|
|
// The filter is Sendable for the mock's sake but the client proxy always calls it on the main actor.
|
|
MainActor.assumeIsolated {
|
|
!roomProxy.infoPublisher.value.isDirect
|
|
&& !roomProxy.infoPublisher.value.isSpace
|
|
&& roomProxy.infoPublisher.value.membership == .joined
|
|
&& !existingRooms.contains { $0.id == roomProxy.id }
|
|
}
|
|
}
|
|
.map { .init(roomProxy: $0) }
|
|
|
|
if state.roomsSection.type == .suggestions {
|
|
state.roomsSection = .init(type: .suggestions, rooms: suggestedRooms)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
return
|
|
}
|
|
|
|
let existingRooms = spaceRoomListProxy.spaceRoomsPublisher.value
|
|
|
|
let rooms = roomSummaryProvider.roomListPublisher.value
|
|
.lazy
|
|
.filter { [addedRoomIDs] summary in
|
|
!summary.isDirect && !existingRooms.contains { $0.id == summary.id } && !addedRoomIDs.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:
|
|
addedRoomIDs.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 { addedRoomIDs.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))
|
|
}
|
|
}
|