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)
{
PushNotificationsService.shared.pushToken = deviceToken
#if !DEBUG
Task {
await PushNotificationsService.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(forceCreate: false)
}
#endif
}
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}

View file

@ -33,15 +33,20 @@ struct AccountSettingsView: View {
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)
Section {
Button(role: .destructive) {
if let token = appAccount.oauthToken {
Task {
await pushNotifications.deleteSubscriptions(accounts: [.init(server: appAccount.server,
token: token,
accountName: appAccount.accountName)])
if let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token }) {
await sub.deleteSubscription()
}
appAccountsManager.delete(account: appAccount)
dismiss()
}

View file

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

View file

@ -12,12 +12,21 @@ struct PushNotificationsView: View {
@EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var pushNotifications: PushNotificationsService
@State private var subscriptions: [PushSubscription] = []
@StateObject public var subscription: PushNotificationSubscriptionSettings
var body: some View {
Form {
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")
}
} footer: {
@ -25,72 +34,77 @@ struct PushNotificationsView: View {
}
.listRowBackground(theme.primaryBackgroundColor)
if pushNotifications.isPushEnabled {
if subscription.isEnabled {
Section {
Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) {
Toggle(isOn: .init(get: {
subscription.isMentionNotificationEnabled
}, set: { newValue in
subscription.isMentionNotificationEnabled = newValue
updateSubscription()
})) {
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")
}
Toggle(isOn: $pushNotifications.isFavoriteNotificationEnabled) {
Toggle(isOn: .init(get: {
subscription.isFavoriteNotificationEnabled
}, set: { newValue in
subscription.isFavoriteNotificationEnabled = newValue
updateSubscription()
})) {
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")
}
Toggle(isOn: $pushNotifications.isPollNotificationEnabled) {
Toggle(isOn: .init(get: {
subscription.isPollNotificationEnabled
}, set: { newValue in
subscription.isPollNotificationEnabled = newValue
updateSubscription()
})) {
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")
}
}
.listRowBackground(theme.primaryBackgroundColor)
.transition(.move(edge: .bottom))
}
}
.navigationTitle("settings.push.navigation-title")
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.onAppear {
Task {
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()
.task {
await subscription.fetchSubscription()
}
}
private func updateSubscriptions() {
private func updateSubscription() {
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
if let index = indexSet.first {
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 {
await pushNotifications.deleteSubscriptions(accounts: [.init(server: account.server,
token: token,
accountName: account.accountName)])
await sub.deleteSubscription()
}
}
appAccountsManager.delete(account: account)
@ -91,9 +90,6 @@ struct SettingsTabs: View {
@ViewBuilder
private var generalSection: some View {
Section("settings.section.general") {
NavigationLink(destination: PushNotificationsView()) {
Label("settings.general.push-notifications", systemImage: "bell.and.waves.left.and.right")
}
if let instanceData = currentInstance.instance {
NavigationLink(destination: InstanceInfoView(instance: instanceData)) {
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 currentClient: Client
public var pushAccounts: [PushNotificationsService.PushAccounts] {
public var pushAccounts: [PushAccount] {
availableAccounts.filter { $0.oauthToken != nil }
.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 {
AvatarView(url: avatar, size: avatarSize)
} else {
EmptyView()
ProgressView()
}
}.overlay(alignment: .topTrailing) {
if !currentAccount.followRequests.isEmpty {

View file

@ -6,15 +6,7 @@ import Network
import SwiftUI
import UserNotifications
@MainActor
public class PushNotificationsService: ObservableObject {
enum Constants {
static let endpoint = "https://icecubesrelay.fly.dev"
static let keychainAuthKey = "notifications_auth_key"
static let keychainPrivateKey = "notifications_private_key"
}
public struct PushAccounts {
public struct PushAccount {
public let server: String
public let token: OauthToken
public let accountName: String?
@ -26,28 +18,20 @@ public class PushNotificationsService: ObservableObject {
}
}
@MainActor
public class PushNotificationsService: ObservableObject {
enum Constants {
static let endpoint = "https://icecubesrelay.fly.dev"
static let keychainAuthKey = "notifications_auth_key"
static let keychainPrivateKey = "notifications_private_key"
}
public static let shared = PushNotificationsService()
public private(set) var subscriptions: [PushNotificationSubscriptionSettings] = []
@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 {
let keychain = KeychainSwift()
#if !DEBUG
@ -64,71 +48,25 @@ public class PushNotificationsService: ObservableObject {
}
}
public func fetchSubscriptions(accounts: [PushAccounts]) async {
public func setAccounts(accounts: [PushAccount]) {
subscriptions = []
for account in accounts {
let client = Client(server: account.server, oauthToken: account.token)
do {
let sub: PushSubscription = try await client.get(endpoint: Push.subscription)
let sub = PushNotificationSubscriptionSettings(account: account,
key: notificationsPrivateKeyAsKey.publicKey.x963Representation,
authKey: notificationsAuthKeyAsKey,
pushToken: pushToken)
subscriptions.append(sub)
} catch {}
}
refreshSubscriptionsUI()
}
public func updateSubscriptions(accounts: [PushAccounts]) async {
subscriptions = []
let key = notificationsPrivateKeyAsKey.publicKey.x963Representation
let authKey = notificationsAuthKeyAsKey
guard let pushToken = pushToken, isUserPushEnabled else { return }
for account in accounts {
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 {}
public func updateSubscriptions(forceCreate: Bool) async {
for subscription in subscriptions {
await withTaskGroup(of: Void.self, body: { group in
group.addTask {
await subscription.fetchSubscription()
await subscription.updateSubscription(forceCreate: forceCreate)
}
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()
}
}
@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()
}
}