diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 04ecb7a52..f0bf88454 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -260,7 +260,16 @@ "screen_media_upload_preview_error_failed_sending" = "Failed uploading media, please try again."; "screen_migration_message" = "This is a one time process, thanks for waiting."; "screen_migration_title" = "Setting up your account."; +"screen_notification_settings_additional_settings_section_title" = "Additional settings"; +"screen_notification_settings_calls_label" = "Audio and video calls"; +"screen_notification_settings_direct_chats" = "Direct chats"; "screen_notification_settings_enable_notifications" = "Enable notifications on this device"; +"screen_notification_settings_group_chats" = "Group chats"; +"screen_notification_settings_mentions_section_title" = "Mentions"; +"screen_notification_settings_mode_all" = "All"; +"screen_notification_settings_mode_mentions" = "Mentions"; +"screen_notification_settings_notification_section_title" = "Notify me for"; +"screen_notification_settings_room_mention_label" = "Notify me on @room"; "screen_notification_settings_system_notifications_action_required" = "To receive notifications, please change your %1$@."; "screen_notification_settings_system_notifications_action_required_content_link" = "system settings"; "screen_notification_settings_system_notifications_turned_off" = "System notifications turned off"; diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index bba7a879c..5227d396f 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -299,14 +299,21 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { // MARK: Settings private func presentSettingsScreen(animated: Bool) { + Task { + await asyncPresentSettingsScreen(animated: animated) + } + } + + private func asyncPresentSettingsScreen(animated: Bool) async { let settingsNavigationStackCoordinator = NavigationStackCoordinator() let userIndicatorController = UserIndicatorController(rootCoordinator: settingsNavigationStackCoordinator) - let parameters = SettingsScreenCoordinatorParameters(navigationStackCoordinator: settingsNavigationStackCoordinator, - userIndicatorController: userIndicatorController, - userSession: userSession, - bugReportService: bugReportService) + let parameters = await SettingsScreenCoordinatorParameters(navigationStackCoordinator: settingsNavigationStackCoordinator, + userIndicatorController: userIndicatorController, + userSession: userSession, + bugReportService: bugReportService, + notificationSettings: userSession.clientProxy.notificationSettings()) let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: parameters) settingsScreenCoordinator.callback = { [weak self] action in guard let self else { return } diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index f23ac42df..d58aab1cc 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -672,8 +672,26 @@ public enum L10n { public static var screenMigrationMessage: String { return L10n.tr("Localizable", "screen_migration_message") } /// Setting up your account. public static var screenMigrationTitle: String { return L10n.tr("Localizable", "screen_migration_title") } + /// Additional settings + public static var screenNotificationSettingsAdditionalSettingsSectionTitle: String { return L10n.tr("Localizable", "screen_notification_settings_additional_settings_section_title") } + /// Audio and video calls + public static var screenNotificationSettingsCallsLabel: String { return L10n.tr("Localizable", "screen_notification_settings_calls_label") } + /// Direct chats + public static var screenNotificationSettingsDirectChats: String { return L10n.tr("Localizable", "screen_notification_settings_direct_chats") } /// Enable notifications on this device public static var screenNotificationSettingsEnableNotifications: String { return L10n.tr("Localizable", "screen_notification_settings_enable_notifications") } + /// Group chats + public static var screenNotificationSettingsGroupChats: String { return L10n.tr("Localizable", "screen_notification_settings_group_chats") } + /// Mentions + public static var screenNotificationSettingsMentionsSectionTitle: String { return L10n.tr("Localizable", "screen_notification_settings_mentions_section_title") } + /// All + public static var screenNotificationSettingsModeAll: String { return L10n.tr("Localizable", "screen_notification_settings_mode_all") } + /// Mentions + public static var screenNotificationSettingsModeMentions: String { return L10n.tr("Localizable", "screen_notification_settings_mode_mentions") } + /// Notify me for + public static var screenNotificationSettingsNotificationSectionTitle: String { return L10n.tr("Localizable", "screen_notification_settings_notification_section_title") } + /// Notify me on @room + public static var screenNotificationSettingsRoomMentionLabel: String { return L10n.tr("Localizable", "screen_notification_settings_room_mention_label") } /// To receive notifications, please change your %1$@. public static func screenNotificationSettingsSystemNotificationsActionRequired(_ p1: Any) -> String { return L10n.tr("Localizable", "screen_notification_settings_system_notifications_action_required", String(describing: p1)) diff --git a/ElementX/Sources/Mocks/NotificationSettingsProxyMock.swift b/ElementX/Sources/Mocks/NotificationSettingsProxyMock.swift index fe4c7fd4f..671387c49 100644 --- a/ElementX/Sources/Mocks/NotificationSettingsProxyMock.swift +++ b/ElementX/Sources/Mocks/NotificationSettingsProxyMock.swift @@ -51,5 +51,19 @@ extension NotificationSettingsProxyMock { self.callbacks.send(.settingsDidChange) } } + setRoomMentionEnabledEnabledClosure = { [weak self] enabled in + guard let self else { return } + self.isRoomMentionEnabledReturnValue = enabled + Task { + self.callbacks.send(.settingsDidChange) + } + } + setCallEnabledEnabledClosure = { [weak self] enabled in + guard let self else { return } + self.isCallEnabledReturnValue = enabled + Task { + self.callbacks.send(.settingsDidChange) + } + } } } diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenCoordinator.swift index cad158c0b..7a27db386 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenCoordinator.swift @@ -19,6 +19,7 @@ import SwiftUI struct NotificationSettingsScreenCoordinatorParameters { let userNotificationCenter: UserNotificationCenterProtocol + let notificationSettings: NotificationSettingsProxyProtocol } enum NotificationSettingsScreenCoordinatorAction { } @@ -37,10 +38,13 @@ final class NotificationSettingsScreenCoordinator: CoordinatorProtocol { self.parameters = parameters viewModel = NotificationSettingsScreenViewModel(appSettings: ServiceLocator.shared.settings, - userNotificationCenter: parameters.userNotificationCenter) + userNotificationCenter: parameters.userNotificationCenter, + notificationSettingsProxy: parameters.notificationSettings) } - func start() { } + func start() { + viewModel.fetchInitialContent() + } func toPresentable() -> AnyView { AnyView(NotificationSettingsScreen(context: viewModel.context)) diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift index 00534ede4..2d6d8d2e1 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift @@ -23,14 +23,31 @@ struct NotificationSettingsScreenViewState: BindableState { var bindings: NotificationSettingsScreenViewStateBindings var strings = NotificationSettingsScreenStrings() var isUserPermissionGranted: Bool? + var allowedNotificationModes: [RoomNotificationModeProxy] = [.allMessages, .mentionsAndKeywordsOnly] var showSystemNotificationsAlert: Bool { bindings.enableNotifications && isUserPermissionGranted == false } + + var settings: NotificationSettingsScreenSettings? + var applyingChange = false } struct NotificationSettingsScreenViewStateBindings { var enableNotifications = false + var roomMentionsEnabled = false + var callsEnabled = false + var alertInfo: AlertInfo? +} + +struct NotificationSettingsScreenSettings { + let groupChatsMode: RoomNotificationModeProxy + let directChatsMode: RoomNotificationModeProxy + let roomMentionsEnabled: Bool? + let callsEnabled: Bool? + // Old clients were having specific settings for encrypted and unencrypted rooms, + // so it's possible for `group chats` and `direct chats` settings to be inconsistent (e.g. encrypted `direct chats` can have a different mode that unencrypted `direct chats`) + let inconsistentSettings: Bool } struct NotificationSettingsScreenStrings { @@ -45,9 +62,29 @@ struct NotificationSettingsScreenStrings { return text }() + + func string(for mode: RoomNotificationModeProxy) -> String { + switch mode { + case .allMessages: + return L10n.screenNotificationSettingsModeAll + case .mentionsAndKeywordsOnly: + return L10n.screenNotificationSettingsModeMentions + case .mute: + return L10n.commonMute + } + } } enum NotificationSettingsScreenViewAction { case linkClicked(url: URL) case changedEnableNotifications + case groupChatsTapped + case directChatsTapped + case roomMentionChanged + case callsChanged +} + +enum NotificationSettingsScreenErrorType: Hashable { + /// A specific error message shown in an alert. + case alert } diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift index 61fd96dae..464d2a1aa 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift @@ -23,14 +23,18 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy private var actionsSubject: PassthroughSubject = .init() private let appSettings: AppSettings private let userNotificationCenter: UserNotificationCenterProtocol + private let notificationSettingsProxy: NotificationSettingsProxyProtocol + @CancellableTask private var fetchSettingsTask: Task? var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(appSettings: AppSettings, userNotificationCenter: UserNotificationCenterProtocol) { + init(appSettings: AppSettings, userNotificationCenter: UserNotificationCenterProtocol, notificationSettingsProxy: NotificationSettingsProxyProtocol) { self.appSettings = appSettings self.userNotificationCenter = userNotificationCenter + self.notificationSettingsProxy = notificationSettingsProxy + let bindings = NotificationSettingsScreenViewStateBindings(enableNotifications: appSettings.enableNotifications) super.init(initialViewState: NotificationSettingsScreenViewState(bindings: bindings)) @@ -39,6 +43,50 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy .weakAssign(to: \.state.bindings.enableNotifications, on: self) .store(in: &cancellables) + setupDidBecomeActiveSubscription() + setupNotificationSettingsSubscription() + } + + func fetchInitialContent() { + fetchSettings() + } + + // MARK: - Public + + override func process(viewAction: NotificationSettingsScreenViewAction) { + switch viewAction { + case .linkClicked(let url): + MXLog.warning("Link clicked: \(url)") + case .changedEnableNotifications: + toggleNotifications() + case .groupChatsTapped: + break + case .directChatsTapped: + break + case .roomMentionChanged: + guard let settings = state.settings, settings.roomMentionsEnabled != state.bindings.roomMentionsEnabled else { + return + } + Task { await enableRoomMention(state.bindings.roomMentionsEnabled) } + case .callsChanged: + guard let settings = state.settings, settings.callsEnabled != state.bindings.callsEnabled else { + return + } + Task { await enableCalls(state.bindings.callsEnabled) } + } + } + + // MARK: - Private + + func readSystemAuthorizationStatus() async { + state.isUserPermissionGranted = await userNotificationCenter.authorizationStatus() == .authorized + } + + func toggleNotifications() { + appSettings.enableNotifications.toggle() + } + + private func setupDidBecomeActiveSubscription() { // Refresh authorization status uppon UIApplication.didBecomeActiveNotification notification NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) .sink { [weak self] _ in @@ -52,25 +100,88 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy await readSystemAuthorizationStatus() } } - - // MARK: - Public - override func process(viewAction: NotificationSettingsScreenViewAction) { - switch viewAction { - case .linkClicked(let url): - MXLog.warning("Link clicked: \(url)") - case .changedEnableNotifications: - toggleNotifications() + private func setupNotificationSettingsSubscription() { + notificationSettingsProxy.callbacks + .receive(on: DispatchQueue.main) + .sink { [weak self] callback in + guard let self else { return } + + switch callback { + case .settingsDidChange: + self.fetchSettings() + } + } + .store(in: &cancellables) + } + + private func fetchSettings() { + fetchSettingsTask = Task { + // Group chats + // A group chat is a chat having more than 2 active members + let groupChatActiveMembers: UInt64 = 3 + var groupChatsMode = await notificationSettingsProxy.getDefaultNotificationRoomMode(isEncrypted: false, activeMembersCount: groupChatActiveMembers) + let encryptedGroupChatsMode = await notificationSettingsProxy.getDefaultNotificationRoomMode(isEncrypted: true, activeMembersCount: groupChatActiveMembers) + + // Direct chats + // A direct chat is a chat having exactly 2 active members + let directChatActiveMembers: UInt64 = 2 + var directChatsMode = await notificationSettingsProxy.getDefaultNotificationRoomMode(isEncrypted: false, activeMembersCount: directChatActiveMembers) + let encryptedDirectChatsMode = await notificationSettingsProxy.getDefaultNotificationRoomMode(isEncrypted: true, activeMembersCount: directChatActiveMembers) + + // Old clients were having specific settings for encrypted and unencrypted rooms, + // so it's possible for `group chats` and `direct chats` settings to be inconsistent (e.g. encrypted `direct chats` can have a different mode that unencrypted `direct chats`) + var inconsistencyDetected = false + if groupChatsMode != encryptedGroupChatsMode { + groupChatsMode = .allMessages + inconsistencyDetected = true + } + if directChatsMode != encryptedDirectChatsMode { + directChatsMode = .allMessages + inconsistencyDetected = true + } + + // The following calls may fail if the associated push rule doesn't exist + let roomMentionsEnabled = try? await notificationSettingsProxy.isRoomMentionEnabled() + let callEnabled = try? await notificationSettingsProxy.isCallEnabled() + + guard !Task.isCancelled else { return } + + let notificationSettings = NotificationSettingsScreenSettings(groupChatsMode: groupChatsMode, + directChatsMode: directChatsMode, + roomMentionsEnabled: roomMentionsEnabled, + callsEnabled: callEnabled, + inconsistentSettings: inconsistencyDetected) + + state.settings = notificationSettings + state.bindings.roomMentionsEnabled = notificationSettings.roomMentionsEnabled ?? false + state.bindings.callsEnabled = notificationSettings.callsEnabled ?? false } } - // MARK: - Private - - func readSystemAuthorizationStatus() async { - state.isUserPermissionGranted = await userNotificationCenter.authorizationStatus() == .authorized + private func enableRoomMention(_ enable: Bool) async { + guard let notificationSettings = state.settings else { return } + do { + state.applyingChange = true + MXLog.info("[NotificationSettingsScreenViewMode] setRoomMentionEnabled(\(enable))") + try await notificationSettingsProxy.setRoomMentionEnabled(enabled: enable) + } catch { + state.bindings.alertInfo = AlertInfo(id: .alert) + state.bindings.roomMentionsEnabled = notificationSettings.roomMentionsEnabled ?? false + } + state.applyingChange = false } - - func toggleNotifications() { - appSettings.enableNotifications.toggle() + + func enableCalls(_ enable: Bool) async { + guard let notificationSettings = state.settings else { return } + do { + state.applyingChange = true + MXLog.info("[NotificationSettingsScreenViewMode] setCallEnabled(\(enable))") + try await notificationSettingsProxy.setCallEnabled(enabled: enable) + } catch { + state.bindings.alertInfo = AlertInfo(id: .alert) + state.bindings.callsEnabled = notificationSettings.callsEnabled ?? false + } + state.applyingChange = false } } diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModelProtocol.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModelProtocol.swift index 9694cf1cc..418527361 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModelProtocol.swift @@ -20,4 +20,6 @@ import Combine protocol NotificationSettingsScreenViewModelProtocol { var actions: AnyPublisher { get } var context: NotificationSettingsScreenViewModelType.Context { get } + + func fetchInitialContent() } diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift index 01dc2dc83..9efe10265 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift @@ -25,9 +25,19 @@ struct NotificationSettingsScreen: View { userPermissionSection } enableNotificationSection + if context.enableNotifications { + roomsNotificationSection + if context.viewState.settings?.roomMentionsEnabled != nil { + mentionsSection + } + if context.viewState.settings?.callsEnabled != nil { + callsSection + } + } } .compoundForm() .navigationTitle(L10n.screenNotificationSettingsTitle) + .alert(item: $context.alertInfo) .track(screen: .settingsNotifications) } @@ -69,6 +79,87 @@ struct NotificationSettingsScreen: View { } .compoundFormSection() } + + private var roomsNotificationSection: some View { + Section { + // Group chats + Button { + context.send(viewAction: .groupChatsTapped) + } label: { + LabeledContent { + if let settings = context.viewState.settings { + Text(context.viewState.strings.string(for: settings.groupChatsMode)) + } else { + ProgressView() + } + } label: { + Text(L10n.screenNotificationSettingsGroupChats) + } + } + .accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.notifications) + .buttonStyle(.compoundForm(accessory: context.viewState.settings.flatMap { _ in .navigationLink })) + .disabled(context.viewState.settings == nil) + + // Direct chats + Button { + context.send(viewAction: .directChatsTapped) + } label: { + LabeledContent { + if let settings = context.viewState.settings { + Text(context.viewState.strings.string(for: settings.directChatsMode)) + } else { + ProgressView() + } + } label: { + Text(L10n.screenNotificationSettingsDirectChats) + } + } + .accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.notifications) + .buttonStyle(.compoundForm(accessory: context.viewState.settings.flatMap { _ in .navigationLink })) + .disabled(context.viewState.settings == nil) + + } header: { + Text(L10n.screenNotificationSettingsNotificationSectionTitle) + .compoundFormSectionHeader() + } + .compoundFormSection() + } + + private var mentionsSection: some View { + Section { + Toggle(isOn: $context.roomMentionsEnabled) { + Text(L10n.screenNotificationSettingsRoomMentionLabel) + } + .toggleStyle(.compoundForm) + .onChange(of: context.roomMentionsEnabled) { _ in + context.send(viewAction: .roomMentionChanged) + } + .disabled(context.viewState.settings?.roomMentionsEnabled == nil) + .allowsHitTesting(!context.viewState.applyingChange) + } header: { + Text(L10n.screenNotificationSettingsMentionsSectionTitle) + .compoundFormSectionHeader() + } + .compoundFormSection() + } + + private var callsSection: some View { + Section { + Toggle(isOn: $context.callsEnabled) { + Text(L10n.screenNotificationSettingsCallsLabel) + } + .toggleStyle(.compoundForm) + .onChange(of: context.callsEnabled) { _ in + context.send(viewAction: .callsChanged) + } + .disabled(context.viewState.settings?.callsEnabled == nil) + .allowsHitTesting(!context.viewState.applyingChange) + } header: { + Text(L10n.screenNotificationSettingsAdditionalSettingsSectionTitle) + .compoundFormSectionHeader() + } + .compoundFormSection() + } } // MARK: - Previews @@ -78,7 +169,22 @@ struct NotificationSettingsScreen_Previews: PreviewProvider { let appSettings = AppSettings() let notificationCenter = UserNotificationCenterMock() notificationCenter.authorizationStatusReturnValue = .notDetermined - var viewModel = NotificationSettingsScreenViewModel(appSettings: appSettings, userNotificationCenter: notificationCenter) + let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init()) + notificationSettingsProxy.getDefaultNotificationRoomModeIsEncryptedActiveMembersCountClosure = { isEncrypted, activeMembersCount in + switch (isEncrypted, activeMembersCount) { + case (_, 2): + return .allMessages + default: + return .mentionsAndKeywordsOnly + } + } + notificationSettingsProxy.isRoomMentionEnabledReturnValue = true + notificationSettingsProxy.isCallEnabledReturnValue = false + + var viewModel = NotificationSettingsScreenViewModel(appSettings: appSettings, + userNotificationCenter: notificationCenter, + notificationSettingsProxy: notificationSettingsProxy) + viewModel.fetchInitialContent() return viewModel }() diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift index e0fa885ca..919fa8c07 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift @@ -21,6 +21,7 @@ struct SettingsScreenCoordinatorParameters { weak var userIndicatorController: UserIndicatorControllerProtocol? let userSession: UserSessionProtocol let bugReportService: BugReportServiceProtocol + let notificationSettings: NotificationSettingsProxyProtocol } enum SettingsScreenCoordinatorAction { @@ -140,9 +141,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { } private func presentNotificationSettings() { - let notificationParameters = NotificationSettingsScreenCoordinatorParameters( - userNotificationCenter: UNUserNotificationCenter.current() - ) + let notificationParameters = NotificationSettingsScreenCoordinatorParameters(userNotificationCenter: UNUserNotificationCenter.current(), + notificationSettings: parameters.notificationSettings) let coordinator = NotificationSettingsScreenCoordinator(parameters: notificationParameters) parameters.navigationStackCoordinator?.push(coordinator) } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index c6b7f5a25..0d2402228 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -169,7 +169,8 @@ class MockScreen: Identifiable { userIndicatorController: nil, userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()), - bugReportService: BugReportServiceMock())) + bugReportService: BugReportServiceMock(), + notificationSettings: NotificationSettingsProxyMock(with: .init()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .bugReport: @@ -195,7 +196,8 @@ class MockScreen: Identifiable { case .notificationSettingsScreen: let userNotificationCenter = UserNotificationCenterMock() userNotificationCenter.authorizationStatusReturnValue = .denied - let parameters = NotificationSettingsScreenCoordinatorParameters(userNotificationCenter: userNotificationCenter) + let parameters = NotificationSettingsScreenCoordinatorParameters(userNotificationCenter: userNotificationCenter, + notificationSettings: NotificationSettingsProxyMock(with: .init())) return NotificationSettingsScreenCoordinator(parameters: parameters) case .onboarding: return OnboardingCoordinator() diff --git a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift index 96fd9b486..02dd580de 100644 --- a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift +++ b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift @@ -95,6 +95,7 @@ final class NotificationManagerTests: XCTestCase { } func test_whenRegisteredAndPusherTagIsSetInSettings_tagNotGenerated() async throws { + notificationCenter.requestAuthorizationOptionsReturnValue = true appSettings.pusherProfileTag = "12345" _ = await notificationManager.register(with: Data()) XCTAssertEqual(appSettings.pusherProfileTag, "12345") diff --git a/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift b/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift index 6daa5567f..ee75a3e78 100644 --- a/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import MatrixRustSDK import XCTest @testable import ElementX @@ -24,6 +25,7 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { private var context: NotificationSettingsScreenViewModelType.Context! private var appSettings: AppSettings! private var userNotificationCenter: UserNotificationCenterMock! + private var notificationSettingsProxy: NotificationSettingsProxyMock! @MainActor override func setUpWithError() throws { AppSettings.reset() @@ -31,8 +33,14 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { userNotificationCenter = UserNotificationCenterMock() userNotificationCenter.authorizationStatusReturnValue = .authorized appSettings = AppSettings() + notificationSettingsProxy = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()) + notificationSettingsProxy.getDefaultNotificationRoomModeIsEncryptedActiveMembersCountReturnValue = .allMessages + notificationSettingsProxy.isRoomMentionEnabledReturnValue = true + notificationSettingsProxy.isCallEnabledReturnValue = true + viewModel = NotificationSettingsScreenViewModel(appSettings: appSettings, - userNotificationCenter: userNotificationCenter) + userNotificationCenter: userNotificationCenter, + notificationSettingsProxy: notificationSettingsProxy) context = viewModel.context } @@ -42,9 +50,183 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { XCTAssertTrue(appSettings.enableNotifications) } - func testOptOut() { + func testDisableNotifications() { appSettings.enableNotifications = true context.send(viewAction: .changedEnableNotifications) XCTAssertFalse(appSettings.enableNotifications) } + + func testFetchSettings() async throws { + notificationSettingsProxy.getDefaultNotificationRoomModeIsEncryptedActiveMembersCountClosure = { isEncrypted, activeMembersCount in + switch (isEncrypted, activeMembersCount) { + case (_, 2): + return .allMessages + case (_, _): + return .mentionsAndKeywordsOnly + } + } + let deferred = deferFulfillment(viewModel.context.$viewState.map(\.settings) + .first(where: { $0 != nil })) + notificationSettingsProxy.callbacks.send(.settingsDidChange) + try await deferred.fulfill() + + XCTAssertEqual(notificationSettingsProxy.getDefaultNotificationRoomModeIsEncryptedActiveMembersCountCallsCount, 4) + XCTAssert(notificationSettingsProxy.isRoomMentionEnabledCalled) + XCTAssert(notificationSettingsProxy.isCallEnabledCalled) + + XCTAssertEqual(context.viewState.settings?.groupChatsMode, .mentionsAndKeywordsOnly) + XCTAssertEqual(context.viewState.settings?.directChatsMode, .allMessages) + XCTAssertEqual(context.viewState.settings?.inconsistentSettings, false) + XCTAssertNil(context.viewState.bindings.alertInfo) + } + + func testInconsistentGroupChatsSettings() async throws { + notificationSettingsProxy.getDefaultNotificationRoomModeIsEncryptedActiveMembersCountClosure = { isEncrypted, activeMembersCount in + switch (isEncrypted, activeMembersCount) { + case (true, 3): + return .allMessages + case (false, 3): + return .mentionsAndKeywordsOnly + default: + return .allMessages + } + } + + let deferred = deferFulfillment(viewModel.context.$viewState.map(\.settings) + .first(where: { $0 != nil })) + notificationSettingsProxy.callbacks.send(.settingsDidChange) + try await deferred.fulfill() + + XCTAssertEqual(context.viewState.settings?.groupChatsMode, .allMessages) + XCTAssertEqual(context.viewState.settings?.inconsistentSettings, true) + } + + func testInconsistentDirectChatsSettings() async throws { + notificationSettingsProxy.getDefaultNotificationRoomModeIsEncryptedActiveMembersCountClosure = { isEncrypted, activeMembersCount in + switch (isEncrypted, activeMembersCount) { + case (true, 2): + return .allMessages + case (false, 2): + return .mentionsAndKeywordsOnly + default: + return .allMessages + } + } + + let deferred = deferFulfillment(viewModel.context.$viewState.map(\.settings) + .first(where: { $0 != nil })) + notificationSettingsProxy.callbacks.send(.settingsDidChange) + try await deferred.fulfill() + + XCTAssertEqual(context.viewState.settings?.directChatsMode, .allMessages) + XCTAssertEqual(context.viewState.settings?.inconsistentSettings, true) + } + + func testToggleRoomMentionOff() async throws { + notificationSettingsProxy.isRoomMentionEnabledReturnValue = true + let deferredInitialFetch = deferFulfillment(viewModel.context.$viewState.map(\.settings) + .first(where: { $0 != nil })) + notificationSettingsProxy.callbacks.send(.settingsDidChange) + try await deferredInitialFetch.fulfill() + + context.roomMentionsEnabled = false + let deferred = deferFulfillment(notificationSettingsProxy.callbacks + .first(where: { $0 == .settingsDidChange })) + context.send(viewAction: .roomMentionChanged) + try await deferred.fulfill() + + XCTAssert(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled) + XCTAssertEqual(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled, false) + } + + func testToggleRoomMentionOn() async throws { + notificationSettingsProxy.isRoomMentionEnabledReturnValue = false + let deferredInitialFetch = deferFulfillment(viewModel.context.$viewState.map(\.settings) + .first(where: { $0 != nil })) + viewModel.fetchInitialContent() + try await deferredInitialFetch.fulfill() + + context.roomMentionsEnabled = true + let deferred = deferFulfillment(notificationSettingsProxy.callbacks + .first(where: { $0 == .settingsDidChange })) + context.send(viewAction: .roomMentionChanged) + try await deferred.fulfill() + + XCTAssert(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled) + XCTAssertEqual(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled, true) + } + + func testToggleRoomMentionFailure() async throws { + notificationSettingsProxy.setRoomMentionEnabledEnabledThrowableError = NotificationSettingsError.Generic(message: "error") + notificationSettingsProxy.isRoomMentionEnabledReturnValue = false + let deferredInitialFetch = deferFulfillment(viewModel.context.$viewState.map(\.settings) + .first(where: { $0 != nil })) + viewModel.fetchInitialContent() + try await deferredInitialFetch.fulfill() + + context.roomMentionsEnabled = true + let deferred = deferFulfillment(context.$viewState.map(\.applyingChange) + .removeDuplicates() + .collect(3) + .first()) + context.send(viewAction: .roomMentionChanged) + let states = try await deferred.fulfill() + + XCTAssertEqual(states, [false, true, false]) + XCTAssertNotNil(context.alertInfo) + } + + func testToggleCallsOff() async throws { + notificationSettingsProxy.isCallEnabledReturnValue = true + let deferredInitialFetch = deferFulfillment(viewModel.context.$viewState.map(\.settings) + .first(where: { $0 != nil })) + viewModel.fetchInitialContent() + try await deferredInitialFetch.fulfill() + + context.callsEnabled = false + let deferred = deferFulfillment(notificationSettingsProxy.callbacks + .first(where: { $0 == .settingsDidChange })) + context.send(viewAction: .callsChanged) + try await deferred.fulfill() + + XCTAssert(notificationSettingsProxy.setCallEnabledEnabledCalled) + XCTAssertEqual(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled, false) + } + + func testToggleCallsOn() async throws { + notificationSettingsProxy.isCallEnabledReturnValue = false + let deferredInitialFetch = deferFulfillment(viewModel.context.$viewState.map(\.settings) + .first(where: { $0 != nil })) + viewModel.fetchInitialContent() + try await deferredInitialFetch.fulfill() + + context.callsEnabled = true + let deferred = deferFulfillment(notificationSettingsProxy.callbacks + .first(where: { $0 == .settingsDidChange })) + context.send(viewAction: .callsChanged) + try await deferred.fulfill() + + XCTAssert(notificationSettingsProxy.setCallEnabledEnabledCalled) + XCTAssertEqual(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled, true) + } + + func testToggleCallsFailure() async throws { + notificationSettingsProxy.setCallEnabledEnabledThrowableError = NotificationSettingsError.Generic(message: "error") + notificationSettingsProxy.isCallEnabledReturnValue = false + let deferredInitialFetch = deferFulfillment(viewModel.context.$viewState.map(\.settings) + .first(where: { $0 != nil })) + viewModel.fetchInitialContent() + try await deferredInitialFetch.fulfill() + + context.callsEnabled = true + let deferred = deferFulfillment(context.$viewState.map(\.applyingChange) + .removeDuplicates() + .collect(3) + .first()) + context.send(viewAction: .callsChanged) + let states = try await deferred.fulfill() + + XCTAssertEqual(states, [false, true, false]) + XCTAssertNotNil(context.alertInfo) + } }