diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index 67d23a1c..f0f43018 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -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) - } - #endif + Task { + PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts) + await PushNotificationsService.shared.updateSubscriptions(forceCreate: false) + } } func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {} diff --git a/IceCubesApp/App/Tabs/Settings/AccountSettingView.swift b/IceCubesApp/App/Tabs/Settings/AccountSettingView.swift index dccbad8f..df1666d0 100644 --- a/IceCubesApp/App/Tabs/Settings/AccountSettingView.swift +++ b/IceCubesApp/App/Tabs/Settings/AccountSettingView.swift @@ -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() } diff --git a/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift b/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift index fd520f6d..af70cb4f 100644 --- a/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift +++ b/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift @@ -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() diff --git a/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift b/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift index 21d76741..5851477b 100644 --- a/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift +++ b/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift @@ -12,85 +12,99 @@ 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: { Text("settings.push.main-toggle.description") } .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() } } } diff --git a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift index eaae5c3f..47537f8a 100644 --- a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift +++ b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift @@ -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") diff --git a/Packages/AppAccount/Sources/AppAccount/AppAccountsManager.swift b/Packages/AppAccount/Sources/AppAccount/AppAccountsManager.swift index 9d64dc64..6efb6e8a 100644 --- a/Packages/AppAccount/Sources/AppAccount/AppAccountsManager.swift +++ b/Packages/AppAccount/Sources/AppAccount/AppAccountsManager.swift @@ -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) } } diff --git a/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift b/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift index f4aab96b..ddd4aa59 100644 --- a/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift +++ b/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift @@ -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 { diff --git a/Packages/Env/Sources/Env/PushNotificationsService.swift b/Packages/Env/Sources/Env/PushNotificationsService.swift index 376bc89a..c6b5c2a8 100644 --- a/Packages/Env/Sources/Env/PushNotificationsService.swift +++ b/Packages/Env/Sources/Env/PushNotificationsService.swift @@ -6,6 +6,18 @@ import Network import SwiftUI 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 public class PushNotificationsService: ObservableObject { enum Constants { @@ -14,40 +26,12 @@ public class PushNotificationsService: ObservableObject { 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 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 @@ -63,72 +47,26 @@ 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) - subscriptions.append(sub) - } catch {} + let sub = PushNotificationSubscriptionSettings(account: account, + key: notificationsPrivateKeyAsKey.publicKey.x963Representation, + authKey: notificationsAuthKeyAsKey, + pushToken: pushToken) + subscriptions.append(sub) } - 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 {} - } - 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 + + 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) + } + }) } } @@ -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() + } +}