diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index a139ded4..a1889c71 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */; }; D64B967C2BC19C28002C8990 /* NotificationsPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */; }; D64B967F2BC1D447002C8990 /* PushManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B967E2BC1D447002C8990 /* PushManager.swift */; }; + D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; }; D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; }; @@ -525,6 +526,7 @@ D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = ""; }; D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPrefsView.swift; sourceTree = ""; }; D64B967E2BC1D447002C8990 /* PushManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManager.swift; sourceTree = ""; }; + D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = ""; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = ""; }; D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = ""; }; @@ -1112,6 +1114,7 @@ children = ( D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */, 04586B4022B2FFB10021BD04 /* PreferencesView.swift */, + D64B96802BC3279D002C8990 /* PrefsAccountView.swift */, 04586B4222B301470021BD04 /* AppearancePrefsView.swift */, D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */, D6958F3C2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift */, @@ -2084,6 +2087,7 @@ D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */, D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */, D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */, + D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */, D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */, D646DCD62A07ED970059ECEB /* FollowNotificationGroupCollectionViewCell.swift in Sources */, D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */, diff --git a/Tusker/Push/PushManager.swift b/Tusker/Push/PushManager.swift index f41f7fbf..b551a05a 100644 --- a/Tusker/Push/PushManager.swift +++ b/Tusker/Push/PushManager.swift @@ -11,6 +11,7 @@ import OSLog #if canImport(Sentry) import Sentry #endif +import Pachyderm private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager") @@ -39,7 +40,7 @@ struct PushManager { } @MainActor -protocol _PushManager { +protocol _PushManager: ObservableObject { var enabled: Bool { get } var pushProxyRegistration: PushProxyRegistration? { get } @@ -111,9 +112,23 @@ private class PushManagerImpl: _PushManager { } } set { + objectWillChange.send() defaults.setValue(newValue?.defaultsDict, forKey: "PushProxyRegistration") } } + private(set) var pushSubscriptions: [PushSubscription] { + get { + if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] { + return array.compactMap(PushSubscription.init(defaultsDict:)) + } else { + return [] + } + } + set { + objectWillChange.send() + defaults.setValue(newValue.map(\.defaultsDict), forKey: "PushSubscriptions") + } + } init(endpoint: URL) { self.endpoint = endpoint @@ -123,8 +138,8 @@ private class PushManagerImpl: _PushManager { guard remoteNotificationsRegistrationContinuation == nil else { throw PushRegistrationError.alreadyRegistering } - let deviceToken = try await getDeviceToken() - logger.debug("Got device token: \(deviceToken.hexEncodedString())") + let deviceToken = try await getDeviceToken().hexEncodedString() + logger.debug("Got device token: \(deviceToken)") let registration: PushProxyRegistration do { registration = try await register(deviceToken: deviceToken) @@ -145,10 +160,15 @@ private class PushManagerImpl: _PushManager { url.path = "/app/v1/registrations/\(pushProxyRegistration.id)" var request = URLRequest(url: url.url!) request.httpMethod = "DELETE" - let (_, resp) = try await URLSession.shared.data(for: request) + let (data, resp) = try await URLSession.shared.data(for: request) let status = (resp as! HTTPURLResponse).statusCode - if !(200...299).contains(status) { + if (200...299).contains(status) { + self.pushProxyRegistration = nil + logger.debug("Unregistered from proxy") + } else { logger.error("Unregistering: unexpected status \(status)") + let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: nil) + throw PushRegistrationError.unregistering(error) } } @@ -158,8 +178,11 @@ private class PushManagerImpl: _PushManager { } logger.debug("Push proxy registration: \(pushProxyRegistration.id, privacy: .public)") do { - let token = try await getDeviceToken() - + let token = try await getDeviceToken().hexEncodedString() + guard token != pushProxyRegistration.deviceToken else { + // already up-to-date, nothing to do + return + } let newRegistration = try await update(registration: pushProxyRegistration, deviceToken: token) if pushProxyRegistration.endpoint != newRegistration.endpoint { // TODO: update subscriptions if the endpoint's changed @@ -190,13 +213,13 @@ private class PushManagerImpl: _PushManager { remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error)) } - private func register(deviceToken: Data) async throws -> PushProxyRegistration { + private func register(deviceToken: String) async throws -> PushProxyRegistration { var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! url.path = "/app/v1/registrations" var request = URLRequest(url: url.url!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "content-type") - request.httpBody = try! JSONEncoder().encode(PushRegistrationParams(transactionID: "TODO", environment: apnsEnvironment, deviceToken: deviceToken.hexEncodedString(), pushVersion: 1)) + request.httpBody = try! JSONEncoder().encode(PushRegistrationParams(transactionID: "TODO", environment: apnsEnvironment, deviceToken: deviceToken, pushVersion: 1)) let (data, resp) = try await URLSession.shared.data(for: request) let status = (resp as! HTTPURLResponse).statusCode guard (200...299).contains(status) else { @@ -207,13 +230,13 @@ private class PushManagerImpl: _PushManager { return try JSONDecoder().decode(PushProxyRegistration.self, from: data) } - private func update(registration: PushProxyRegistration, deviceToken: Data) async throws -> PushProxyRegistration { + private func update(registration: PushProxyRegistration, deviceToken: String) async throws -> PushProxyRegistration { var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! url.path = "/app/v1/registrations/\(registration.id)" var request = URLRequest(url: url.url!) request.httpMethod = "PUT" request.setValue("application/json", forHTTPHeaderField: "content-type") - request.httpBody = try! JSONEncoder().encode(PushUpdateParams(environment: apnsEnvironment, deviceToken: deviceToken.hexEncodedString(), pushVersion: 1)) + request.httpBody = try! JSONEncoder().encode(PushUpdateParams(environment: apnsEnvironment, deviceToken: deviceToken, pushVersion: 1)) let (data, resp) = try await URLSession.shared.data(for: request) let status = (resp as! HTTPURLResponse).statusCode guard (200...299).contains(status) else { @@ -229,6 +252,7 @@ enum PushRegistrationError: LocalizedError { case alreadyRegistering case registeringForRemoteNotifications(any Error) case registeringWithProxy(any Error) + case unregistering(any Error) var errorDescription: String? { switch self { @@ -238,6 +262,8 @@ enum PushRegistrationError: LocalizedError { "Remote notifications: \(error.localizedDescription)" case .registeringWithProxy(let error): "Proxy: \(error.localizedDescription)" + case .unregistering(let error): + "Unregistering: \(error.localizedDescription)" } } } @@ -290,22 +316,31 @@ private struct PushUpdateParams: Encodable { struct PushProxyRegistration: Decodable { let id: String let endpoint: URL + let deviceToken: String fileprivate var defaultsDict: [String: String] { [ "id": id, - "endpoint": endpoint.absoluteString + "endpoint": endpoint.absoluteString, + "deviceToken": deviceToken, ] } fileprivate init?(defaultsDict: [String: String]) { guard let id = defaultsDict["id"], - let endpoint = defaultsDict["endpoint"], - let endpointURL = URL(string: endpoint) else { + let endpoint = defaultsDict["endpoint"].flatMap(URL.init(string:)), + let deviceToken = defaultsDict["deviceToken"] else { return nil } self.id = id - self.endpoint = endpointURL + self.endpoint = endpoint + self.deviceToken = deviceToken + } + + private enum CodingKeys: String, CodingKey { + case id + case endpoint + case deviceToken = "device_token" } } @@ -322,3 +357,49 @@ private extension Data { } } } + +struct PushSubscription { + let accountID: String + let endpoint: URL + let alerts: Alerts + let policy: Policy + + fileprivate var defaultsDict: [String: Any] { + [ + "accountID": accountID, + "endpoint": endpoint.absoluteString, + "alerts": alerts.rawValue, + "policy": policy.rawValue + ] + } + + init?(defaultsDict: [String: Any]) { + guard let accountID = defaultsDict["accountID"] as? String, + let endpoint = (defaultsDict["endpoint"] as? String).flatMap(URL.init(string:)), + let alerts = defaultsDict["alerts"] as? Int, + let policy = (defaultsDict["policy"] as? String).flatMap(Policy.init(rawValue:)) else { + return nil + } + self.accountID = accountID + self.endpoint = endpoint + self.alerts = Alerts(rawValue: alerts) + self.policy = policy + } + + enum Policy: String { + case all, followed, followers + } + + struct Alerts: OptionSet { + static let mention = Alerts(rawValue: 1 << 0) + static let status = Alerts(rawValue: 1 << 1) + static let reblog = Alerts(rawValue: 1 << 2) + static let follow = Alerts(rawValue: 1 << 3) + static let followRequest = Alerts(rawValue: 1 << 4) + static let favorite = Alerts(rawValue: 1 << 5) + static let poll = Alerts(rawValue: 1 << 6) + static let update = Alerts(rawValue: 1 << 7) + + let rawValue: Int + } +} diff --git a/Tusker/Screens/Preferences/NotificationsPrefsView.swift b/Tusker/Screens/Preferences/NotificationsPrefsView.swift index 4e56697e..81e1eeb9 100644 --- a/Tusker/Screens/Preferences/NotificationsPrefsView.swift +++ b/Tusker/Screens/Preferences/NotificationsPrefsView.swift @@ -8,16 +8,20 @@ import SwiftUI import UserNotifications +import UserAccounts struct NotificationsPrefsView: View { @State private var error: NotificationsSetupError? - @State private var isSetup = false - @State private var working = false + @State private var isSetup = TriStateToggle.Mode.off @State private var pushProxyRegistration: PushProxyRegistration? + @ObservedObject private var userAccounts = UserAccountsManager.shared var body: some View { List { enableSection + if isSetup == .on { + accountsSection + } } .listStyle(.insetGrouped) .appGroupedListBackground(container: PreferencesNavigationController.self) @@ -31,19 +35,10 @@ struct NotificationsPrefsView: View { Spacer() - if working { - ProgressView() - } else { - Toggle("Push Notifications Enabled", isOn: Binding(get: { - isSetup - }, set: { newValue in - isSetup = newValue - isSetupChanged(newValue: newValue) - })) - .labelsHidden() - } + TriStateToggle(titleKey: "Push Notifications Enabled", mode: $isSetup, onChange: isSetupChanged(newValue:)) } } + .appGroupedListRowBackground() .alertWithData("An Error Occurred", data: $error) { error in Button("OK") {} } message: { error in @@ -51,26 +46,32 @@ struct NotificationsPrefsView: View { } .task { @MainActor in pushProxyRegistration = PushManager.shared.pushProxyRegistration - isSetup = pushProxyRegistration != nil + isSetup = pushProxyRegistration != nil ? .on : .off if !UIApplication.shared.isRegisteredForRemoteNotifications { _ = await registerForRemoteNotifications() } } } - private func isSetupChanged(newValue: Bool) { - working = true - Task { - defer { - working = false - } - let success = if newValue { - await startRegistration() - } else { - await unregister() + private var accountsSection: some View { + Section { + ForEach(userAccounts.accounts) { account in + PrefsAccountView(account: account) } + } + .appGroupedListRowBackground() + } + + private func isSetupChanged(newValue: Bool) async { + if newValue { + let success = await startRegistration() if !success { - isSetup = !newValue + isSetup = .off + } + } else { + let success = await unregister() + if !success { + isSetup = .on } } } @@ -111,6 +112,52 @@ struct NotificationsPrefsView: View { } } +private struct TriStateToggle: View { + let titleKey: LocalizedStringKey + @Binding var mode: Mode + let onChange: (Bool) async -> Void + @State private var isOn: Bool = false + + var body: some View { + toggleOrSpinner + .onChange(of: mode) { newValue in + switch newValue { + case .off: + isOn = false + case .loading: + break + case .on: + isOn = true + } + } + } + + @ViewBuilder + private var toggleOrSpinner: some View { + if mode == .loading { + ProgressView() + } else { + Toggle(titleKey, isOn: Binding { + isOn + } set: { newValue in + isOn = newValue + mode = .loading + Task { + await onChange(newValue) + mode = newValue ? .on : .off + } + }) + .labelsHidden() + } + } + + enum Mode { + case off + case loading + case on + } +} + private enum NotificationsSetupError: LocalizedError { case requestingAuthorization(any Error) case registering(any Error) diff --git a/Tusker/Screens/Preferences/PreferencesView.swift b/Tusker/Screens/Preferences/PreferencesView.swift index e3ec54f3..3e41e4c9 100644 --- a/Tusker/Screens/Preferences/PreferencesView.swift +++ b/Tusker/Screens/Preferences/PreferencesView.swift @@ -7,7 +7,6 @@ import SwiftUI import UserAccounts -import WebURL struct PreferencesView: View { let mastodonController: MastodonController @@ -34,24 +33,12 @@ struct PreferencesView: View { private var accountsSection: some View { Section { - ForEach(userAccounts.accounts, id: \.accessToken) { (account) in + ForEach(userAccounts.accounts) { (account) in Button(action: { NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": account]) }) { HStack { - LocalAccountAvatarView(localAccountInfo: account) - VStack(alignment: .leading) { - Text(verbatim: account.username) - .foregroundColor(.primary) - let instance = if let domain = WebURL.Domain(account.instanceURL.host!) { - domain.render(.uncheckedUnicodeString) - } else { - account.instanceURL.host! - } - Text(verbatim: instance) - .font(.caption) - .foregroundColor(.primary) - } + PrefsAccountView(account: account) Spacer() if account == mastodonController.accountInfo! { Image(systemName: "checkmark") diff --git a/Tusker/Screens/Preferences/PrefsAccountView.swift b/Tusker/Screens/Preferences/PrefsAccountView.swift new file mode 100644 index 00000000..f9a4b250 --- /dev/null +++ b/Tusker/Screens/Preferences/PrefsAccountView.swift @@ -0,0 +1,37 @@ +// +// PrefsAccountView.swift +// Tusker +// +// Created by Shadowfacts on 4/7/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import SwiftUI +import UserAccounts +import WebURL + +struct PrefsAccountView: View { + let account: UserAccountInfo + + var body: some View { + HStack { + LocalAccountAvatarView(localAccountInfo: account) + VStack(alignment: .leading) { + Text(verbatim: account.username) + .foregroundColor(.primary) + let instance = if let domain = WebURL.Domain(account.instanceURL.host!) { + domain.render(.uncheckedUnicodeString) + } else { + account.instanceURL.host! + } + Text(verbatim: instance) + .font(.caption) + .foregroundColor(.primary) + } + } + } +} + +//#Preview { +// PrefsAccountView() +//}