Push notifications: Per account settings

This commit is contained in:
Thomas Ricouard 2023-01-26 13:21:35 +01:00
parent 36a9eefe21
commit d1ed8e962b
8 changed files with 201 additions and 156 deletions

View file

@ -214,12 +214,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{ {
PushNotificationsService.shared.pushToken = deviceToken PushNotificationsService.shared.pushToken = deviceToken
#if !DEBUG Task {
Task { PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) await PushNotificationsService.shared.updateSubscriptions(forceCreate: false)
await PushNotificationsService.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) }
}
#endif
} }
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {} func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}

View file

@ -33,15 +33,20 @@ struct AccountSettingsView: View {
isEditingFilters = true isEditingFilters = true
} }
} }
if let subscription = pushNotifications.subscriptions.first(where: { $0.account.token == appAccount.oauthToken }) {
NavigationLink(destination: PushNotificationsView(subscription: subscription)) {
Label("settings.general.push-notifications", systemImage: "bell.and.waves.left.and.right")
}
}
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
Section { Section {
Button(role: .destructive) { Button(role: .destructive) {
if let token = appAccount.oauthToken { if let token = appAccount.oauthToken {
Task { Task {
await pushNotifications.deleteSubscriptions(accounts: [.init(server: appAccount.server, if let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token }) {
token: token, await sub.deleteSubscription()
accountName: appAccount.accountName)]) }
appAccountsManager.delete(account: appAccount) appAccountsManager.delete(account: appAccount)
dismiss() dismiss()
} }

View file

@ -231,7 +231,8 @@ struct AddAccountView: View {
accountName: "\(account.acct)@\(client.server)", accountName: "\(account.acct)@\(client.server)",
oauthToken: oauthToken)) oauthToken: oauthToken))
Task { Task {
await pushNotifications.updateSubscriptions(accounts: appAccountsManager.pushAccounts) pushNotifications.setAccounts(accounts: appAccountsManager.pushAccounts)
await pushNotifications.updateSubscriptions(forceCreate: true)
} }
isSigninIn = false isSigninIn = false
dismiss() dismiss()

View file

@ -12,85 +12,99 @@ struct PushNotificationsView: View {
@EnvironmentObject private var appAccountsManager: AppAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var pushNotifications: PushNotificationsService @EnvironmentObject private var pushNotifications: PushNotificationsService
@State private var subscriptions: [PushSubscription] = [] @StateObject public var subscription: PushNotificationSubscriptionSettings
var body: some View { var body: some View {
Form { Form {
Section { Section {
Toggle(isOn: $pushNotifications.isPushEnabled) { Toggle(isOn: .init(get: {
subscription.isEnabled
}, set: { newValue in
subscription.isEnabled = newValue
if newValue {
updateSubscription()
} else {
deleteSubscription()
}
})) {
Text("settings.push.main-toggle") Text("settings.push.main-toggle")
} }
} footer: { } footer: {
Text("settings.push.main-toggle.description") Text("settings.push.main-toggle.description")
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
if pushNotifications.isPushEnabled { if subscription.isEnabled {
Section { Section {
Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) { Toggle(isOn: .init(get: {
subscription.isMentionNotificationEnabled
}, set: { newValue in
subscription.isMentionNotificationEnabled = newValue
updateSubscription()
})) {
Label("settings.push.mentions", systemImage: "at") Label("settings.push.mentions", systemImage: "at")
} }
Toggle(isOn: $pushNotifications.isFollowNotificationEnabled) { Toggle(isOn: .init(get: {
subscription.isFollowNotificationEnabled
}, set: { newValue in
subscription.isFollowNotificationEnabled = newValue
updateSubscription()
})) {
Label("settings.push.follows", systemImage: "person.badge.plus") Label("settings.push.follows", systemImage: "person.badge.plus")
} }
Toggle(isOn: $pushNotifications.isFavoriteNotificationEnabled) { Toggle(isOn: .init(get: {
subscription.isFavoriteNotificationEnabled
}, set: { newValue in
subscription.isFavoriteNotificationEnabled = newValue
updateSubscription()
})) {
Label("settings.push.favorites", systemImage: "star") Label("settings.push.favorites", systemImage: "star")
} }
Toggle(isOn: $pushNotifications.isReblogNotificationEnabled) { Toggle(isOn: .init(get: {
subscription.isReblogNotificationEnabled
}, set: { newValue in
subscription.isReblogNotificationEnabled = newValue
updateSubscription()
})) {
Label("settings.push.boosts", systemImage: "arrow.left.arrow.right.circle") Label("settings.push.boosts", systemImage: "arrow.left.arrow.right.circle")
} }
Toggle(isOn: $pushNotifications.isPollNotificationEnabled) { Toggle(isOn: .init(get: {
subscription.isPollNotificationEnabled
}, set: { newValue in
subscription.isPollNotificationEnabled = newValue
updateSubscription()
})) {
Label("settings.push.polls", systemImage: "chart.bar") Label("settings.push.polls", systemImage: "chart.bar")
} }
Toggle(isOn: $pushNotifications.isNewPostsNotificationEnabled) { Toggle(isOn: .init(get: {
subscription.isNewPostsNotificationEnabled
}, set: { newValue in
subscription.isNewPostsNotificationEnabled = newValue
updateSubscription()
})) {
Label("settings.push.new-posts", systemImage: "bubble.right") Label("settings.push.new-posts", systemImage: "bubble.right")
} }
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.transition(.move(edge: .bottom))
} }
} }
.navigationTitle("settings.push.navigation-title") .navigationTitle("settings.push.navigation-title")
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor) .background(theme.secondaryBackgroundColor)
.onAppear { .task {
Task { await subscription.fetchSubscription()
await pushNotifications.fetchSubscriptions(accounts: appAccountsManager.pushAccounts)
}
}
.onChange(of: pushNotifications.isPushEnabled) { newValue in
pushNotifications.isUserPushEnabled = newValue
if !newValue {
Task {
await pushNotifications.deleteSubscriptions(accounts: appAccountsManager.pushAccounts)
}
} else {
updateSubscriptions()
}
}
.onChange(of: pushNotifications.isFollowNotificationEnabled) { _ in
updateSubscriptions()
}
.onChange(of: pushNotifications.isPollNotificationEnabled) { _ in
updateSubscriptions()
}
.onChange(of: pushNotifications.isReblogNotificationEnabled) { _ in
updateSubscriptions()
}
.onChange(of: pushNotifications.isMentionNotificationEnabled) { _ in
updateSubscriptions()
}
.onChange(of: pushNotifications.isFavoriteNotificationEnabled) { _ in
updateSubscriptions()
}
.onChange(of: pushNotifications.isNewPostsNotificationEnabled) { _ in
updateSubscriptions()
} }
} }
private func updateSubscriptions() { private func updateSubscription() {
Task { Task {
await pushNotifications.updateSubscriptions(accounts: appAccountsManager.pushAccounts) await subscription.updateSubscription(forceCreate: true)
}
}
private func deleteSubscription() {
Task {
await subscription.deleteSubscription()
} }
} }
} }

View file

@ -73,11 +73,10 @@ struct SettingsTabs: View {
.onDelete { indexSet in .onDelete { indexSet in
if let index = indexSet.first { if let index = indexSet.first {
let account = appAccountsManager.availableAccounts[index] let account = appAccountsManager.availableAccounts[index]
if let token = account.oauthToken { if let token = account.oauthToken,
let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token }) {
Task { Task {
await pushNotifications.deleteSubscriptions(accounts: [.init(server: account.server, await sub.deleteSubscription()
token: token,
accountName: account.accountName)])
} }
} }
appAccountsManager.delete(account: account) appAccountsManager.delete(account: account)
@ -91,9 +90,6 @@ struct SettingsTabs: View {
@ViewBuilder @ViewBuilder
private var generalSection: some View { private var generalSection: some View {
Section("settings.section.general") { Section("settings.section.general") {
NavigationLink(destination: PushNotificationsView()) {
Label("settings.general.push-notifications", systemImage: "bell.and.waves.left.and.right")
}
if let instanceData = currentInstance.instance { if let instanceData = currentInstance.instance {
NavigationLink(destination: InstanceInfoView(instance: instanceData)) { NavigationLink(destination: InstanceInfoView(instance: instanceData)) {
Label("settings.general.instance", systemImage: "server.rack") Label("settings.general.instance", systemImage: "server.rack")

View file

@ -19,7 +19,7 @@ public class AppAccountsManager: ObservableObject {
@Published public var availableAccounts: [AppAccount] @Published public var availableAccounts: [AppAccount]
@Published public var currentClient: Client @Published public var currentClient: Client
public var pushAccounts: [PushNotificationsService.PushAccounts] { public var pushAccounts: [PushAccount] {
availableAccounts.filter { $0.oauthToken != nil } availableAccounts.filter { $0.oauthToken != nil }
.map { .init(server: $0.server, token: $0.oauthToken!, accountName: $0.accountName) } .map { .init(server: $0.server, token: $0.oauthToken!, accountName: $0.accountName) }
} }

View file

@ -58,7 +58,7 @@ public struct AppAccountsSelectorView: View {
if let avatar = currentAccount.account?.avatar { if let avatar = currentAccount.account?.avatar {
AvatarView(url: avatar, size: avatarSize) AvatarView(url: avatar, size: avatarSize)
} else { } else {
EmptyView() ProgressView()
} }
}.overlay(alignment: .topTrailing) { }.overlay(alignment: .topTrailing) {
if !currentAccount.followRequests.isEmpty { if !currentAccount.followRequests.isEmpty {

View file

@ -6,6 +6,18 @@ import Network
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
public struct PushAccount {
public let server: String
public let token: OauthToken
public let accountName: String?
public init(server: String, token: OauthToken, accountName: String?) {
self.server = server
self.token = token
self.accountName = accountName
}
}
@MainActor @MainActor
public class PushNotificationsService: ObservableObject { public class PushNotificationsService: ObservableObject {
enum Constants { enum Constants {
@ -14,40 +26,12 @@ public class PushNotificationsService: ObservableObject {
static let keychainPrivateKey = "notifications_private_key" static let keychainPrivateKey = "notifications_private_key"
} }
public struct PushAccounts {
public let server: String
public let token: OauthToken
public let accountName: String?
public init(server: String, token: OauthToken, accountName: String?) {
self.server = server
self.token = token
self.accountName = accountName
}
}
public static let shared = PushNotificationsService() public static let shared = PushNotificationsService()
public private(set) var subscriptions: [PushNotificationSubscriptionSettings] = []
@Published public var pushToken: Data? @Published public var pushToken: Data?
@AppStorage("user_push_is_on") public var isUserPushEnabled: Bool = true
@Published public var isPushEnabled: Bool = false {
didSet {
if !oldValue && isPushEnabled {
requestPushNotifications()
}
}
}
@Published public var isFollowNotificationEnabled: Bool = true
@Published public var isFavoriteNotificationEnabled: Bool = true
@Published public var isReblogNotificationEnabled: Bool = true
@Published public var isMentionNotificationEnabled: Bool = true
@Published public var isPollNotificationEnabled: Bool = true
@Published public var isNewPostsNotificationEnabled: Bool = true
private var subscriptions: [PushSubscription] = []
private var keychain: KeychainSwift { private var keychain: KeychainSwift {
let keychain = KeychainSwift() let keychain = KeychainSwift()
#if !DEBUG #if !DEBUG
@ -63,72 +47,26 @@ public class PushNotificationsService: ObservableObject {
} }
} }
} }
public func fetchSubscriptions(accounts: [PushAccounts]) async { public func setAccounts(accounts: [PushAccount]) {
subscriptions = [] subscriptions = []
for account in accounts { for account in accounts {
let client = Client(server: account.server, oauthToken: account.token) let sub = PushNotificationSubscriptionSettings(account: account,
do { key: notificationsPrivateKeyAsKey.publicKey.x963Representation,
let sub: PushSubscription = try await client.get(endpoint: Push.subscription) authKey: notificationsAuthKeyAsKey,
subscriptions.append(sub) pushToken: pushToken)
} catch {} subscriptions.append(sub)
} }
refreshSubscriptionsUI()
} }
public func updateSubscriptions(accounts: [PushAccounts]) async { public func updateSubscriptions(forceCreate: Bool) async {
subscriptions = [] for subscription in subscriptions {
let key = notificationsPrivateKeyAsKey.publicKey.x963Representation await withTaskGroup(of: Void.self, body: { group in
let authKey = notificationsAuthKeyAsKey group.addTask {
guard let pushToken = pushToken, isUserPushEnabled else { return } await subscription.fetchSubscription()
for account in accounts { await subscription.updateSubscription(forceCreate: forceCreate)
let client = Client(server: account.server, oauthToken: account.token) }
do { })
var listenerURL = Constants.endpoint
listenerURL += "/push/"
listenerURL += pushToken.hexString
listenerURL += "/\(account.accountName ?? account.server)"
#if DEBUG
listenerURL += "?sandbox=true"
#endif
let sub: PushSubscription =
try await client.post(endpoint: Push.createSub(endpoint: listenerURL,
p256dh: key,
auth: authKey,
mentions: isMentionNotificationEnabled,
status: isNewPostsNotificationEnabled,
reblog: isReblogNotificationEnabled,
follow: isFollowNotificationEnabled,
favorite: isFavoriteNotificationEnabled,
poll: isPollNotificationEnabled))
subscriptions.append(sub)
} catch {}
}
refreshSubscriptionsUI()
}
public func deleteSubscriptions(accounts: [PushAccounts]) async {
for account in accounts {
let client = Client(server: account.server, oauthToken: account.token)
do {
_ = try await client.delete(endpoint: Push.subscription)
} catch {}
}
await fetchSubscriptions(accounts: accounts)
refreshSubscriptionsUI()
}
private func refreshSubscriptionsUI() {
if let sub = subscriptions.first {
isPushEnabled = true
isFollowNotificationEnabled = sub.alerts.follow
isFavoriteNotificationEnabled = sub.alerts.favourite
isReblogNotificationEnabled = sub.alerts.reblog
isMentionNotificationEnabled = sub.alerts.mention
isPollNotificationEnabled = sub.alerts.poll
isNewPostsNotificationEnabled = sub.alerts.status
} else {
isPushEnabled = false
} }
} }
@ -183,3 +121,96 @@ extension Data {
return map { String(format: "%02.2hhx", arguments: [$0]) }.joined() return map { String(format: "%02.2hhx", arguments: [$0]) }.joined()
} }
} }
@MainActor
public class PushNotificationSubscriptionSettings: ObservableObject {
@Published public var isEnabled: Bool = true
@Published public var isFollowNotificationEnabled: Bool = true
@Published public var isFavoriteNotificationEnabled: Bool = true
@Published public var isReblogNotificationEnabled: Bool = true
@Published public var isMentionNotificationEnabled: Bool = true
@Published public var isPollNotificationEnabled: Bool = true
@Published public var isNewPostsNotificationEnabled: Bool = true
public let account: PushAccount
private let key: Data
private let authKey: Data
public var pushToken: Data?
public private(set) var subscription: PushSubscription?
public init(account: PushAccount, key: Data, authKey: Data, pushToken: Data?) {
self.account = account
self.key = key
self.authKey = authKey
self.pushToken = pushToken
}
private func refreshSubscriptionsUI() {
if let subscription {
isFollowNotificationEnabled = subscription.alerts.follow
isFavoriteNotificationEnabled = subscription.alerts.favourite
isReblogNotificationEnabled = subscription.alerts.reblog
isMentionNotificationEnabled = subscription.alerts.mention
isPollNotificationEnabled = subscription.alerts.poll
isNewPostsNotificationEnabled = subscription.alerts.status
}
}
public func updateSubscription(forceCreate: Bool) async {
guard let pushToken = pushToken, subscription != nil || forceCreate else { return }
let client = Client(server: account.server, oauthToken: account.token)
do {
var listenerURL = PushNotificationsService.Constants.endpoint
listenerURL += "/push/"
listenerURL += pushToken.hexString
listenerURL += "/\(account.accountName ?? account.server)"
#if DEBUG
listenerURL += "?sandbox=true"
#endif
subscription =
try await client.post(endpoint: Push.createSub(endpoint: listenerURL,
p256dh: key,
auth: authKey,
mentions: isMentionNotificationEnabled,
status: isNewPostsNotificationEnabled,
reblog: isReblogNotificationEnabled,
follow: isFollowNotificationEnabled,
favorite: isFavoriteNotificationEnabled,
poll: isPollNotificationEnabled))
isEnabled = subscription != nil
} catch {
isEnabled = false
}
refreshSubscriptionsUI()
}
public func deleteSubscription() async {
let client = Client(server: account.server, oauthToken: account.token)
do {
_ = try await client.delete(endpoint: Push.subscription)
subscription = nil
await fetchSubscription()
refreshSubscriptionsUI()
while subscription != nil {
await deleteSubscription()
}
isEnabled = false
} catch {}
}
public func fetchSubscription() async {
let client = Client(server: account.server, oauthToken: account.token)
do {
subscription = try await client.get(endpoint: Push.subscription)
isEnabled = subscription != nil
} catch {
subscription = nil
isEnabled = false
}
refreshSubscriptionsUI()
}
}