diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 2b752068f..d446f7876 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -508,15 +508,17 @@ Tap the + to start adding people."; "settings_encrypted_direct_messages" = "Encrypted direct messages"; "settings_group_messages" = "Group messages"; "settings_encrypted_group_messages" = "Encrypted group messages"; -"settings_messages_containing_display_name" = "Messages containing my display name"; -"settings_messages_containing_user_name" = "Messages containing my display name"; -"settings_messages_containing_at_room" = "Messages containing @room"; -"settings_messages_containing_keywords" = "Messages containing keywords"; +"settings_messages_containing_display_name" = "My display name"; +"settings_messages_containing_user_name" = "My username"; +"settings_messages_containing_at_room" = "@room"; +"settings_messages_containing_keywords" = "Keywords"; "settings_room_invitations" = "Room invitations"; "settings_call_invitations" = "Call invitations"; "settings_messages_by_a_bot" = "Messages by a bot"; "settings_room_upgrades" = "Room upgrades"; - +"settings_your_keywords" = "Your Keywords"; +"settings_new_keyword" = "Add new Keyword"; +"settings_mentions_and_keywords_encryption_notice" = "You won’t get notifications for mentions & keywords in encrypted rooms on mobile."; "settings_enable_callkit" = "Integrated calling"; "settings_callkit_info" = "Receive incoming calls on your lock screen. See your Element calls in the system's call history. If iCloud is enabled, this call history will be shared with Apple."; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index f3be1a8c7..67e81de32 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -4426,26 +4426,34 @@ internal enum VectorL10n { internal static var settingsMentionsAndKeywords: String { return VectorL10n.tr("Vector", "settings_mentions_and_keywords") } + /// You won’t get notifications for mentions & keywords in encrypted rooms on mobile. + internal static var settingsMentionsAndKeywordsEncryptionNotice: String { + return VectorL10n.tr("Vector", "settings_mentions_and_keywords_encryption_notice") + } /// Messages by a bot internal static var settingsMessagesByABot: String { return VectorL10n.tr("Vector", "settings_messages_by_a_bot") } - /// Messages containing @room + /// @room internal static var settingsMessagesContainingAtRoom: String { return VectorL10n.tr("Vector", "settings_messages_containing_at_room") } - /// Messages containing my display name + /// My display name internal static var settingsMessagesContainingDisplayName: String { return VectorL10n.tr("Vector", "settings_messages_containing_display_name") } - /// Messages containing keywords + /// Keywords internal static var settingsMessagesContainingKeywords: String { return VectorL10n.tr("Vector", "settings_messages_containing_keywords") } - /// Messages containing my display name + /// My username internal static var settingsMessagesContainingUserName: String { return VectorL10n.tr("Vector", "settings_messages_containing_user_name") } + /// Add new Keyword + internal static var settingsNewKeyword: String { + return VectorL10n.tr("Vector", "settings_new_keyword") + } /// new password internal static var settingsNewPassword: String { return VectorL10n.tr("Vector", "settings_new_password") @@ -4642,6 +4650,10 @@ internal enum VectorL10n { internal static func settingsVersion(_ p1: String) -> String { return VectorL10n.tr("Vector", "settings_version", p1) } + /// Your Keywords + internal static var settingsYourKeywords: String { + return VectorL10n.tr("Vector", "settings_your_keywords") + } /// Login in the main app to share content internal static var shareExtensionAuthPrompt: String { return VectorL10n.tr("Vector", "share_extension_auth_prompt") diff --git a/Riot/Modules/Settings/Notifications/Mock/MockNotificationSettingsService.swift b/Riot/Modules/Settings/Notifications/Mock/MockNotificationSettingsService.swift new file mode 100644 index 000000000..028f3059b --- /dev/null +++ b/Riot/Modules/Settings/Notifications/Mock/MockNotificationSettingsService.swift @@ -0,0 +1,51 @@ +// +// Copyright 2021 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. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +class MockNotificationSettingsService: NotificationSettingsServiceType, ObservableObject { + static let example = MockNotificationSettingsService() + + @Published var keywords = Set() + @Published var rules = [MXPushRule]() + @Published var contentRules = [MXPushRule]() + + var contentRulesPublisher: AnyPublisher<[MXPushRule], Never> { + $contentRules.eraseToAnyPublisher() + } + + var keywordsPublisher: AnyPublisher, Never> { + $keywords.eraseToAnyPublisher() + } + + var rulesPublisher: AnyPublisher<[MXPushRule], Never> { + $rules.eraseToAnyPublisher() + } + + func add(keyword: String, enabled: Bool) { + keywords.insert(keyword) + } + + func remove(keyword: String) { + keywords.remove(keyword) + } + + func updatePushRuleActions(for ruleId: String, enabled: Bool, actions: NotificationActions?) { + + } +} diff --git a/Riot/Modules/Settings/Notifications/ViewModel/KeywordsViewModel.swift b/Riot/Modules/Settings/Notifications/Model/NotificationActions.swift similarity index 63% rename from Riot/Modules/Settings/Notifications/ViewModel/KeywordsViewModel.swift rename to Riot/Modules/Settings/Notifications/Model/NotificationActions.swift index 74f732bbe..f8284b9a9 100644 --- a/Riot/Modules/Settings/Notifications/ViewModel/KeywordsViewModel.swift +++ b/Riot/Modules/Settings/Notifications/Model/NotificationActions.swift @@ -16,7 +16,17 @@ import Foundation -@available(iOS 14.0, *) -class KeywordsViewModel: ObservableObject { - @Published var keywords: [String] = ["Website", "Element", "Design"] +/** + The actions defined on a push rule, used int he static push rule definitions. + */ +struct NotificationActions { + let notify: Bool + let highlight: Bool + let sound: String? + + init(notify: Bool, highlight: Bool = false, sound: String? = nil) { + self.notify = notify + self.highlight = highlight + self.sound = sound + } } diff --git a/Riot/Modules/Settings/Notifications/Model/NotificationIndex.swift b/Riot/Modules/Settings/Notifications/Model/NotificationIndex.swift new file mode 100644 index 000000000..1605f2946 --- /dev/null +++ b/Riot/Modules/Settings/Notifications/Model/NotificationIndex.swift @@ -0,0 +1,46 @@ +// +// Copyright 2021 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. +// + +import Foundation + +/** + Index that determines the state of the push setting. + Silent case is un-unsed on iOS but keepingin for consistency of + definition across the platforms. + */ +enum NotificationIndex { + case off + case silent + case noisy +} + +extension NotificationIndex: CaseIterable { } + +extension NotificationIndex { + /** + Used to map the on/off checkarks to an index used in the static push rule definitions. + */ + static func index(enabled: Bool) -> NotificationIndex { + return enabled ? .noisy : .off + } + + /** + Used to map from the checked state back to the index. + */ + var enabled: Bool { + return self != .off + } +} diff --git a/Riot/Modules/Settings/Notifications/Model/NotificationPushRuleIds.swift b/Riot/Modules/Settings/Notifications/Model/NotificationPushRuleIds.swift new file mode 100644 index 000000000..b3af72eca --- /dev/null +++ b/Riot/Modules/Settings/Notifications/Model/NotificationPushRuleIds.swift @@ -0,0 +1,73 @@ +// +// Copyright 2021 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. +// + +import Foundation + +/** + The push rule ids used in notification settings and the static rule definitions. + */ +enum NotificationPushRuleId: String { + case suppressBots = ".m.rule.suppress_notices" + case inviteMe = ".m.rule.invite_for_me" + case containDisplayName = ".m.rule.contains_display_name" + case tombstone = ".m.rule.tombstone" + case roomNotif = ".m.rule.roomnotif" + case containUserName = ".m.rule.contains_user_name" + case call = ".m.rule.call" + case oneToOneEncryptedRoom = ".m.rule.encrypted_room_one_to_one" + case oneToOneRoom = ".m.rule.room_one_to_one" + case allOtherMessages = ".m.rule.message" + case encrypted = ".m.rule.encrypted" + case keywords = "_keywords" +} + + +extension NotificationPushRuleId: Identifiable { + var id: String { + rawValue + } +} + +extension NotificationPushRuleId { + var title: String { + switch self { + case .suppressBots: + return VectorL10n.settingsMessagesByABot + case .inviteMe: + return VectorL10n.settingsRoomInvitations + case .containDisplayName: + return VectorL10n.settingsMessagesContainingDisplayName + case .tombstone: + return VectorL10n.settingsRoomUpgrades + case .roomNotif: + return VectorL10n.settingsMessagesContainingAtRoom + case .containUserName: + return VectorL10n.settingsMessagesContainingUserName + case .call: + return VectorL10n.settingsCallInvitations + case .oneToOneEncryptedRoom: + return VectorL10n.settingsEncryptedDirectMessages + case .oneToOneRoom: + return VectorL10n.settingsDirectMessages + case .allOtherMessages: + return VectorL10n.settingsGroupMessages + case .encrypted: + return VectorL10n.settingsEncryptedGroupMessages + case .keywords: + return VectorL10n.settingsMessagesContainingKeywords + } + } +} diff --git a/Riot/Modules/Settings/Notifications/Model/NotificationSettingsScreen.swift b/Riot/Modules/Settings/Notifications/Model/NotificationSettingsScreen.swift index 506a30578..96c747e00 100644 --- a/Riot/Modules/Settings/Notifications/Model/NotificationSettingsScreen.swift +++ b/Riot/Modules/Settings/Notifications/Model/NotificationSettingsScreen.swift @@ -16,6 +16,9 @@ import Foundation +/** +The notification settings screen definitions, used when calling the coordinator. + */ @objc enum NotificationSettingsScreen: Int { case defaultNotificaitons case mentionsAndKeywords @@ -29,7 +32,10 @@ extension NotificationSettingsScreen: Identifiable { } extension NotificationSettingsScreen { - var pushRules: [PushRuleId] { + /** + Defines which rules are handled by each of the screens. + */ + var pushRules: [NotificationPushRuleId] { switch self { case .defaultNotificaitons: return [.oneToOneRoom, .allOtherMessages, .oneToOneEncryptedRoom, .encrypted] diff --git a/Riot/Modules/Settings/Notifications/Model/PushRuleDefinitions.swift b/Riot/Modules/Settings/Notifications/Model/PushRuleDefinitions.swift index 3f967bbfa..9d7c6bf10 100644 --- a/Riot/Modules/Settings/Notifications/Model/PushRuleDefinitions.swift +++ b/Riot/Modules/Settings/Notifications/Model/PushRuleDefinitions.swift @@ -16,104 +16,86 @@ import Foundation -enum NotificationIndex { - case off - case silent - case noisy -} -enum PushRuleId: String { - - // Default Override Rules - case disableAll = ".m.rule.master" - case suppressBots = ".m.rule.suppress_notices" - case inviteMe = ".m.rule.invite_for_me" - case peopleJoinLeave = ".m.rule.member_event" - case containDisplayName = ".m.rule.contains_display_name" - case tombstone = ".m.rule.tombstone" - case roomNotif = ".m.rule.roomnotif" - // Default Content Rules - case containUserName = ".m.rule.contains_user_name" - case keywords = "_keywords" - // Default Underride Rules - case call = ".m.rule.call" - case oneToOneEncryptedRoom = ".m.rule.encrypted_room_one_to_one" - case oneToOneRoom = ".m.rule.room_one_to_one" - case allOtherMessages = ".m.rule.message" - case encrypted = ".m.rule.encrypted" - // Not documented - case fallback = ".m.rule.fallback" - case reaction = ".m.rule.reaction" -} - -func standardActions(for ruleId: PushRuleId, index: NotificationIndex) -> NotificationStandardActions? { - switch ruleId { - case .containDisplayName: - switch index { - case .off: return .disabled - case .silent: return .notify - case .noisy: return .highlightDefaultSound +extension NotificationPushRuleId { + /* + A static definition of the push rule actions. + It is defined similarly across Web and Android. + */ + func standardActions(for index: NotificationIndex) -> NotificationStandardActions? { + switch self { + case .containDisplayName: + switch index { + case .off: return .disabled + case .silent: return .notify + case .noisy: return .highlightDefaultSound + } + case .containUserName: + switch index { + case .off: return .disabled + case .silent: return .notify + case .noisy: return .highlightDefaultSound + } + case .roomNotif: + switch index { + case .off: return .disabled + case .silent: return .notify + case .noisy: return .highlight + } + case .oneToOneRoom: + switch index { + case .off: return .dontNotify + case .silent: return .notify + case .noisy: return .notifyDefaultSound + } + case .oneToOneEncryptedRoom: + switch index { + case .off: return .dontNotify + case .silent: return .notify + case .noisy: return .notifyDefaultSound + } + case .allOtherMessages: + switch index { + case .off: return .dontNotify + case .silent: return .notify + case .noisy: return .notifyDefaultSound + } + case .encrypted: + switch index { + case .off: return .dontNotify + case .silent: return .notify + case .noisy: return .notifyDefaultSound + } + case .inviteMe: + switch index { + case .off: return .disabled + case .silent: return .notify + case .noisy: return .notifyDefaultSound + } + case .call: + switch index { + case .off: return .disabled + case .silent: return .notify + case .noisy: return .notifyRingSound + } + case .suppressBots: + switch index { + case .off: return .dontNotify + case .silent: return .disabled + case .noisy: return .notifyDefaultSound + } + case .tombstone: + switch index { + case .off: return .disabled + case .silent: return .notify + case .noisy: return .highlight + } + case .keywords: + switch index { + case .off: return .disabled + case .silent: return .notify + case .noisy: return .highlightDefaultSound + } } - case .containUserName: - switch index { - case .off: return .disabled - case .silent: return .notify - case .noisy: return .highlightDefaultSound - } - case .roomNotif: - switch index { - case .off: return .disabled - case .silent: return .notify - case .noisy: return .highlight - } - case .oneToOneRoom: - switch index { - case .off: return .dontNotify - case .silent: return .notify - case .noisy: return .notifyDefaultSound - } - case .oneToOneEncryptedRoom: - switch index { - case .off: return .dontNotify - case .silent: return .notify - case .noisy: return .notifyDefaultSound - } - case .allOtherMessages: - switch index { - case .off: return .dontNotify - case .silent: return .notify - case .noisy: return .notifyDefaultSound - } - case .encrypted: - switch index { - case .off: return .dontNotify - case .silent: return .notify - case .noisy: return .notifyDefaultSound - } - case .inviteMe: - switch index { - case .off: return .disabled - case .silent: return .notify - case .noisy: return .notifyDefaultSound - } - case .call: - switch index { - case .off: return .disabled - case .silent: return .notify - case .noisy: return .notifyRingSound - } - case .suppressBots: - switch index { - case .off: return .dontNotify - case .silent: return .disabled - case .noisy: return .notifyDefaultSound - } - case .tombstone: - switch index { - case .off: return .disabled - case .silent: return .notify - case .noisy: return .highlight - } - default: return nil } } diff --git a/Riot/Modules/Settings/Notifications/Model/StandardActions.swift b/Riot/Modules/Settings/Notifications/Model/StandardActions.swift index bdd9cd36d..a233c6500 100644 --- a/Riot/Modules/Settings/Notifications/Model/StandardActions.swift +++ b/Riot/Modules/Settings/Notifications/Model/StandardActions.swift @@ -16,12 +16,10 @@ import Foundation -enum NotificationAction { - case notify(Bool) - case highlight(Bool) - case sound(String) -} - +/* + A static definition of the different actions that can be defined on push rules. + It is defined similarly across Web and Android. + */ enum NotificationStandardActions { case notify case notifyDefaultSound @@ -31,20 +29,20 @@ enum NotificationStandardActions { case dontNotify case disabled - var actions: [NotificationAction]? { + var actions: NotificationActions? { switch self { case .notify: - return [.notify(true)] + return NotificationActions(notify: true) case .notifyDefaultSound: - return [.notify(true), .sound("default")] + return NotificationActions(notify: true, sound: "default") case .notifyRingSound: - return [.notify(true), .sound("ring")] + return NotificationActions(notify: true, sound: "ring") case .highlight: - return [.notify(true), .highlight(true)] + return NotificationActions(notify: true, highlight: true) case .highlightDefaultSound: - return [.notify(true), .highlight(true), .sound("default")] + return NotificationActions(notify: true, highlight: true, sound: "default") case .dontNotify: - return [.notify(false)] + return NotificationActions(notify: false) case .disabled: return nil } diff --git a/Riot/Modules/Settings/Notifications/NotificationSettingsCoordinator.swift b/Riot/Modules/Settings/Notifications/NotificationSettingsCoordinator.swift index 02dc1c8c0..e106b9071 100644 --- a/Riot/Modules/Settings/Notifications/NotificationSettingsCoordinator.swift +++ b/Riot/Modules/Settings/Notifications/NotificationSettingsCoordinator.swift @@ -41,9 +41,17 @@ final class NotificationSettingsCoordinator: NotificationSettingsCoordinatorType init(session: MXSession, screen: NotificationSettingsScreen) { self.session = session - - let viewModel = NotificationSettingsViewModel(rules: screen.pushRules) - let viewController = VectorHostingController(rootView: NotificationSettings(viewModel: viewModel, footer: Text("footer"))) + let notificationSettingsService = NotificationSettingsService(session: session) + let viewModel = NotificationSettingsViewModel(notificationSettingsService: notificationSettingsService, ruleIds: screen.pushRules) + let viewController: UIViewController + switch screen { + case .defaultNotificaitons: + viewController = VectorHostingController(rootView: DefaultNotificationSettings(viewModel: viewModel)) + case .mentionsAndKeywords: + viewController = VectorHostingController(rootView: MentionsAndKeywordNotificationSettings(viewModel: viewModel)) + case .other: + viewController = VectorHostingController(rootView: OtherNotificationSettings(viewModel: viewModel)) + } self.notificationSettingsViewModel = viewModel self.notificationSettingsViewController = viewController } diff --git a/Riot/Modules/Settings/Notifications/SwiftUI/BorderedInputFieldStyle.swift b/Riot/Modules/Settings/Notifications/SwiftUI/BorderedInputFieldStyle.swift new file mode 100644 index 000000000..abc4f9db9 --- /dev/null +++ b/Riot/Modules/Settings/Notifications/SwiftUI/BorderedInputFieldStyle.swift @@ -0,0 +1,127 @@ +// +// Copyright 2021 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. +// + +import Foundation +import SwiftUI + +/* + A bordered style of text input as defined in: + https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=2039%3A26415 + */ +@available(iOS 14.0, *) +struct BorderedInputFieldStyle: TextFieldStyle { + @Environment(\.theme) var theme: Theme + + var isEditing: Bool = false + var isError: Bool = false + var isEnabled: Bool = true + + private var borderColor: Color { + if !isEnabled { + return Color(theme.colors.quinaryContent) + } else if isError { + return Color(theme.colors.alert) + } else if isEditing { + return Color(theme.colors.accent) + } + return Color(theme.colors.quarterlyContent) + } + + private var accentColor: Color { + if isError { + return Color(theme.colors.alert) + } + return Color(theme.colors.accent) + } + + private var textColor: Color { + if !isEnabled { + return Color(theme.colors.quarterlyContent) + } + return Color(theme.colors.primaryContent) + } + + private var backgroundColor: Color { + if !isEnabled && (theme is DarkTheme) { + return Color(theme.colors.quinaryContent) + } + return Color(theme.colors.background) + } + + private var borderWdith: CGFloat { + return isEditing || isError ? 2 : 1.5 + } + + func _body(configuration: TextField<_Label>) -> some View { + let rect = RoundedRectangle(cornerRadius: 8) + configuration + .font(Font(theme.fonts.callout)) + .foregroundColor(textColor) + .accentColor(accentColor) + .frame(height: 48) + .padding(.horizontal, 8) + .background(backgroundColor) + .clipShape(rect) + .overlay( + rect + .stroke(borderColor, lineWidth: borderWdith) + ) + + + } +} + +@available(iOS 14.0, *) +struct BorderedInputFieldStyle_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack { + TextField("Placeholder", text: .constant("")) + .textFieldStyle(BorderedInputFieldStyle()) + TextField("Placeholder", text: .constant("")) + .textFieldStyle(BorderedInputFieldStyle(isEditing: true)) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(BorderedInputFieldStyle()) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(BorderedInputFieldStyle(isEditing: true)) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(BorderedInputFieldStyle(isEnabled: false)) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(BorderedInputFieldStyle(isEditing: true, isError: true)) + + } + .padding() + VStack { + TextField("Placeholder", text: .constant("")) + .textFieldStyle(BorderedInputFieldStyle()) + TextField("Placeholder", text: .constant("")) + .textFieldStyle(BorderedInputFieldStyle(isEditing: true)) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(BorderedInputFieldStyle()) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(BorderedInputFieldStyle(isEditing: true)) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(BorderedInputFieldStyle(isEnabled: false)) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(BorderedInputFieldStyle(isEditing: true, isError: true)) + + } + .padding() + .theme(ThemeIdentifier.dark) + } + + } +} diff --git a/Riot/Modules/Settings/Notifications/SwiftUI/Chip.swift b/Riot/Modules/Settings/Notifications/SwiftUI/Chip.swift index 41bed355f..814086f3a 100644 --- a/Riot/Modules/Settings/Notifications/SwiftUI/Chip.swift +++ b/Riot/Modules/Settings/Notifications/SwiftUI/Chip.swift @@ -16,29 +16,47 @@ import SwiftUI +/** + A single rounded rect chip to be rendered within `Chips` collection + */ @available(iOS 14.0, *) struct Chip: View { + @Environment(\.isEnabled) var isEnabled @Environment(\.theme) var theme: Theme - let titleKey: String - let onClose: () -> Void + let chip: String + let onDelete: () -> Void + + var backgroundColor: Color { + if !isEnabled { + return Color(theme.colors.quinaryContent) + } + return Color(theme.colors.accent) + } + + var foregroundColor: Color { + if !isEnabled { + return Color(theme.colors.tertiaryContent) + } + return Color(theme.colors.background) + } var body: some View { HStack { - Text(titleKey) + Text(chip) .font(Font(theme.fonts.body)) .lineLimit(1) Image(systemName: "xmark.circle.fill") .frame(width: 16, height: 16, alignment: .center) - .onTapGesture(perform: onClose) + .onTapGesture(perform: onDelete) } .padding(.leading, 12) .padding(.top, 6) .padding(.bottom, 6) .padding(.trailing, 8) - .background(Color(theme.tintColor)) - .foregroundColor(Color.white) + .background(backgroundColor) + .foregroundColor(foregroundColor) .cornerRadius(20) } @@ -47,6 +65,6 @@ struct Chip: View { @available(iOS 14.0, *) struct Chip_Previews: PreviewProvider { static var previews: some View { - Chip(titleKey: "My great chip", onClose: { }) + Chip(chip: "My great chip", onDelete: { }) } } diff --git a/Riot/Modules/Settings/Notifications/SwiftUI/Chips.swift b/Riot/Modules/Settings/Notifications/SwiftUI/Chips.swift index 4432d5bdc..a4ba01e86 100644 --- a/Riot/Modules/Settings/Notifications/SwiftUI/Chips.swift +++ b/Riot/Modules/Settings/Notifications/SwiftUI/Chips.swift @@ -16,52 +16,75 @@ import SwiftUI +/** + Renders multiple chips in a flow layout. + */ @available(iOS 14.0, *) struct Chips: View { + @State private var totalHeight: CGFloat = 0 + var chips: [String] + var didDeleteChip: (String) -> Void + var verticalSpacing: CGFloat = 16 + var horizontalSpacing: CGFloat = 12 var body: some View { - var width = CGFloat.zero - var height = CGFloat.zero - return GeometryReader { geo in - ZStack(alignment: .topLeading, content: { - ForEach(chips, id: \.self) { chip in - Chip(titleKey: chip) { - - } - .padding(.all, 5) - .alignmentGuide(.leading) { dimension in - if abs(width - dimension.width) > geo.size.width { - width = 0 - height -= dimension.height - } - - let result = width - if chip == chips.last { - width = 0 - } else { - width -= dimension.width - } - return result - } - .alignmentGuide(.top) { dimension in - let result = height - if chip == chips.last { - height = 0 - } - return result + Group { + VStack { + var x = CGFloat.zero + var y = CGFloat.zero + GeometryReader { geo in + ZStack(alignment: .topLeading, content: { + ForEach(chips, id: \.self) { chip in + Chip(chip: chip) { + didDeleteChip(chip) + } + .alignmentGuide(.leading) { dimension in + if abs(x - dimension.width) > geo.size.width { + x = 0 + y -= dimension.height + verticalSpacing + } + + let result = x + if chip == chips.last { + x = 0 + } else { + x -= dimension.width + horizontalSpacing + } + return result + } + .alignmentGuide(.top) { dimension in + let result = y + if chip == chips.last { + y = 0 + } + return result + } } + }) + .background(viewHeightReader($totalHeight)) } - }) - }.padding(.all, 10) + } + .frame(height: totalHeight) + } + } + + private func viewHeightReader(_ binding: Binding) -> some View { + return GeometryReader { geo -> Color in + DispatchQueue.main.async { + binding.wrappedValue = geo.frame(in: .local).size.height + } + return .clear + } } } @available(iOS 14.0, *) struct Chips_Previews: PreviewProvider { + static var chips: [String] = ["Chip1", "Chip2", "Chip3", "Chip4", "Chip5", "Chip6"] static var previews: some View { - Chips(chips: ["Chip1", "Chip2", "Chip3", "Chip4", "Chip5", "Chip6"]) - .frame(width: .infinity, height: 400, alignment: .leading) + Chips(chips: chips, didDeleteChip: { _ in }) + } } diff --git a/Riot/Modules/Settings/Notifications/SwiftUI/ChipsInput.swift b/Riot/Modules/Settings/Notifications/SwiftUI/ChipsInput.swift new file mode 100644 index 000000000..217c08185 --- /dev/null +++ b/Riot/Modules/Settings/Notifications/SwiftUI/ChipsInput.swift @@ -0,0 +1,70 @@ +// +// Copyright 2021 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. +// + +import SwiftUI + + +/* + Renders an input field and a collection of chips + with callbacks for addition and deletion. + */ +@available(iOS 14.0, *) +struct ChipsInput: View { + + @Environment(\.theme) var theme: Theme + @Environment(\.isEnabled) var isEnabled + + @State private var chipText: String = "" + @State private var isEditing: Bool = false + + + var chips: [String] + var placeholder: String = "" + var didAddChip: (String) -> Void + var didDeleteChip: (String) -> Void + + + var body: some View { + VStack(spacing: 16) { + TextField(placeholder, text: $chipText) { editing in + isEditing = editing + } onCommit: { + didAddChip(chipText) + chipText = "" + } + .disabled(!isEnabled) + .disableAutocorrection(true) + .autocapitalization(.none) + .textFieldStyle(FormInputFieldStyle()) + Chips(chips: chips, didDeleteChip: didDeleteChip) + .padding(.horizontal) + } + } +} + +@available(iOS 14.0, *) +struct ChipsInput_Previews: PreviewProvider { + static var chips = Set(["Website", "Element", "Design", "Matrix/Element"]) + static var previews: some View { + ChipsInput(chips: Array(chips)) { chip in + chips.insert(chip) + } didDeleteChip: { chip in + chips.remove(chip) + } + .disabled(true) + + } +} diff --git a/Riot/Modules/Settings/Notifications/SwiftUI/DefaultNotifications.swift b/Riot/Modules/Settings/Notifications/SwiftUI/DefaultNotificationSettings.swift similarity index 77% rename from Riot/Modules/Settings/Notifications/SwiftUI/DefaultNotifications.swift rename to Riot/Modules/Settings/Notifications/SwiftUI/DefaultNotificationSettings.swift index 1e350efaa..21b9dc92b 100644 --- a/Riot/Modules/Settings/Notifications/SwiftUI/DefaultNotifications.swift +++ b/Riot/Modules/Settings/Notifications/SwiftUI/DefaultNotificationSettings.swift @@ -17,15 +17,12 @@ import SwiftUI @available(iOS 14.0, *) -struct DefaultNotifications: View { +struct DefaultNotificationSettings: View { @ObservedObject var viewModel: NotificationSettingsViewModel var body: some View { - NotificationSettings( - viewModel: viewModel, - footer: EmptyView() - ) + NotificationSettings(viewModel: viewModel) .navigationBarTitle(VectorL10n.settingsDefault) } } @@ -34,9 +31,10 @@ struct DefaultNotifications: View { struct DefaultNotifications_Previews: PreviewProvider { static var previews: some View { NavigationView { - DefaultNotifications( + DefaultNotificationSettings( viewModel: NotificationSettingsViewModel( - rules: NotificationSettingsScreen.defaultNotificaitons.pushRules + notificationSettingsService: MockNotificationSettingsService.example, + ruleIds: NotificationSettingsScreen.defaultNotificaitons.pushRules ) ) .navigationBarTitleDisplayMode(.inline) diff --git a/Riot/Modules/Settings/Notifications/SwiftUI/FormInputFieldStyle.swift b/Riot/Modules/Settings/Notifications/SwiftUI/FormInputFieldStyle.swift new file mode 100644 index 000000000..072dabe3b --- /dev/null +++ b/Riot/Modules/Settings/Notifications/SwiftUI/FormInputFieldStyle.swift @@ -0,0 +1,91 @@ +// +// Copyright 2021 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. +// + +import Foundation +import SwiftUI + +/** + An input field for forms. + */ +@available(iOS 14.0, *) +struct FormInputFieldStyle: TextFieldStyle { + @Environment(\.theme) var theme: Theme + + var isEditing: Bool = false + var isEnabled: Bool = true + + private var textColor: Color { + if !isEnabled { + return Color(theme.colors.quarterlyContent) + } + return Color(theme.colors.primaryContent) + } + + private var backgroundColor: Color { + if !isEnabled && (theme is DarkTheme) { + return Color(theme.colors.quinaryContent) + } + return Color(theme.colors.background) + } + + func _body(configuration: TextField<_Label>) -> some View { + configuration + .font(Font(theme.fonts.callout)) + .foregroundColor(textColor) + .frame(minHeight: 48) + .padding(.horizontal) + .background(backgroundColor) + } +} + + +@available(iOS 14.0, *) +struct FormInputFieldStyle_Previews: PreviewProvider { + static var previews: some View { + Group { + VectorForm { + TextField("Placeholder", text: .constant("")) + .textFieldStyle(FormInputFieldStyle()) + TextField("Placeholder", text: .constant("")) + .textFieldStyle(FormInputFieldStyle(isEditing: true)) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(FormInputFieldStyle()) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(FormInputFieldStyle(isEditing: true)) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(FormInputFieldStyle(isEnabled: false)) + + } + .padding() + VectorForm { + TextField("Placeholder", text: .constant("")) + .textFieldStyle(FormInputFieldStyle()) + TextField("Placeholder", text: .constant("")) + .textFieldStyle(FormInputFieldStyle(isEditing: true)) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(FormInputFieldStyle()) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(FormInputFieldStyle(isEditing: true)) + TextField("Placeholder", text: .constant("Web")) + .textFieldStyle(FormInputFieldStyle(isEnabled: false)) + + } + .padding() + .theme(ThemeIdentifier.dark) + } + + } +} diff --git a/Riot/Modules/Settings/Notifications/SwiftUI/Keywords.swift b/Riot/Modules/Settings/Notifications/SwiftUI/Keywords.swift index dcf604518..f17a4d700 100644 --- a/Riot/Modules/Settings/Notifications/SwiftUI/Keywords.swift +++ b/Riot/Modules/Settings/Notifications/SwiftUI/Keywords.swift @@ -16,23 +16,31 @@ import SwiftUI +/* + Renders the keywords input, driven by 'NotificationSettingsViewModel'. + */ @available(iOS 14.0, *) struct Keywords: View { - - @ObservedObject var viewModel: KeywordsViewModel - @State var keywordText: String = "" - + @ObservedObject var viewModel: NotificationSettingsViewModel var body: some View { - VStack { - TextField("New Keyword", text: $keywordText) - Chips(chips: viewModel.keywords) - } + ChipsInput( + chips: viewModel.viewState.keywords, + placeholder: VectorL10n.settingsNewKeyword, + didAddChip: viewModel.add(keyword:), + didDeleteChip: viewModel.remove(keyword:) + ) + .disabled(!(viewModel.viewState.selectionState[.keywords] ?? false)) + } } @available(iOS 14.0, *) struct Keywords_Previews: PreviewProvider { + static let viewModel = NotificationSettingsViewModel( + notificationSettingsService: MockNotificationSettingsService.example, + ruleIds: NotificationSettingsScreen.mentionsAndKeywords.pushRules + ) static var previews: some View { - Keywords(viewModel: KeywordsViewModel()) + Keywords(viewModel: viewModel) } } diff --git a/Riot/Modules/Settings/Notifications/SwiftUI/MentionsAndKeywords.swift b/Riot/Modules/Settings/Notifications/SwiftUI/MentionsAndKeywordNotificationSettings.swift similarity index 65% rename from Riot/Modules/Settings/Notifications/SwiftUI/MentionsAndKeywords.swift rename to Riot/Modules/Settings/Notifications/SwiftUI/MentionsAndKeywordNotificationSettings.swift index ce226e303..166be9c64 100644 --- a/Riot/Modules/Settings/Notifications/SwiftUI/MentionsAndKeywords.swift +++ b/Riot/Modules/Settings/Notifications/SwiftUI/MentionsAndKeywordNotificationSettings.swift @@ -17,15 +17,22 @@ import SwiftUI @available(iOS 14.0, *) -struct MentionsAndKeywords: View { +struct MentionsAndKeywordNotificationSettings: View { - @ObservedObject var keywordsViewModel: KeywordsViewModel @ObservedObject var viewModel: NotificationSettingsViewModel + var keywordSection: some View { + SwiftUI.Section( + header: FormSectionHeader(text: VectorL10n.settingsYourKeywords), + footer: FormSectionFooter(text: VectorL10n.settingsMentionsAndKeywordsEncryptionNotice) + ) { + Keywords(viewModel: viewModel) + } + } var body: some View { NotificationSettings( viewModel: viewModel, - footer: Keywords(viewModel: keywordsViewModel) + bottomSection: keywordSection ) .navigationTitle(VectorL10n.settingsMentionsAndKeywords) } @@ -35,10 +42,10 @@ struct MentionsAndKeywords: View { struct MentionsAndKeywords_Previews: PreviewProvider { static var previews: some View { NavigationView { - MentionsAndKeywords( - keywordsViewModel: KeywordsViewModel(), + MentionsAndKeywordNotificationSettings( viewModel: NotificationSettingsViewModel( - rules: NotificationSettingsScreen.mentionsAndKeywords.pushRules + notificationSettingsService: MockNotificationSettingsService.example, + ruleIds: NotificationSettingsScreen.mentionsAndKeywords.pushRules ) ) .navigationBarTitleDisplayMode(.inline) diff --git a/Riot/Modules/Settings/Notifications/SwiftUI/NotificationSettings.swift b/Riot/Modules/Settings/Notifications/SwiftUI/NotificationSettings.swift index 261360332..ef8fe8cd1 100644 --- a/Riot/Modules/Settings/Notifications/SwiftUI/NotificationSettings.swift +++ b/Riot/Modules/Settings/Notifications/SwiftUI/NotificationSettings.swift @@ -16,40 +16,40 @@ import SwiftUI +/* + Renders the push rule settings that can enabled/disable. + Also renders an optional bottom section + (Used in the case of keywords for the keyword chips and input). + */ @available(iOS 14.0, *) -struct NotificationSettings: View { +struct NotificationSettings: View { @ObservedObject var viewModel: NotificationSettingsViewModel - var footer: Footer - - @ViewBuilder - private var rightButton: some View { - Button(VectorL10n.save) { - viewModel.process(viewAction: .save) - } - } + var bottomSection: BottomSection? var body: some View { VectorForm { SwiftUI.Section( - header: FormSectionHeader(text: VectorL10n.roomNotifsSettingsNotifyMeFor), - footer: footer + header: FormSectionHeader(text: VectorL10n.settingsNotifyMeFor) ) { - ForEach(viewModel.viewState.selectionState) { item in - FormPickerItem(title: item.title ?? "", selected: item.selected) { - viewModel.process(viewAction: .selectNotification(item.ruleId, !item.selected)) + ForEach(viewModel.viewState.ruleIds) { ruleId in + let checked = viewModel.viewState.selectionState[ruleId] ?? false + FormPickerItem(title: ruleId.title, selected: checked) { + viewModel.check(ruleID: ruleId, checked: !checked) } } } + bottomSection } .activityIndicator(show: viewModel.viewState.saving) - .navigationBarItems( - trailing: rightButton - ) - .onAppear { - viewModel.process(viewAction: .load) - } + } +} + +@available(iOS 14.0, *) +extension NotificationSettings where BottomSection == EmptyView { + init(viewModel: NotificationSettingsViewModel) { + self.init(viewModel: viewModel, bottomSection: nil) } } @@ -60,8 +60,7 @@ struct NotificationSettings_Previews: PreviewProvider { ForEach(NotificationSettingsScreen.allCases) { screen in NavigationView { NotificationSettings( - viewModel: NotificationSettingsViewModel(rules: screen.pushRules), - footer: EmptyView() + viewModel: NotificationSettingsViewModel(notificationSettingsService: MockNotificationSettingsService.example, ruleIds: screen.pushRules) ) .navigationBarTitleDisplayMode(.inline) } diff --git a/Riot/Modules/Settings/Notifications/SwiftUI/OtherNotifications.swift b/Riot/Modules/Settings/Notifications/SwiftUI/OtherNotificationSettings.swift similarity index 79% rename from Riot/Modules/Settings/Notifications/SwiftUI/OtherNotifications.swift rename to Riot/Modules/Settings/Notifications/SwiftUI/OtherNotificationSettings.swift index adb81ec8f..3f30393d8 100644 --- a/Riot/Modules/Settings/Notifications/SwiftUI/OtherNotifications.swift +++ b/Riot/Modules/Settings/Notifications/SwiftUI/OtherNotificationSettings.swift @@ -17,14 +17,11 @@ import SwiftUI @available(iOS 14.0, *) -struct OtherNotifications: View { +struct OtherNotificationSettings: View { @ObservedObject var viewModel: NotificationSettingsViewModel var body: some View { - NotificationSettings( - viewModel: viewModel, - footer: EmptyView() - ) + NotificationSettings(viewModel: viewModel) .navigationTitle(VectorL10n.settingsOther) } } @@ -33,9 +30,9 @@ struct OtherNotifications: View { struct OtherNotifications_Previews: PreviewProvider { static var previews: some View { NavigationView { - DefaultNotifications( + DefaultNotificationSettings( viewModel: NotificationSettingsViewModel( - rules: NotificationSettingsScreen.other.pushRules + notificationSettingsService: MockNotificationSettingsService.example, ruleIds: NotificationSettingsScreen.other.pushRules ) ) .navigationBarTitleDisplayMode(.inline) diff --git a/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsService.swift b/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsService.swift new file mode 100644 index 000000000..4b7d94dcc --- /dev/null +++ b/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsService.swift @@ -0,0 +1,127 @@ +// +// Copyright 2021 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. +// + +import Foundation +import Combine + +/* + A service for changing notification settings and keywords + */ +@available(iOS 14.0, *) +protocol NotificationSettingsServiceType { + /* + Publisher of all push rules + */ + var rulesPublisher: AnyPublisher<[MXPushRule], Never> { get } + /* + Publisher of content rules. + */ + var contentRulesPublisher: AnyPublisher<[MXPushRule], Never> { get } + /* + Adds a keword. + + - Parameters: + - keyword: The keyword to add + - enabled: Wether the keyword should be added in the enabled or disabled state. + */ + func add(keyword: String, enabled: Bool) + /* + Removes a keword. + + - Parameters: + - keyword: The keyword to remove + */ + func remove(keyword: String) + /* + Updates the push rule actions. + + - Parameters: + - ruleId: The id of the rule. + - enabled: Wether the rule should be enabled or disabled. + - actions: The actions to update with. + */ + func updatePushRuleActions(for ruleId: String, enabled: Bool, actions: NotificationActions?) +} + +@available(iOS 14.0, *) +class NotificationSettingsService: NotificationSettingsServiceType { + + private let session: MXSession + private var cancellables = Set() + + @Published private var contentRules = [MXPushRule]() + @Published private var rules = [MXPushRule]() + + var rulesPublisher: AnyPublisher<[MXPushRule], Never> { + $rules.eraseToAnyPublisher() + } + + var contentRulesPublisher: AnyPublisher<[MXPushRule], Never> { + $contentRules.eraseToAnyPublisher() + } + + init(session: MXSession) { + self.session = session + // publisher of all rule updates + let rulesUpdated = NotificationCenter.default.publisher(for: NSNotification.Name(rawValue: kMXNotificationCenterDidUpdateRules)) + + // Set initial value of the content rules + if let contentRules = session.notificationCenter.rules.global.content as? [MXPushRule] { + self.contentRules = contentRules + } + + // Observe future updates to content rules + rulesUpdated + .compactMap({ _ in self.session.notificationCenter.rules.global.content as? [MXPushRule] }) + .assign(to: &$contentRules) + + // Set initial value of rules + if let flatRules = session.notificationCenter.flatRules as? [MXPushRule] { + rules = flatRules + } + // Observe future updates to rules + rulesUpdated + .compactMap({ _ in self.session.notificationCenter.flatRules as? [MXPushRule] }) + .assign(to: &$rules) + } + + func add(keyword: String, enabled: Bool) { + let index = NotificationIndex.index(enabled: enabled) + 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) + } + + func remove(keyword: String) { + guard let rule = session.notificationCenter.rule(byId: keyword) else { return } + 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) + + if let actions = actions { + session.notificationCenter.updatePushRuleActions(ruleId, + kind: rule.kind, + notify: actions.notify, + soundName: actions.sound, + highlight: actions.highlight) + } + } +} diff --git a/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewAction.swift b/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewAction.swift deleted file mode 100644 index 3206a896c..000000000 --- a/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewAction.swift +++ /dev/null @@ -1,27 +0,0 @@ -// File created from ScreenTemplate -// $ createScreen.sh Settings/Notifications NotificationSettings -/* - Copyright 2021 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. - */ - -import Foundation - -/// NotificationSettingsViewController view actions exposed to view model -enum NotificationSettingsViewAction { - case load - case selectNotification(PushRuleId, Bool) - case save - case cancel -} diff --git a/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift b/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift index 763ed3ad2..845289841 100644 --- a/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift +++ b/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift @@ -19,6 +19,7 @@ import Foundation import Combine import SwiftUI +import OrderedCollections @available(iOS 14.0, *) final class NotificationSettingsViewModel: NotificationSettingsViewModelType, ObservableObject { @@ -27,55 +28,222 @@ final class NotificationSettingsViewModel: NotificationSettingsViewModelType, Ob // MARK: Private + private let notificationSettingsService: NotificationSettingsServiceType + // The rule ids this view model allows the ui to enabled/disable. + private let ruleIds: [NotificationPushRuleId] + private var cancellables = Set() + + // The set of keywords the UI displays. We use an Ordered set to keep a consistent ordering + // so that the keywords don't jump around. + @Published private var keywordsSet = OrderedSet() + // MARK: Public - - weak var viewDelegate: NotificationSettingsViewModelViewDelegate? - weak var coordinatorDelegate: NotificationSettingsViewModelCoordinatorDelegate? @Published var viewState: NotificationSettingsViewState + weak var coordinatorDelegate: NotificationSettingsViewModelCoordinatorDelegate? + // MARK: - Setup - init(initialState: NotificationSettingsViewState) { + init(notificationSettingsService: NotificationSettingsServiceType, ruleIds: [NotificationPushRuleId], initialState: NotificationSettingsViewState) { + self.notificationSettingsService = notificationSettingsService + self.ruleIds = ruleIds self.viewState = initialState + + // Observe whent he rules updated to update the state of the settings. + notificationSettingsService.rulesPublisher + .sink(receiveValue: rulesUpdated(newRules:)) + .store(in: &cancellables) + + // Only observe keywords if this settings view has it as an id + if ruleIds.contains(.keywords) { + // Publisher of all the keyword push rules(do not start with '.') + let keywordsRules = notificationSettingsService.contentRulesPublisher + .map { $0.filter { !$0.ruleId.starts(with: ".")} } + + // Map to just the keword strings + let keywords = keywordsRules + .map { Set($0.compactMap { $0.ruleId }) } + + // Update the keyword set + keywords + .sink { [weak self] updatedKeywords in + guard let self = self else { return } + // We don't just assign the new set as it would cause all keywords to get sorted lexigraphically. + // We first sort lexigraphically by preserve the order the user added them. + // The following adds/removes any updates while preserving that ordering. + let newKeywordSet = OrderedSet(updatedKeywords.sorted()) + self.keywordsSet.removeAll { keyword in + !newKeywordSet.contains(keyword) + } + newKeywordSet.forEach { keyword in + self.keywordsSet.append(keyword) + } + } + .store(in: &cancellables) + + // Keword rules were updates, may need to update the setting. + keywordsRules + .map { $0.contains { $0.enabled } } + .sink(receiveValue: keywordRuleUpdated(anyEnabled:)) + .store(in: &cancellables) + + // Update the viewState with the final keywords to be displayed. + $keywordsSet + .map(Array.init) + .weakAssign(to: \.viewState.keywords, on: self) + .store(in: &cancellables) + } } - convenience init(rules: [PushRuleId]) { - let ruleSate = rules.map({ PushRuleSelectedState(ruleId: $0, selected: false) }) - self.init(initialState: NotificationSettingsViewState(saving: false, selectionState: ruleSate)) - } - - deinit { - self.cancelOperations() + convenience init(notificationSettingsService: NotificationSettingsServiceType, ruleIds: [NotificationPushRuleId]) { + let ruleState = Dictionary(uniqueKeysWithValues: ruleIds.map({ ($0, selected: false) })) + self.init(notificationSettingsService: notificationSettingsService, ruleIds: ruleIds, initialState: NotificationSettingsViewState(saving: false, ruleIds: ruleIds, selectionState: ruleState)) } // MARK: - Public - func process(viewAction: NotificationSettingsViewAction) { - switch viewAction { - case .load: - self.loadData() - case .save: - break - case .cancel: - self.cancelOperations() -// self.coordinatorDelegate?.notificationSettingsViewModelDidCancel(self) - case .selectNotification(_, _): - break + func check(ruleID: NotificationPushRuleId, checked: Bool) { + let index = NotificationIndex.index(enabled: checked) + if ruleID == .keywords { + // Keywords is handled differently to other settings + handleCheckKeywords(checked: checked) + return + } + // Get the static definition and update the actions/enabled state. + guard let standardActions = ruleID.standardActions(for: index) else { return } + let enabled = standardActions != .disabled + notificationSettingsService.updatePushRuleActions( + for: ruleID.rawValue, + enabled: enabled, + actions: standardActions.actions + ) + } + + private func handleCheckKeywords(checked: Bool) { + guard !keywordsSet.isEmpty else { + self.viewState.selectionState[.keywords]?.toggle() + return + } + // Get the static definition and update the actions/enabled state for every keyword. + let index = NotificationIndex.index(enabled: checked) + guard let standardActions = NotificationPushRuleId.keywords.standardActions(for: index) else { return } + let enabled = standardActions != .disabled + keywordsSet.forEach { keyword in + notificationSettingsService.updatePushRuleActions( + for: keyword, + enabled: enabled, + actions: standardActions.actions + ) } } + func add(keyword: String) { + keywordsSet.append(keyword) + notificationSettingsService.add(keyword: keyword, enabled: true) + } + + func remove(keyword: String) { + keywordsSet.remove(keyword) + notificationSettingsService.remove(keyword: keyword) + } + // MARK: - Private + private func rulesUpdated(newRules: [MXPushRule]) { + for rule in newRules { + guard let ruleId = NotificationPushRuleId(rawValue: rule.ruleId), + ruleIds.contains(ruleId) else { continue } + self.viewState.selectionState[ruleId] = self.isChecked(rule: rule) + } + } - private func loadData() { + private func keywordRuleUpdated(anyEnabled: Bool) { + if !keywordsSet.isEmpty { + self.viewState.selectionState[.keywords] = anyEnabled + } + } + + /** + Given a push rule check which index/checked state does it match. + Matcing is done by comparing the rule against the static definitions for that rule. + Same logic is used on android. + */ + private func isChecked(rule: MXPushRule) -> Bool { + guard let ruleId = NotificationPushRuleId(rawValue: rule.ruleId) else { return false } + + let firstIndex = NotificationIndex.allCases.first { nextIndex in + return ruleMaches(rule: rule, targetRule: ruleId.standardActions(for: nextIndex)) + } + + guard let index = firstIndex else { + return false + } + + return index.enabled + } + /* + Given a rule does it match the actions int he static definition. + */ + private func ruleMaches(rule: MXPushRule, targetRule: NotificationStandardActions?) -> Bool { + guard let targetRule = targetRule else { + return false + } + if !rule.enabled && targetRule == .disabled { + return true + } + + if rule.enabled, + let actions = targetRule.actions, + rule.highlight == actions.highlight, + rule.sound == actions.sound, + rule.notify == actions.notify, + rule.dontNotify == !actions.notify { + return true + } + return false + } + +} +fileprivate extension MXPushRule { + func getAction(actionType: MXPushRuleActionType, tweakType: String? = nil) -> MXPushRuleAction? { + guard let actions = actions as? [MXPushRuleAction] else { + return nil + } + + return actions.first { action in + var match = action.actionType == actionType + MXLog.debug("action \(action)") + if let tweakType = tweakType, + let actionTweak = action.parameters?["set_tweak"] as? String { + match = match && (tweakType == actionTweak) + } + return match + } } - private func update(viewState: NotificationSettingsViewState) { -// self.viewDelegate?.notificationSettingsViewModel(self, didUpdateViewState: viewState) + var highlight: Bool { + guard let action = getAction(actionType: MXPushRuleActionTypeSetTweak, tweakType: "highlight") else { + return false + } + if let highlight = action.parameters["value"] as? Bool { + return highlight + } + return true } - private func cancelOperations() { + var sound: String? { + guard let action = getAction(actionType: MXPushRuleActionTypeSetTweak, tweakType: "sound") else { + return nil + } + return action.parameters["value"] as? String + } + var notify: Bool { + return getAction(actionType: MXPushRuleActionTypeNotify) != nil + } + + var dontNotify: Bool { + return getAction(actionType: MXPushRuleActionTypeDontNotify) != nil } } diff --git a/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModelType.swift b/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModelType.swift index 55088947f..76358a3bf 100644 --- a/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModelType.swift +++ b/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModelType.swift @@ -18,20 +18,12 @@ import Foundation -protocol NotificationSettingsViewModelViewDelegate: AnyObject { - func notificationSettingsViewModel(_ viewModel: NotificationSettingsViewModelType, didUpdateViewState viewSate: NotificationSettingsViewState) -} - protocol NotificationSettingsViewModelCoordinatorDelegate: AnyObject { func notificationSettingsViewModel(_ viewModel: NotificationSettingsViewModelType, didCompleteWithUserDisplayName userDisplayName: String?) func notificationSettingsViewModelDidCancel(_ viewModel: NotificationSettingsViewModelType) } /// Protocol describing the view model used by `NotificationSettingsViewController` -protocol NotificationSettingsViewModelType { - - var viewDelegate: NotificationSettingsViewModelViewDelegate? { get set } +protocol NotificationSettingsViewModelType { var coordinatorDelegate: NotificationSettingsViewModelCoordinatorDelegate? { get set } - - func process(viewAction: NotificationSettingsViewAction) } diff --git a/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewState.swift b/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewState.swift index 594bcefb7..22bb4fed8 100644 --- a/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewState.swift +++ b/Riot/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewState.swift @@ -18,52 +18,9 @@ import Foundation -/// NotificationSettingsViewController view state - -struct PushRuleSelectedState { - let ruleId: PushRuleId - let selected: Bool -} - -extension PushRuleSelectedState: Identifiable { - var id: String { ruleId.rawValue } -} - -extension PushRuleSelectedState { - var title: String? { - switch ruleId { - case .suppressBots: - return VectorL10n.settingsMessagesByABot - case .inviteMe: - return VectorL10n.settingsRoomInvitations - case .containDisplayName: - return VectorL10n.settingsMessagesContainingDisplayName - case .tombstone: - return VectorL10n.settingsRoomUpgrades - case .roomNotif: - return VectorL10n.settingsMessagesContainingAtRoom - case .containUserName: - return VectorL10n.settingsMessagesContainingUserName - case .call: - return VectorL10n.settingsCallInvitations - case .oneToOneEncryptedRoom: - return VectorL10n.settingsEncryptedDirectMessages - case .oneToOneRoom: - return VectorL10n.settingsDirectMessages - case .allOtherMessages: - return VectorL10n.settingsGroupMessages - case .encrypted: - return VectorL10n.settingsEncryptedGroupMessages - case .keywords: - return VectorL10n.settingsMessagesContainingKeywords - default: - return nil - } - } -} - - struct NotificationSettingsViewState { var saving: Bool - var selectionState: [PushRuleSelectedState] + var ruleIds: [NotificationPushRuleId] + var selectionState: [NotificationPushRuleId: Bool] + var keywords = [String]() } diff --git a/Riot/target.yml b/Riot/target.yml index c96717dbb..ea05e8dfa 100644 --- a/Riot/target.yml +++ b/Riot/target.yml @@ -35,6 +35,8 @@ targets: - target: SiriIntents - target: RiotNSE - target: DesignKit + - package: swift-collections + product: OrderedCollections configFiles: Debug: Debug.xcconfig @@ -144,4 +146,4 @@ targets: - path: Assets/zh_Hans.lproj/Vector.strings - path: Assets/zh_Hant.lproj/InfoPlist.strings - path: Assets/zh_Hant.lproj/Localizable.strings - - path: Assets/zh_Hant.lproj/Vector.strings \ No newline at end of file + - path: Assets/zh_Hant.lproj/Vector.strings diff --git a/project.yml b/project.yml index 095e7067c..36a2e3bab 100644 --- a/project.yml +++ b/project.yml @@ -25,6 +25,11 @@ options: useBaseInternationalization: true postGenCommand: sh Tools/XcodeGen/postGenCommand.sh +packages: + swift-collections: + url: https://github.com/apple/swift-collections + majorVersion: 0.0.5 + include: - path: Riot/target.yml - path: RiotTests/target.yml