* Initial plan * Migrate 3 test files from XCTest to Swift Testing - MediaUploadPreviewScreenViewModelTests: @MainActor @Suite struct with init(), BundleFinder class for Bundle(for:), mutating test/setup functions, [self] capture replacing [weak self] in closures - NotificationManagerTests: @MainActor @Suite final class with init()/deinit, expectation/fulfillment(of:) replaced with confirmation(...), test_ prefix stripped - NotificationSettingsScreenViewModelTests: @MainActor @Suite struct with init() throws, non-optional stored properties, test prefix stripped Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Migrate 3 XCTest files to Swift Testing - NotificationSettingsEditScreenViewModelTests: @MainActor @Suite struct with init() throws, mutating test methods - TimelineViewModelTests: @MainActor @Suite final class with init() async throws + deinit - AttributedStringBuilderTests: @Suite struct with init() async throws All XCT assertions replaced with #expect/#require/Issue.record Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Migrate 4 test files from XCTest to Swift Testing - TimelineMediaPreviewViewModelTests: @Suite struct, mutating @Test funcs, testLoadingItem renamed to loadingItem (called internally by other tests) - ServerConfirmationScreenViewModelTests: @Suite final class with init()/deinit - CompletionSuggestionServiceTests: @Suite struct with init() - RoomFlowCoordinatorTests: @Suite final class with deinit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Migrate 4 test files from XCTest to Swift Testing - VoiceMessageRecorderTests: @Suite struct with init() async throws, added BundleFinder class for Bundle lookup, migrated all assertions - SpaceScreenViewModelTests: @Suite struct, private mutating setupViewModel, all test funcs mutating, XCTestExpectation → confirmation - RoomNotificationSettingsScreenViewModelTests: @Suite struct with init() throws, cancellable tests marked mutating - JoinRoomScreenViewModelTests: @Suite final class with init()/deinit, XCTestExpectation → confirmation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Migrate 6 test files from XCTestCase to Swift Testing Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com> * Fix trailing blank line in RoomPollsHistoryScreenViewModelTests Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com> * Migrate 3 test files from XCTest to Swift Testing - MediaUploadingPreprocessorTests: @Suite final class with init()/deinit, removed executionTimeAllowance, XCTAssertEqual(accuracy:) → abs(Double) - SecurityAndPrivacyScreenViewModelTests: @MainActor @Suite final class, 5 expectation+fulfillment → await confirmation(...) - CreateRoomViewModelTests: @MainActor @Suite final class, 4 expectation+fulfillment → await confirmation(...) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Migrate RoomScreenViewModelTests and RoomDetailsScreenViewModelTests to Swift Testing - Replace XCTest with Testing framework - RoomScreenViewModelTests: final class with init() async throws + deinit - RoomDetailsScreenViewModelTests: struct with init() and mutating funcs - Convert XCT assertions to #expect / Issue.record - Convert XCTestExpectation patterns to confirmation { confirm in } - Strip 'test' prefix from all test function names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Migrate ComposerToolbarViewModelTests from XCTest to Swift Testing - Replace import XCTest with import Testing - Convert XCTestCase class to @MainActor @Suite final class - Replace setUp()/tearDown() with init()/deinit - Strip 'test' prefix from all 41 test method names and add @Test - Replace XCTAssert* with #expect()/#require() - Replace try XCTUnwrap() with try #require() - Convert expectation+wait patterns to deferFulfillment with PassthroughSubject - Convert isInverted expectation to boolean flag checked after await - Use deferFulfillment on $viewState for state-transition tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address comments with Copilot. * Fix the failing tests. * Fixed flaky tests (#5137) resolved flaky tests * Tweaks and fixes. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com> Co-authored-by: Doug <douglase@element.io> Co-authored-by: Mauro <34335419+Velin92@users.noreply.github.com>
174 lines
7.5 KiB
Swift
174 lines
7.5 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 SwiftUI
|
|
|
|
typealias RoomNotificationSettingsScreenViewModelType = StateStoreViewModelV2<RoomNotificationSettingsScreenViewState, RoomNotificationSettingsScreenViewAction>
|
|
|
|
class RoomNotificationSettingsScreenViewModel: RoomNotificationSettingsScreenViewModelType, RoomNotificationSettingsScreenViewModelProtocol {
|
|
private let actionsSubject: PassthroughSubject<RoomNotificationSettingsScreenViewModelAction, Never> = .init()
|
|
private let notificationSettingsProxy: NotificationSettingsProxyProtocol
|
|
private let roomProxy: JoinedRoomProxyProtocol
|
|
|
|
// periphery:ignore - cancellable tasks cancel when reassigned
|
|
@CancellableTask private var fetchNotificationSettingsTask: Task<Void, Error>?
|
|
|
|
var actions: AnyPublisher<RoomNotificationSettingsScreenViewModelAction, Never> {
|
|
actionsSubject.eraseToAnyPublisher()
|
|
}
|
|
|
|
init(notificationSettingsProxy: NotificationSettingsProxyProtocol, roomProxy: JoinedRoomProxyProtocol, displayAsUserDefinedRoomSettings: Bool) {
|
|
let bindings = RoomNotificationSettingsScreenViewStateBindings()
|
|
self.notificationSettingsProxy = notificationSettingsProxy
|
|
self.roomProxy = roomProxy
|
|
let navigationTitle = displayAsUserDefinedRoomSettings ? roomProxy.infoPublisher.value.displayName : L10n.screenRoomDetailsNotificationTitle
|
|
let customSettingsSectionHeader = displayAsUserDefinedRoomSettings ? L10n.screenRoomNotificationSettingsRoomCustomSettingsTitle : L10n.screenRoomNotificationSettingsCustomSettingsTitle
|
|
super.init(initialViewState: RoomNotificationSettingsScreenViewState(bindings: bindings,
|
|
displayAsUserDefinedRoomSettings: displayAsUserDefinedRoomSettings,
|
|
navigationTitle: navigationTitle ?? L10n.screenRoomDetailsNotificationTitle,
|
|
customSettingsSectionHeader: customSettingsSectionHeader))
|
|
|
|
setupNotificationSettingsSubscription()
|
|
fetchNotificationSettings()
|
|
}
|
|
|
|
// MARK: - Public
|
|
|
|
override func process(viewAction: RoomNotificationSettingsScreenViewAction) {
|
|
switch viewAction {
|
|
case .changedAllowCustomSettings:
|
|
toogleCustomSetting()
|
|
case .setCustomMode(let mode):
|
|
setCustomMode(mode)
|
|
case .customSettingFootnoteLinkTapped:
|
|
actionsSubject.send(.openGlobalSettings)
|
|
case .deleteCustomSettingTapped:
|
|
Task { await deleteCustomSetting() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func setupNotificationSettingsSubscription() {
|
|
notificationSettingsProxy.callbacks
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] callback in
|
|
guard let self else { return }
|
|
|
|
switch callback {
|
|
case .settingsDidChange:
|
|
self.fetchNotificationSettings()
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func fetchNotificationSettings() {
|
|
fetchNotificationSettingsTask = Task {
|
|
await fetchRoomNotificationSettings()
|
|
}
|
|
}
|
|
|
|
private func fetchRoomNotificationSettings() async {
|
|
let isEncrypted = roomProxy.infoPublisher.value.isEncrypted
|
|
|
|
state.shouldDisplayMentionsOnlyDisclaimer = isEncrypted ? await !notificationSettingsProxy.canPushEncryptedEventsToDevice() : false
|
|
|
|
do {
|
|
// `isOneToOne` here is not the same as `isDirect` on the room. From the point of view of the push rule, a one-to-one room is a room with exactly two active members.
|
|
let settings = try await notificationSettingsProxy.getNotificationSettings(roomId: roomProxy.id,
|
|
isEncrypted: isEncrypted,
|
|
isOneToOne: roomProxy.infoPublisher.value.activeMembersCount == 2)
|
|
guard !Task.isCancelled else { return }
|
|
state.notificationSettingsState = .loaded(settings: settings)
|
|
if !state.isRestoringDefaultSetting {
|
|
state.bindings.allowCustomSetting = !settings.isDefault
|
|
}
|
|
} catch {
|
|
state.notificationSettingsState = .error
|
|
displayError(.loadingSettingsFailed)
|
|
}
|
|
}
|
|
|
|
private func toogleCustomSetting() {
|
|
guard case .loaded(let settings) = state.notificationSettingsState else { return }
|
|
guard state.bindings.allowCustomSetting == settings.isDefault else { return }
|
|
|
|
if state.bindings.allowCustomSetting {
|
|
setCustomMode(settings.mode)
|
|
} else {
|
|
restoreDefaultSetting()
|
|
}
|
|
}
|
|
|
|
private func restoreDefaultSetting() {
|
|
state.isRestoringDefaultSetting = true
|
|
Task {
|
|
do {
|
|
try await notificationSettingsProxy.restoreDefaultNotificationMode(roomId: roomProxy.id)
|
|
} catch {
|
|
displayError(.restoreDefaultFailed)
|
|
}
|
|
await MainActor.run {
|
|
state.isRestoringDefaultSetting = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setCustomMode(_ mode: RoomNotificationModeProxy) {
|
|
// Check if the new mode is already the current one
|
|
if case .loaded(let currentSettings) = state.notificationSettingsState {
|
|
if !currentSettings.isDefault, currentSettings.mode == mode {
|
|
return
|
|
}
|
|
}
|
|
|
|
state.pendingCustomMode = mode
|
|
Task {
|
|
do {
|
|
try await notificationSettingsProxy.setNotificationMode(roomId: roomProxy.id, mode: mode)
|
|
} catch {
|
|
displayError(.setModeFailed)
|
|
}
|
|
await MainActor.run {
|
|
state.pendingCustomMode = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private func displayError(_ type: RoomNotificationSettingsScreenErrorType) {
|
|
switch type {
|
|
case .loadingSettingsFailed:
|
|
state.bindings.alertInfo = AlertInfo(id: type,
|
|
title: L10n.commonError,
|
|
message: L10n.screenRoomNotificationSettingsErrorLoadingSettings)
|
|
case .setModeFailed:
|
|
state.bindings.alertInfo = AlertInfo(id: type,
|
|
title: L10n.commonError,
|
|
message: L10n.screenRoomNotificationSettingsErrorSettingMode)
|
|
|
|
case .restoreDefaultFailed:
|
|
state.bindings.alertInfo = AlertInfo(id: type,
|
|
title: L10n.commonError,
|
|
message: L10n.screenRoomNotificationSettingsErrorRestoringDefault)
|
|
}
|
|
}
|
|
|
|
private func deleteCustomSetting() async {
|
|
state.deletingCustomSetting = true
|
|
do {
|
|
try await notificationSettingsProxy.restoreDefaultNotificationMode(roomId: roomProxy.id)
|
|
actionsSubject.send(.dismiss)
|
|
} catch {
|
|
displayError(.restoreDefaultFailed)
|
|
}
|
|
state.deletingCustomSetting = false
|
|
}
|
|
}
|