Merge commit 'aaadcc73674cc8886e363693a7d7c08ac9b4f516' into feature/4260_merge_foss_1_10_2

# Conflicts:
#	Config/AppVersion.xcconfig
#	Podfile
#	Podfile.lock
#	Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved
#	Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift
#	Riot/Modules/Application/LegacyAppDelegate.m
#	Riot/Modules/Authentication/AuthenticationCoordinator.swift
#	Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift
#	Riot/Modules/ContextMenu/ActionProviders/RoomActionProvider.swift
#	Riot/Modules/Home/AllChats/AllChatsViewController.swift
#	Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift
#	Riot/Modules/Room/RoomInfo/RoomInfoList/RoomInfoListViewController.swift
#	Riot/Modules/Room/Settings/RoomSettingsViewController.m
#	fastlane/Fastfile
This commit is contained in:
JanNiklas Grabowski
2023-02-15 14:56:55 +01:00
279 changed files with 7285 additions and 2433 deletions
@@ -41,6 +41,10 @@ extension MXPushRule: NotificationPushRuleType {
return false
}
var ruleActions: NotificationActions? {
.init(notify: notify, highlight: highlight, sound: sound)
}
private func getAction(actionType: MXPushRuleActionType, tweakType: String? = nil) -> MXPushRuleAction? {
guard let actions = actions as? [MXPushRuleAction] else {
return nil
@@ -16,10 +16,12 @@
import Foundation
struct MockNotificationPushRule: NotificationPushRuleType {
struct MockNotificationPushRule: NotificationPushRuleType, Equatable {
var ruleId: String!
var enabled: Bool
var ruleActions: NotificationActions? = NotificationStandardActions.notifyDefaultSound.actions
func matches(standardActions: NotificationStandardActions?) -> Bool {
false
standardActions?.actions == ruleActions
}
}
@@ -17,7 +17,7 @@
import Foundation
/// The actions defined on a push rule, used in the static push rule definitions.
struct NotificationActions {
struct NotificationActions: Equatable {
let notify: Bool
let highlight: Bool
let sound: String?
@@ -22,7 +22,7 @@ extension NotificationPushRuleId {
/// It is defined similarly across Web and Android.
/// - Parameter index: The notification index for which to get the actions for.
/// - Returns: The associated `NotificationStandardActions`.
func standardActions(for index: NotificationIndex) -> NotificationStandardActions? {
func standardActions(for index: NotificationIndex) -> NotificationStandardActions {
switch self {
case .containDisplayName:
switch index {
@@ -42,7 +42,7 @@ extension NotificationPushRuleId {
case .silent: return .notify
case .noisy: return .highlight
}
case .oneToOneRoom:
case .oneToOneRoom, .oneToOnePollStart, .msc3930oneToOnePollStart, .oneToOnePollEnd, .msc3930oneToOnePollEnd:
switch index {
case .off: return .dontNotify
case .silent: return .notify
@@ -54,7 +54,7 @@ extension NotificationPushRuleId {
case .silent: return .notify
case .noisy: return .notifyDefaultSound
}
case .allOtherMessages:
case .allOtherMessages, .pollStart, .msc3930pollStart, .pollEnd, .msc3930pollEnd:
switch index {
case .off: return .dontNotify
case .silent: return .notify
@@ -30,6 +30,18 @@ enum NotificationPushRuleId: String {
case allOtherMessages = ".m.rule.message"
case encrypted = ".m.rule.encrypted"
case keywords = "_keywords"
// poll started event
case pollStart = ".m.rule.poll_start"
case msc3930pollStart = ".org.matrix.msc3930.rule.poll_start"
// poll started event (one to one)
case oneToOnePollStart = ".m.rule.poll_start_one_to_one"
case msc3930oneToOnePollStart = ".org.matrix.msc3930.rule.poll_start_one_to_one"
// poll ended event
case pollEnd = ".m.rule.poll_end"
case msc3930pollEnd = ".org.matrix.msc3930.rule.poll_end"
// poll ended event (one to one)
case oneToOnePollEnd = ".m.rule.poll_end_one_to_one"
case msc3930oneToOnePollEnd = ".org.matrix.msc3930.rule.poll_end_one_to_one"
}
extension NotificationPushRuleId: Identifiable {
@@ -65,6 +77,20 @@ extension NotificationPushRuleId {
return VectorL10n.settingsEncryptedGroupMessages
case .keywords:
return VectorL10n.settingsMessagesContainingKeywords
case .pollStart, .msc3930pollStart, .oneToOnePollStart, .msc3930oneToOnePollStart, .pollEnd, .msc3930pollEnd, .oneToOnePollEnd, .msc3930oneToOnePollEnd:
// They don't need to be rendered on the UI
return ""
}
}
var syncedRules: [NotificationPushRuleId] {
switch self {
case .oneToOneRoom:
return [.oneToOnePollStart, .msc3930oneToOnePollStart, .oneToOnePollEnd, .msc3930oneToOnePollEnd]
case .allOtherMessages:
return [.pollStart, .msc3930pollStart, .pollEnd, .msc3930pollEnd]
default:
return []
}
}
}
@@ -19,5 +19,13 @@ import Foundation
protocol NotificationPushRuleType {
var ruleId: String! { get }
var enabled: Bool { get }
var ruleActions: NotificationActions? { get }
func matches(standardActions: NotificationStandardActions?) -> Bool
}
extension NotificationPushRuleType {
var pushRuleId: NotificationPushRuleId? {
ruleId.flatMap(NotificationPushRuleId.init(rawValue:))
}
}
@@ -44,7 +44,9 @@ class MXNotificationSettingsService: NotificationSettingsServiceType {
// Observe future updates to content rules
rulesUpdated
.compactMap { _ in self.session.notificationCenter.rules.global.content as? [MXPushRule] }
.compactMap { [weak self] _ in
self?.session.notificationCenter.rules.global.content as? [MXPushRule]
}
.assign(to: &$contentRules)
// Set initial value of rules
@@ -53,14 +55,15 @@ class MXNotificationSettingsService: NotificationSettingsServiceType {
}
// Observe future updates to rules
rulesUpdated
.compactMap { _ in self.session.notificationCenter.flatRules as? [MXPushRule] }
.compactMap { [weak self] _ in
self?.session.notificationCenter.flatRules as? [MXPushRule]
}
.assign(to: &$rules)
}
func add(keyword: String, enabled: Bool) {
let index = NotificationIndex.index(when: enabled)
guard let actions = NotificationPushRuleId.keywords.standardActions(for: index)?.actions
else {
guard let actions = NotificationPushRuleId.keywords.standardActions(for: index).actions else {
return
}
session.notificationCenter.addContentRuleWithRuleId(matchingPattern: keyword, notify: actions.notify, sound: actions.sound, highlight: actions.highlight)
@@ -71,16 +74,52 @@ class MXNotificationSettingsService: NotificationSettingsServiceType {
session.notificationCenter.removeRule(rule)
}
func updatePushRuleActions(for ruleId: String, enabled: Bool, actions: NotificationActions?) {
guard let rule = session.notificationCenter.rule(byId: ruleId) else { return }
session.notificationCenter.enableRule(rule, isEnabled: enabled)
func updatePushRuleActions(for ruleId: String,
enabled: Bool,
actions: NotificationActions?) async throws {
if let actions = actions {
session.notificationCenter.updatePushRuleActions(ruleId,
kind: rule.kind,
notify: actions.notify,
soundName: actions.sound,
highlight: actions.highlight)
guard let rule = session.notificationCenter.rule(byId: ruleId) else {
return
}
guard let actions = actions else {
try await session.notificationCenter.enableRule(pushRule: rule, isEnabled: enabled)
return
}
// Updating the actions before enabling the rule allows the homeserver to triggers just one sync update
try await session.notificationCenter.updatePushRuleActions(ruleId,
kind: rule.kind,
notify: actions.notify,
soundName: actions.sound,
highlight: actions.highlight)
try await session.notificationCenter.enableRule(pushRule: rule, isEnabled: enabled)
}
}
private extension MXNotificationCenter {
func enableRule(pushRule: MXPushRule, isEnabled: Bool) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
enableRule(pushRule, isEnabled: isEnabled) { error in
if let error = error {
continuation.resume(with: .failure(error))
} else {
continuation.resume()
}
}
}
}
func updatePushRuleActions(ruleId: String, kind: __MXPushRuleKind, notify: Bool, soundName: String, highlight: Bool) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
updatePushRuleActions(ruleId, kind: kind, notify: notify, soundName: soundName, highlight: highlight) { error in
if let error = error {
continuation.resume(with: .failure(error))
} else {
continuation.resume()
}
}
}
}
}
@@ -44,5 +44,11 @@ class MockNotificationSettingsService: NotificationSettingsServiceType, Observab
keywords.remove(keyword)
}
func updatePushRuleActions(for ruleId: String, enabled: Bool, actions: NotificationActions?) { }
func updatePushRuleActions(for ruleId: String, enabled: Bool, actions: NotificationActions?) async throws {
guard let ruleIndex = rules.firstIndex(where: { $0.ruleId == ruleId }) else {
return
}
rules[ruleIndex] = MockNotificationPushRule(ruleId: ruleId, enabled: enabled, ruleActions: actions)
}
}
@@ -40,5 +40,5 @@ protocol NotificationSettingsServiceType {
/// - ruleId: The id of the rule.
/// - enabled: Whether the rule should be enabled or disabled.
/// - actions: The actions to update with.
func updatePushRuleActions(for ruleId: String, enabled: Bool, actions: NotificationActions?)
func updatePushRuleActions(for ruleId: String, enabled: Bool, actions: NotificationActions?) async throws
}
@@ -0,0 +1,140 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
@testable import RiotSwiftUI
import XCTest
final class NotificationSettingsViewModelTests: XCTestCase {
private var viewModel: NotificationSettingsViewModel!
private var notificationService: MockNotificationSettingsService!
override func setUpWithError() throws {
notificationService = .init()
}
func testAllTheRulesAreChecked() throws {
viewModel = .init(notificationSettingsService: notificationService, ruleIds: .default)
XCTAssertEqual(viewModel.viewState.selectionState.count, 4)
XCTAssertTrue(viewModel.viewState.selectionState.values.allSatisfy { $0 })
}
func testUpdateRule() async {
viewModel = .init(notificationSettingsService: notificationService, ruleIds: .default)
notificationService.rules = [MockNotificationPushRule].default
await viewModel.update(ruleID: .encrypted, isChecked: false)
XCTAssertEqual(viewModel.viewState.selectionState.count, 4)
XCTAssertEqual(viewModel.viewState.selectionState[.encrypted], false)
}
func testUpdateOneToOneRuleAlsoUpdatesPollRules() async {
setupWithPollRules()
await viewModel.update(ruleID: .oneToOneRoom, isChecked: false)
XCTAssertEqual(viewModel.viewState.selectionState.count, 8)
XCTAssertEqual(viewModel.viewState.selectionState[.oneToOneRoom], false)
XCTAssertEqual(viewModel.viewState.selectionState[.oneToOnePollStart], false)
XCTAssertEqual(viewModel.viewState.selectionState[.oneToOnePollEnd], false)
// unrelated poll rules stay the same
XCTAssertEqual(viewModel.viewState.selectionState[.allOtherMessages], true)
XCTAssertEqual(viewModel.viewState.selectionState[.pollStart], true)
XCTAssertEqual(viewModel.viewState.selectionState[.pollEnd], true)
}
func testUpdateMessageRuleAlsoUpdatesPollRules() async {
setupWithPollRules()
await viewModel.update(ruleID: .allOtherMessages, isChecked: false)
XCTAssertEqual(viewModel.viewState.selectionState.count, 8)
XCTAssertEqual(viewModel.viewState.selectionState[.allOtherMessages], false)
XCTAssertEqual(viewModel.viewState.selectionState[.pollStart], false)
XCTAssertEqual(viewModel.viewState.selectionState[.pollEnd], false)
// unrelated poll rules stay the same
XCTAssertEqual(viewModel.viewState.selectionState[.oneToOneRoom], true)
XCTAssertEqual(viewModel.viewState.selectionState[.oneToOnePollStart], true)
XCTAssertEqual(viewModel.viewState.selectionState[.oneToOnePollEnd], true)
}
func testMismatchingRulesAreHandled() async {
setupWithPollRules()
await viewModel.update(ruleID: .allOtherMessages, isChecked: false)
// simulating a "mismatch" on the poll started rule
await viewModel.update(ruleID: .pollStart, isChecked: true)
XCTAssertEqual(viewModel.viewState.selectionState.count, 8)
// The other messages rule ui flag should match the loudest related poll rule
XCTAssertEqual(viewModel.viewState.selectionState[.allOtherMessages], true)
}
func testMismatchingOneToOneRulesAreHandled() async {
setupWithPollRules()
await viewModel.update(ruleID: .oneToOneRoom, isChecked: false)
// simulating a "mismatch" on the one to one poll started rule
await viewModel.update(ruleID: .oneToOnePollStart, isChecked: true)
XCTAssertEqual(viewModel.viewState.selectionState.count, 8)
// The one to one room rule ui flag should match the loudest related poll rule
XCTAssertEqual(viewModel.viewState.selectionState[.oneToOneRoom], true)
// the oneToOneRoom rule should be flagged as "out of sync"
XCTAssertTrue(viewModel.isRuleOutOfSync(.oneToOneRoom))
XCTAssertFalse(viewModel.isRuleOutOfSync(.allOtherMessages))
}
}
private extension NotificationSettingsViewModelTests {
func setupWithPollRules() {
viewModel = .init(notificationSettingsService: notificationService, ruleIds: .default + .polls)
notificationService.rules = [MockNotificationPushRule].default + [MockNotificationPushRule].polls
}
}
private extension Array where Element == NotificationPushRuleId {
static var `default`: [NotificationPushRuleId] {
[.oneToOneRoom, .allOtherMessages, .oneToOneEncryptedRoom, .encrypted]
}
static var polls: [NotificationPushRuleId] {
[.pollStart, .pollEnd, .oneToOnePollStart, .oneToOnePollEnd]
}
}
private extension Array where Element == MockNotificationPushRule {
static var `default`: [MockNotificationPushRule] {
[NotificationPushRuleId]
.default
.map { ruleId in
MockNotificationPushRule(ruleId: ruleId.rawValue, enabled: true)
}
}
static var polls: [MockNotificationPushRule] {
[NotificationPushRuleId]
.polls
.map { ruleId in
MockNotificationPushRule(ruleId: ruleId.rawValue, enabled: true)
}
}
}
@@ -21,6 +21,7 @@ import SwiftUI
/// Also renders an optional bottom section.
/// Used in the case of keywords, for the keyword chips and input.
struct NotificationSettings<BottomSection: View>: View {
@Environment(\.theme) var theme: ThemeSwiftUI
@ObservedObject var viewModel: NotificationSettingsViewModel
var bottomSection: BottomSection?
@@ -31,15 +32,28 @@ struct NotificationSettings<BottomSection: View>: View {
header: FormSectionHeader(text: VectorL10n.settingsNotifyMeFor)
) {
ForEach(viewModel.viewState.ruleIds) { ruleId in
let checked = viewModel.viewState.selectionState[ruleId] ?? false
FormPickerItem(title: ruleId.title, selected: checked) {
viewModel.update(ruleID: ruleId, isChecked: !checked)
VStack(alignment: .leading, spacing: 4) {
let checked = viewModel.viewState.selectionState[ruleId] ?? false
FormPickerItem(title: ruleId.title, selected: checked) {
Task {
await viewModel.update(ruleID: ruleId, isChecked: !checked)
}
}
if viewModel.isRuleOutOfSync(ruleId) {
Text(VectorL10n.settingsPushRulesError)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.alert)
.padding(.horizontal)
.padding(.bottom, 16)
}
}
}
}
bottomSection
}
.activityIndicator(show: viewModel.viewState.saving)
.disabled(viewModel.viewState.saving)
}
}
@@ -49,7 +49,9 @@ final class NotificationSettingsViewModel: NotificationSettingsViewModelType, Ob
// Observe when the rules are updated, to subsequently update the state of the settings.
notificationSettingsService.rulesPublisher
.sink(receiveValue: rulesUpdated(newRules:))
.sink { [weak self] newRules in
self?.rulesUpdated(newRules: newRules)
}
.store(in: &cancellables)
// Only observe keywords if the current settings view displays it.
@@ -88,7 +90,9 @@ final class NotificationSettingsViewModel: NotificationSettingsViewModelType, Ob
// Keyword rules were updates, check if we need to update the setting.
keywordsRules
.map { $0.contains { $0.enabled } }
.sink(receiveValue: keywordRuleUpdated(anyEnabled:))
.sink { [weak self] in
self?.keywordRuleUpdated(anyEnabled: $0)
}
.store(in: &cancellables)
// Update the viewState with the final keywords to be displayed.
@@ -105,35 +109,27 @@ final class NotificationSettingsViewModel: NotificationSettingsViewModelType, Ob
// MARK: - Public
func update(ruleID: NotificationPushRuleId, isChecked: Bool) {
@MainActor
func update(ruleID: NotificationPushRuleId, isChecked: Bool) async {
let index = NotificationIndex.index(when: isChecked)
if ruleID == .keywords {
// Keywords is handled differently to other settings
updateKeywords(isChecked: isChecked)
return
}
// Get the static definition and update the actions and enabled state.
guard let standardActions = ruleID.standardActions(for: index) else { return }
let standardActions = ruleID.standardActions(for: index)
let enabled = standardActions != .disabled
notificationSettingsService.updatePushRuleActions(
for: ruleID.rawValue,
enabled: enabled,
actions: standardActions.actions
)
}
private func updateKeywords(isChecked: Bool) {
guard !keywordsOrdered.isEmpty else {
viewState.selectionState[.keywords]?.toggle()
return
}
// Get the static definition and update the actions and enabled state for every keyword.
let index = NotificationIndex.index(when: isChecked)
guard let standardActions = NotificationPushRuleId.keywords.standardActions(for: index) else { return }
let enabled = standardActions != .disabled
keywordsOrdered.forEach { keyword in
notificationSettingsService.updatePushRuleActions(
for: keyword,
switch ruleID {
case .keywords: // Keywords is handled differently to other settings
await updateKeywords(isChecked: isChecked)
case .oneToOneRoom, .allOtherMessages:
await updatePushAction(
id: ruleID,
enabled: enabled,
standardActions: standardActions,
then: ruleID.syncedRules
)
default:
try? await notificationSettingsService.updatePushRuleActions(
for: ruleID.rawValue,
enabled: enabled,
actions: standardActions.actions
)
@@ -152,17 +148,94 @@ final class NotificationSettingsViewModel: NotificationSettingsViewModelType, Ob
notificationSettingsService.remove(keyword: keyword)
}
// MARK: - Private
func isRuleOutOfSync(_ ruleId: NotificationPushRuleId) -> Bool {
viewState.outOfSyncRules.contains(ruleId) && viewState.saving == false
}
}
private func rulesUpdated(newRules: [NotificationPushRuleType]) {
for rule in newRules {
guard let ruleId = NotificationPushRuleId(rawValue: rule.ruleId),
ruleIds.contains(ruleId) else { continue }
viewState.selectionState[ruleId] = isChecked(rule: rule)
// MARK: - Private
private extension NotificationSettingsViewModel {
@MainActor
func updateKeywords(isChecked: Bool) async {
guard !keywordsOrdered.isEmpty else {
viewState.selectionState[.keywords]?.toggle()
return
}
// Get the static definition and update the actions and enabled state for every keyword.
let index = NotificationIndex.index(when: isChecked)
let standardActions = NotificationPushRuleId.keywords.standardActions(for: index)
let enabled = standardActions != .disabled
let keywordsToUpdate = keywordsOrdered
await withThrowingTaskGroup(of: Void.self) { group in
for keyword in keywordsToUpdate {
group.addTask {
try await self.notificationSettingsService.updatePushRuleActions(
for: keyword,
enabled: enabled,
actions: standardActions.actions
)
}
}
}
}
func updatePushAction(id: NotificationPushRuleId,
enabled: Bool,
standardActions: NotificationStandardActions,
then rules: [NotificationPushRuleId]) async {
await MainActor.run {
viewState.saving = true
}
do {
// update the 'parent rule' first
try await notificationSettingsService.updatePushRuleActions(for: id.rawValue, enabled: enabled, actions: standardActions.actions)
// synchronize all the 'children rules' with the parent rule
await withThrowingTaskGroup(of: Void.self) { group in
for ruleId in rules {
group.addTask {
try await self.notificationSettingsService.updatePushRuleActions(for: ruleId.rawValue, enabled: enabled, actions: standardActions.actions)
}
}
}
await completeUpdate()
} catch {
await completeUpdate()
}
}
private func keywordRuleUpdated(anyEnabled: Bool) {
@MainActor
func completeUpdate() {
viewState.saving = false
}
func rulesUpdated(newRules: [NotificationPushRuleType]) {
var outOfSyncRules: Set<NotificationPushRuleId> = .init()
for rule in newRules {
guard
let ruleId = rule.pushRuleId,
ruleIds.contains(ruleId)
else {
continue
}
let relatedSyncedRules = ruleId.syncedRules(in: newRules)
viewState.selectionState[ruleId] = isChecked(rule: rule, syncedRules: relatedSyncedRules)
if isOutOfSync(rule: rule, syncedRules: relatedSyncedRules) {
outOfSyncRules.insert(ruleId)
}
}
viewState.outOfSyncRules = outOfSyncRules
}
func keywordRuleUpdated(anyEnabled: Bool) {
if !keywordsOrdered.isEmpty {
viewState.selectionState[.keywords] = anyEnabled
}
@@ -174,8 +247,10 @@ final class NotificationSettingsViewModel: NotificationSettingsViewModelType, Ob
/// The same logic is used on android.
/// - Parameter rule: The push rule type to check.
/// - Returns: Wether it should be displayed as checked or not checked.
private func isChecked(rule: NotificationPushRuleType) -> Bool {
guard let ruleId = NotificationPushRuleId(rawValue: rule.ruleId) else { return false }
func defaultIsChecked(rule: NotificationPushRuleType) -> Bool {
guard let ruleId = rule.pushRuleId else {
return false
}
let firstIndex = NotificationIndex.allCases.first { nextIndex in
rule.matches(standardActions: ruleId.standardActions(for: nextIndex))
@@ -187,4 +262,45 @@ final class NotificationSettingsViewModel: NotificationSettingsViewModelType, Ob
return index.enabled
}
func isChecked(rule: NotificationPushRuleType, syncedRules: [NotificationPushRuleType]) -> Bool {
guard let ruleId = rule.pushRuleId else {
return false
}
switch ruleId {
case .oneToOneRoom, .allOtherMessages:
let ruleIsChecked = defaultIsChecked(rule: rule)
let someSyncedRuleIsChecked = syncedRules.contains(where: { defaultIsChecked(rule: $0) })
// The "loudest" rule will be applied when there is a clash between a rule and its dependent rules.
return ruleIsChecked || someSyncedRuleIsChecked
default:
return defaultIsChecked(rule: rule)
}
}
func isOutOfSync(rule: NotificationPushRuleType, syncedRules: [NotificationPushRuleType]) -> Bool {
guard let ruleId = rule.pushRuleId else {
return false
}
switch ruleId {
case .oneToOneRoom, .allOtherMessages:
let ruleIsChecked = defaultIsChecked(rule: rule)
return syncedRules.contains(where: { defaultIsChecked(rule: $0) != ruleIsChecked })
default:
return false
}
}
}
extension NotificationPushRuleId {
func syncedRules(in rules: [NotificationPushRuleType]) -> [NotificationPushRuleType] {
rules.filter {
guard let ruleId = $0.pushRuleId else {
return false
}
return syncedRules.contains(ruleId)
}
}
}
@@ -22,5 +22,6 @@ struct NotificationSettingsViewState {
var saving: Bool
var ruleIds: [NotificationPushRuleId]
var selectionState: [NotificationPushRuleId: Bool]
var outOfSyncRules: Set<NotificationPushRuleId> = .init()
var keywords = [String]()
}