From 98a80e57047fba2b770c457c5418ae9b59f10a58 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Wed, 3 Feb 2021 21:24:00 -0800 Subject: [PATCH] Notification preferences --- DB/Sources/DB/Identity/IdentityDatabase.swift | 10 +++ Localizations/Localizable.strings | 4 + .../Entities/MastodonNotification.swift | 4 + .../Mastodon/Entities/PushNotification.swift | 12 +-- Metatext.xcodeproj/project.pbxproj | 4 + ...otification Service Extension.entitlements | 4 + .../NotificationService.swift | 90 ++++++++++++------- .../Services/AllIdentitiesService.swift | 5 ++ .../Utilities/AppPreferences.swift | 22 +++++ .../SwiftUI/NotificationPreferencesView.swift | 73 +++++++++++++++ Views/SwiftUI/PreferencesView.swift | 2 + 11 files changed, 188 insertions(+), 42 deletions(-) create mode 100644 Views/SwiftUI/NotificationPreferencesView.swift diff --git a/DB/Sources/DB/Identity/IdentityDatabase.swift b/DB/Sources/DB/Identity/IdentityDatabase.swift index 77af7bc..33c098f 100644 --- a/DB/Sources/DB/Identity/IdentityDatabase.swift +++ b/DB/Sources/DB/Identity/IdentityDatabase.swift @@ -228,6 +228,16 @@ public extension IdentityDatabase { return Identity(info: info) } + + // Only for use in notification extension + func identity(id: Identity.Id) throws -> Identity? { + guard let info = try databaseWriter.read( + IdentityInfo.request(IdentityRecord.filter(IdentityRecord.Columns.id == id)) + .fetchOne) + else { return nil } + + return Identity(info: info) + } } private extension IdentityDatabase { diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 4a740ab..d7c7ed4 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -186,6 +186,10 @@ "preferences.notification-types.mention" = "Mention"; "preferences.notification-types.poll" = "Poll"; "preferences.notification-types.status" = "Status"; +"preferences.notifications" = "Notifications"; +"preferences.notifications.include-account-name" = "Include account name"; +"preferences.notifications.include-pictures" = "Include pictures"; +"preferences.notifications.sounds" = "Sounds"; "preferences.muted-users" = "Muted Users"; "preferences.home-timeline-position-on-startup" = "Home timeline position on startup"; "preferences.notifications-position-on-startup" = "Notifications position on startup"; diff --git a/Mastodon/Sources/Mastodon/Entities/MastodonNotification.swift b/Mastodon/Sources/Mastodon/Entities/MastodonNotification.swift index ced870e..9b7ef91 100644 --- a/Mastodon/Sources/Mastodon/Entities/MastodonNotification.swift +++ b/Mastodon/Sources/Mastodon/Entities/MastodonNotification.swift @@ -38,3 +38,7 @@ public extension MastodonNotification { public static var unknownCase: Self { .unknown } } } + +extension MastodonNotification.NotificationType: Identifiable { + public var id: Self { self } +} diff --git a/Mastodon/Sources/Mastodon/Entities/PushNotification.swift b/Mastodon/Sources/Mastodon/Entities/PushNotification.swift index 4d6e88d..7e4a527 100644 --- a/Mastodon/Sources/Mastodon/Entities/PushNotification.swift +++ b/Mastodon/Sources/Mastodon/Entities/PushNotification.swift @@ -3,21 +3,11 @@ import Foundation public struct PushNotification: Codable { - public enum NotificationType: String, Codable, Unknowable { - case mention - case reblog - case favourite - case follow - case unknown - - public static var unknownCase: Self { .unknown } - } - public let accessToken: String public let body: String public let title: String public let icon: URL public let notificationId: Int - public let notificationType: NotificationType + public let notificationType: MastodonNotification.NotificationType public let preferredLocale: String } diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 128c106..69fd7f8 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ D08B8D72254246E200B1EBEF /* PollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D71254246E200B1EBEF /* PollView.swift */; }; D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D812544D80000B1EBEF /* PollOptionButton.swift */; }; D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */; }; + D08B9F1025CB8E060062D040 /* NotificationPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B9F0F25CB8E060062D040 /* NotificationPreferencesView.swift */; }; D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */; }; D08E52612579D2E100FA2C5F /* DomainBlocksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */; }; D08E5276257C36CA00FA2C5F /* Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D08E526C257C36CA00FA2C5F /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -297,6 +298,7 @@ D08B8D71254246E200B1EBEF /* PollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollView.swift; sourceTree = ""; }; D08B8D812544D80000B1EBEF /* PollOptionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionButton.swift; sourceTree = ""; }; D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollResultView.swift; sourceTree = ""; }; + D08B9F0F25CB8E060062D040 /* NotificationPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreferencesView.swift; sourceTree = ""; }; D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Extensions.swift"; sourceTree = ""; }; D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainBlocksView.swift; sourceTree = ""; }; D08E526C257C36CA00FA2C5F /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -469,6 +471,7 @@ D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */, + D08B9F0F25CB8E060062D040 /* NotificationPreferencesView.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */, D0B32F4F250B373600311912 /* RegistrationView.swift */, @@ -1003,6 +1006,7 @@ D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, + D08B9F1025CB8E060062D040 /* NotificationPreferencesView.swift in Sources */, D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */, D021A60A25C36B32008A0C0D /* IdentityTableViewCell.swift in Sources */, D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */, diff --git a/Notification Service Extension/Notification Service Extension.entitlements b/Notification Service Extension/Notification Service Extension.entitlements index 81bfdaf..cd41796 100644 --- a/Notification Service Extension/Notification Service Extension.entitlements +++ b/Notification Service Extension/Notification Service Extension.entitlements @@ -4,6 +4,10 @@ com.apple.security.app-sandbox + com.apple.security.application-groups + + group.metabolist.metatext + com.apple.security.network.client keychain-access-groups diff --git a/Notification Service Extension/NotificationService.swift b/Notification Service Extension/NotificationService.swift index 345d840..758a75c 100644 --- a/Notification Service Extension/NotificationService.swift +++ b/Notification Service Extension/NotificationService.swift @@ -29,9 +29,11 @@ final class NotificationService: UNNotificationServiceExtension { guard let bestAttemptContent = bestAttemptContent else { return } let pushNotification: PushNotification + let decryptedJSON: Data + let identityId: Identity.Id do { - let decryptedJSON = try Self.extractAndDecrypt(userInfo: request.content.userInfo) + (decryptedJSON, identityId) = try Self.extractAndDecrypt(userInfo: request.content.userInfo) pushNotification = try MastodonDecoder().decode(PushNotification.self, from: decryptedJSON) } catch { @@ -43,35 +45,23 @@ final class NotificationService: UNNotificationServiceExtension { bestAttemptContent.title = pushNotification.title bestAttemptContent.body = XMLUnescaper(string: pushNotification.body).unescape() - let fileName = pushNotification.icon.lastPathComponent - let fileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(fileName) + let appPreferences = AppPreferences(environment: environment) - KingfisherManager.shared.retrieveImage(with: pushNotification.icon) { - switch $0 { - case let .success(result): - let format: ImageFormat + if appPreferences.notificationSounds.contains(pushNotification.notificationType) { + bestAttemptContent.sound = .default + } - switch fileURL.pathExtension.lowercased() { - case "jpg", "jpeg": - format = .JPEG - case "gif": - format = .GIF - case "png": - format = .PNG - default: - format = .unknown - } + if appPreferences.notificationAccountName, + let accountName = try? AllIdentitiesService(environment: environment).identity(id: identityId)?.handle { + bestAttemptContent.subtitle = accountName + } - do { - try result.image.kf.data(format: format)?.write(to: fileURL) - bestAttemptContent.attachments = [try UNNotificationAttachment(identifier: fileName, url: fileURL)] - contentHandler(bestAttemptContent) - } catch { - contentHandler(bestAttemptContent) - } - case .failure: - contentHandler(bestAttemptContent) - } + if appPreferences.notificationPictures { + Self.addImage(pushNotification: pushNotification, + bestAttemptContent: bestAttemptContent, + contentHandler: contentHandler) + } else { + contentHandler(bestAttemptContent) } } @@ -106,10 +96,10 @@ private extension NotificationService { } } - static func extractAndDecrypt(userInfo: [AnyHashable: Any]) throws -> Data { + static func extractAndDecrypt(userInfo: [AnyHashable: Any]) throws -> (Data, Identity.Id) { guard let identityIdString = userInfo[identityIdUserInfoKey] as? String, - let identityId = UUID(uuidString: identityIdString), + let identityId = Identity.Id(uuidString: identityIdString), let encryptedMessageBase64 = (userInfo[encryptedMessageUserInfoKey] as? String)?.URLSafeBase64ToBase64(), let encryptedMessage = Data(base64Encoded: encryptedMessageBase64), let saltBase64 = (userInfo[saltUserInfoKey] as? String)?.URLSafeBase64ToBase64(), @@ -125,11 +115,12 @@ private extension NotificationService { let pushKey = try secretsService.getPushKey() else { throw NotificationServiceError.keychainDataAbsent } - return try decrypt(encryptedMessage: encryptedMessage, + return (try decrypt(encryptedMessage: encryptedMessage, privateKeyData: pushKey, serverPublicKeyData: serverPublicKeyData, auth: auth, - salt: salt) + salt: salt), + identityId) } static func decrypt(encryptedMessage: Data, @@ -179,6 +170,43 @@ private extension NotificationService { return Data(unpadded) } + + static func addImage(pushNotification: PushNotification, + bestAttemptContent: UNMutableNotificationContent, + contentHandler: @escaping (UNNotificationContent) -> Void) { + let fileName = pushNotification.icon.lastPathComponent + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(fileName) + + KingfisherManager.shared.retrieveImage(with: pushNotification.icon) { + switch $0 { + case let .success(result): + let format: ImageFormat + + switch fileURL.pathExtension.lowercased() { + case "jpg", "jpeg": + format = .JPEG + case "gif": + format = .GIF + case "png": + format = .PNG + default: + format = .unknown + } + + do { + try result.image.kf.data(format: format)?.write(to: fileURL) + bestAttemptContent.attachments = + [try UNNotificationAttachment(identifier: fileName, url: fileURL)] + contentHandler(bestAttemptContent) + } catch { + contentHandler(bestAttemptContent) + } + case .failure: + contentHandler(bestAttemptContent) + } + } + } } extension String { diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift index fa7224a..2c5e366 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift @@ -136,6 +136,11 @@ public extension AllIdentitiesService { .ignoreOutput() .eraseToAnyPublisher() } + + // Only for use in notification extension + func identity(id: Identity.Id) throws -> Identity? { + try database.identity(id: id) + } } private extension AllIdentitiesService.IdentityCreation { diff --git a/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift b/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift index 42a7eae..bbc7b99 100644 --- a/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift +++ b/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift @@ -140,6 +140,15 @@ public extension AppPreferences { set { self[.defaultEmojiSkinTone] = newValue?.rawValue } } + var notificationSounds: Set { + get { + Set((self[.notificationSounds] as [String]?)?.compactMap { + MastodonNotification.NotificationType(rawValue: $0) + } ?? MastodonNotification.NotificationType.allCasesExceptUnknown) + } + set { self[.notificationSounds] = newValue.map { $0.rawValue } } + } + var shouldReduceMotion: Bool { systemReduceMotion() && useSystemReduceMotionForMedia } @@ -167,6 +176,16 @@ public extension AppPreferences { get { self[.requireDoubleTapToFavorite] ?? false } set { self[.requireDoubleTapToFavorite] = newValue } } + + var notificationPictures: Bool { + get { self[.notificationPictures] ?? true } + set { self[.notificationPictures] = newValue } + } + + var notificationAccountName: Bool { + get { self[.notificationAccountName] ?? false } + set { self[.notificationAccountName] = newValue } + } } private extension AppPreferences { @@ -183,6 +202,9 @@ private extension AppPreferences { case notificationsTabBehavior case defaultEmojiSkinTone case showReblogAndFavoriteCounts + case notificationPictures + case notificationAccountName + case notificationSounds } subscript(index: Item) -> T? { diff --git a/Views/SwiftUI/NotificationPreferencesView.swift b/Views/SwiftUI/NotificationPreferencesView.swift new file mode 100644 index 0000000..4c0880a --- /dev/null +++ b/Views/SwiftUI/NotificationPreferencesView.swift @@ -0,0 +1,73 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Mastodon +import SwiftUI +import ViewModels + +struct NotificationPreferencesView: View { + @StateObject var viewModel: PreferencesViewModel + @StateObject var identityContext: IdentityContext + + init(viewModel: PreferencesViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + _identityContext = StateObject(wrappedValue: viewModel.identityContext) + } + + var body: some View { + Form { + Section { + Toggle("preferences.notifications.include-pictures", + isOn: $identityContext.appPreferences.notificationPictures) + Toggle("preferences.notifications.include-account-name", + isOn: $identityContext.appPreferences.notificationAccountName) + } + Section(header: Text("preferences.notifications.sounds")) { + ForEach(MastodonNotification.NotificationType.allCasesExceptUnknown) { type in + Toggle(type.localizedStringKey, isOn: .init { + viewModel.identityContext.appPreferences.notificationSounds.contains(type) + } set: { + if $0 { + viewModel.identityContext.appPreferences.notificationSounds.insert(type) + } else { + viewModel.identityContext.appPreferences.notificationSounds.remove(type) + } + }) + } + } + } + .navigationTitle("preferences.notifications") + } +} + +extension MastodonNotification.NotificationType { + var localizedStringKey: LocalizedStringKey { + switch self { + case .follow: + return "preferences.notification-types.follow" + case .mention: + return "preferences.notification-types.mention" + case .reblog: + return "preferences.notification-types.reblog" + case .favourite: + return "preferences.notification-types.favourite" + case .poll: + return "preferences.notification-types.poll" + case .followRequest: + return "preferences.notification-types.follow-request" + case .status: + return "preferences.notification-types.status" + case .unknown: + return "" + } + } +} + +#if DEBUG +import PreviewViewModels + +struct NotificationPreferencesView_Previews: PreviewProvider { + static var previews: some View { + NotificationPreferencesView(viewModel: .init(identityContext: .preview)) + } +} +#endif diff --git a/Views/SwiftUI/PreferencesView.swift b/Views/SwiftUI/PreferencesView.swift index 89cf71a..2287657 100644 --- a/Views/SwiftUI/PreferencesView.swift +++ b/Views/SwiftUI/PreferencesView.swift @@ -63,6 +63,8 @@ struct PreferencesView: View { && viewModel.identityContext.identity.authenticated) } Section(header: Text("preferences.app")) { + NavigationLink("preferences.notifications", + destination: NotificationPreferencesView(viewModel: viewModel)) Picker("preferences.status-word", selection: $identityContext.appPreferences.statusWord) { ForEach(AppPreferences.StatusWord.allCases) { option in