Notification preferences

This commit is contained in:
Justin Mazzocchi 2021-02-03 21:24:00 -08:00
parent a566babe3a
commit 98a80e5704
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
11 changed files with 188 additions and 42 deletions

View file

@ -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 {

View file

@ -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";

View file

@ -38,3 +38,7 @@ public extension MastodonNotification {
public static var unknownCase: Self { .unknown }
}
}
extension MastodonNotification.NotificationType: Identifiable {
public var id: Self { self }
}

View file

@ -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
}

View file

@ -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 = "<group>"; };
D08B8D812544D80000B1EBEF /* PollOptionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionButton.swift; sourceTree = "<group>"; };
D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollResultView.swift; sourceTree = "<group>"; };
D08B9F0F25CB8E060062D040 /* NotificationPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreferencesView.swift; sourceTree = "<group>"; };
D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Extensions.swift"; sourceTree = "<group>"; };
D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainBlocksView.swift; sourceTree = "<group>"; };
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 */,

View file

@ -4,6 +4,10 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.metabolist.metatext</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>

View file

@ -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 {

View file

@ -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 {

View file

@ -140,6 +140,15 @@ public extension AppPreferences {
set { self[.defaultEmojiSkinTone] = newValue?.rawValue }
}
var notificationSounds: Set<MastodonNotification.NotificationType> {
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<T>(index: Item) -> T? {

View file

@ -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

View file

@ -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