From f98589b419959f6e11592a207d82699c11aa13b0 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 7 Apr 2024 23:14:12 -0400 Subject: [PATCH] Start account-specific push subscriptions --- .../Pachyderm/Model/PushSubscription.swift | 75 ++++++++++++++++- Tusker.xcodeproj/project.pbxproj | 22 ++++- Tusker/Push/PushManager.swift | 15 +++- .../NotificationsPrefsView.swift | 48 +---------- .../PushInstanceSettingsView.swift | 82 +++++++++++++++++++ .../Notifications/PushSubscriptionView.swift | 33 ++++++++ .../Notifications/TriStateToggle.swift | 63 ++++++++++++++ .../Preferences/PrefsAccountView.swift | 15 +++- 8 files changed, 298 insertions(+), 55 deletions(-) rename Tusker/Screens/Preferences/{ => Notifications}/NotificationsPrefsView.swift (76%) create mode 100644 Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift create mode 100644 Tusker/Screens/Preferences/Notifications/PushSubscriptionView.swift create mode 100644 Tusker/Screens/Preferences/Notifications/TriStateToggle.swift diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/PushSubscription.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/PushSubscription.swift index 1892372247..e97a134951 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/PushSubscription.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/PushSubscription.swift @@ -12,13 +12,82 @@ public struct PushSubscription: Decodable, Sendable { public let id: String public let endpoint: URL public let serverKey: String - // TODO: WTF is this? -// public let alerts + public let alerts: Alerts + public let policy: Policy + + public static func create(endpoint: URL, publicKey: Data, authSecret: Data, alerts: Alerts, policy: Policy) -> Request { + return Request(method: .post, path: "/api/v1/push/subscription", body: ParametersBody([ + "subscription[endpoint]" => endpoint.absoluteString, + "subscription[keys][p256dh]" => publicKey.base64EncodedString(), + "subscription[keys][auth]" => authSecret.base64EncodedString(), + "data[alerts][mention]" => alerts.mention, + "data[alerts][status]" => alerts.status, + "data[alerts][reblog]" => alerts.reblog, + "data[alerts][follow]" => alerts.follow, + "data[alerts][follow_request]" => alerts.followRequest, + "data[alerts][favourite]" => alerts.favourite, + "data[alerts][poll]" => alerts.poll, + "data[alerts][update]" => alerts.update, + "data[policy]" => policy.rawValue, + ])) + } + + public static func update(alerts: Alerts, policy: Policy) -> Request { + return Request(method: .put, path: "/api/v1/push/subscription", body: ParametersBody([ + "data[alerts][mention]" => alerts.mention, + "data[alerts][status]" => alerts.status, + "data[alerts][reblog]" => alerts.reblog, + "data[alerts][follow]" => alerts.follow, + "data[alerts][follow_request]" => alerts.followRequest, + "data[alerts][favourite]" => alerts.favourite, + "data[alerts][poll]" => alerts.poll, + "data[alerts][update]" => alerts.update, + "data[policy]" => policy.rawValue, + ])) + } + + public static func delete() -> Request { + return Request(method: .delete, path: "/api/v1/push/subscription") + } private enum CodingKeys: String, CodingKey { case id case endpoint case serverKey = "server_key" -// case alerts + case alerts + case policy + } +} + +extension PushSubscription { + public struct Alerts: Decodable, Sendable { + public let mention: Bool + public let status: Bool + public let reblog: Bool + public let follow: Bool + public let followRequest: Bool + public let favourite: Bool + public let poll: Bool + public let update: Bool + + private enum CodingKeys: String, CodingKey { + case mention + case status + case reblog + case follow + case followRequest = "follow_request" + case favourite + case poll + case update + } + } +} + +extension PushSubscription { + public enum Policy: String, Decodable, Sendable { + case all + case followed + case followers + case none } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index a1889c7161..ad9973fbfb 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ 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 */; }; + D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.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 */; }; @@ -132,6 +133,8 @@ D6552367289870790048A653 /* ScreenCorners in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, ); productRef = D6552366289870790048A653 /* ScreenCorners */; }; D659F35E2953A212002D944A /* TTTKit in Frameworks */ = {isa = PBXBuildFile; productRef = D659F35D2953A212002D944A /* TTTKit */; }; D659F36229541065002D944A /* TTTView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D659F36129541065002D944A /* TTTView.swift */; }; + D65A261B2BC3928A005EB5D8 /* TriStateToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65A261A2BC3928A005EB5D8 /* TriStateToggle.swift */; }; + D65A261D2BC39399005EB5D8 /* PushInstanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */; }; D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B532971F71D00DABDFB /* EditedReport.swift */; }; D65B4B562971F98300DABDFB /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B552971F98300DABDFB /* ReportView.swift */; }; D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */; }; @@ -527,12 +530,15 @@ 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 = ""; }; + D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.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 = ""; }; D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = ""; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = ""; }; D659F36129541065002D944A /* TTTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTTView.swift; sourceTree = ""; }; + D65A261A2BC3928A005EB5D8 /* TriStateToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriStateToggle.swift; sourceTree = ""; }; + D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushInstanceSettingsView.swift; sourceTree = ""; }; D65B4B532971F71D00DABDFB /* EditedReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditedReport.swift; sourceTree = ""; }; D65B4B552971F98300DABDFB /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = ""; }; D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSelectRulesView.swift; sourceTree = ""; }; @@ -1126,7 +1132,7 @@ 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */, D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */, D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */, - D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */, + D64B96822BC3892B002C8990 /* Notifications */, D60089172981FEA4005B4D00 /* Tip Jar */, D68A76EF2953910A001DA1B3 /* About */, ); @@ -1197,6 +1203,17 @@ path = Push; sourceTree = ""; }; + D64B96822BC3892B002C8990 /* Notifications */ = { + isa = PBXGroup; + children = ( + D64B967B2BC19C28002C8990 /* NotificationsPrefsView.swift */, + D65A261C2BC39399005EB5D8 /* PushInstanceSettingsView.swift */, + D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */, + D65A261A2BC3928A005EB5D8 /* TriStateToggle.swift */, + ); + path = Notifications; + sourceTree = ""; + }; D65A37F221472F300087646E /* Frameworks */ = { isa = PBXGroup; children = ( @@ -2082,6 +2099,7 @@ D68329EF299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift in Sources */, D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */, D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */, + D65A261D2BC39399005EB5D8 /* PushInstanceSettingsView.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */, D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */, @@ -2141,6 +2159,7 @@ D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, + D65A261B2BC3928A005EB5D8 /* TriStateToggle.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, D64B967F2BC1D447002C8990 /* PushManager.swift in Sources */, D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */, @@ -2239,6 +2258,7 @@ D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */, D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */, D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */, + D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */, D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */, D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */, D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */, diff --git a/Tusker/Push/PushManager.swift b/Tusker/Push/PushManager.swift index b551a05af4..ac6267fa0b 100644 --- a/Tusker/Push/PushManager.swift +++ b/Tusker/Push/PushManager.swift @@ -12,6 +12,7 @@ import OSLog import Sentry #endif import Pachyderm +import UserAccounts private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager") @@ -40,10 +41,12 @@ struct PushManager { } @MainActor -protocol _PushManager: ObservableObject { +protocol _PushManager { var enabled: Bool { get } var pushProxyRegistration: PushProxyRegistration? { get } + func pushSubscription(account: UserAccountInfo) -> PushSubscription? + func register(transactionID: UInt64) async throws -> PushProxyRegistration func unregister() async throws func updateIfNecessary() async @@ -61,6 +64,10 @@ private class DisabledPushManager: _PushManager { nil } + func pushSubscription(account: UserAccountInfo) -> PushSubscription? { + nil + } + func register(transactionID: UInt64) async throws -> PushProxyRegistration { throw Disabled() } @@ -112,7 +119,6 @@ private class PushManagerImpl: _PushManager { } } set { - objectWillChange.send() defaults.setValue(newValue?.defaultsDict, forKey: "PushProxyRegistration") } } @@ -125,7 +131,6 @@ private class PushManagerImpl: _PushManager { } } set { - objectWillChange.send() defaults.setValue(newValue.map(\.defaultsDict), forKey: "PushSubscriptions") } } @@ -134,6 +139,10 @@ private class PushManagerImpl: _PushManager { self.endpoint = endpoint } + func pushSubscription(account: UserAccountInfo) -> PushSubscription? { + pushSubscriptions.first { $0.accountID == account.id } + } + func register(transactionID: UInt64) async throws -> PushProxyRegistration { guard remoteNotificationsRegistrationContinuation == nil else { throw PushRegistrationError.alreadyRegistering diff --git a/Tusker/Screens/Preferences/NotificationsPrefsView.swift b/Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift similarity index 76% rename from Tusker/Screens/Preferences/NotificationsPrefsView.swift rename to Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift index 81e1eeb9ae..94676603bf 100644 --- a/Tusker/Screens/Preferences/NotificationsPrefsView.swift +++ b/Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift @@ -56,7 +56,7 @@ struct NotificationsPrefsView: View { private var accountsSection: some View { Section { ForEach(userAccounts.accounts) { account in - PrefsAccountView(account: account) + PushInstanceSettingsView(account: account) } } .appGroupedListRowBackground() @@ -112,52 +112,6 @@ 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/Notifications/PushInstanceSettingsView.swift b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift new file mode 100644 index 0000000000..9206f102c6 --- /dev/null +++ b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift @@ -0,0 +1,82 @@ +// +// PushInstanceSettingsView.swift +// Tusker +// +// Created by Shadowfacts on 4/7/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import SwiftUI +import UserAccounts +import Pachyderm + +struct PushInstanceSettingsView: View { + let account: UserAccountInfo + let pushProxyRegistration: PushProxyRegistration + @State private var mode: TriStateToggle.Mode + @State private var error: Error? + + var body: some View { + VStack(alignment: .prefsAvatar) { + HStack { + PrefsAccountView(account: account) + TriStateToggle(titleKey: "\(account.instanceURL.host!) notifications enabled", mode: $mode, onChange: updateNotificationsEnabled(enabled:)) + } + PushSubscriptionView(account: account) + } + .alertWithData("An Error Occurred", data: $error) { data in + Button("OK") {} + } message: { data in + Text(data.localizedDescription) + } + + } + + private func updateNotificationsEnabled(enabled: Bool) async { + if enabled { + do { + try await enableNotifications() + } catch { + self.error = .enabling(error) + } + } else { + do { + try await disableNotifications() + } catch { + self.error = .disabling(error) + } + } + } + + private func enableNotifications() async throws { + let req = Pachyderm.PushSubscription.create( + endpoint: pushProxyRegistration.endpoint, + publicKey: <#T##Data#>, + authSecret: <#T##Data#>, + alerts: <#T##PushSubscription.Alerts#>, + policy: <#T##PushSubscription.Policy#> + ) + } + + private func disableNotifications() async throws { + + } +} + +private enum Error: LocalizedError { + case enabling(any Swift.Error) + case disabling(any Swift.Error) + + var errorDescription: String? { + switch self { + case .enabling(let error): + "Enabling push: \(error.localizedDescription)" + case .disabling(let error): + "Disabling push: \(error.localizedDescription)" + } + } +} + +//#Preview { +// PushInstanceSettingsView() +//} diff --git a/Tusker/Screens/Preferences/Notifications/PushSubscriptionView.swift b/Tusker/Screens/Preferences/Notifications/PushSubscriptionView.swift new file mode 100644 index 0000000000..eac3dbbf21 --- /dev/null +++ b/Tusker/Screens/Preferences/Notifications/PushSubscriptionView.swift @@ -0,0 +1,33 @@ +// +// PushSubscriptionView.swift +// Tusker +// +// Created by Shadowfacts on 4/7/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import SwiftUI +import UserAccounts + +struct PushSubscriptionView: View { + let account: UserAccountInfo + @State private var subscription: PushSubscription? + + @MainActor + init(account: UserAccountInfo) { + self.account = account + self.subscription = PushManager.shared.pushSubscription(account: account) + } + + var body: some View { + if let subscription { + + } else { + Text("No notifications") + } + } +} + +//#Preview { +// PushSubscriptionView() +//} diff --git a/Tusker/Screens/Preferences/Notifications/TriStateToggle.swift b/Tusker/Screens/Preferences/Notifications/TriStateToggle.swift new file mode 100644 index 0000000000..70d57a1788 --- /dev/null +++ b/Tusker/Screens/Preferences/Notifications/TriStateToggle.swift @@ -0,0 +1,63 @@ +// +// TriStateToggle.swift +// Tusker +// +// Created by Shadowfacts on 4/7/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import SwiftUI + +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 + } +} + + +#Preview { + @State var mode = TriStateToggle.Mode.on + return TriStateToggle(titleKey: "", mode: $mode) { _ in + try! await Task.sleep(nanoseconds: NSEC_PER_SEC) + } +} diff --git a/Tusker/Screens/Preferences/PrefsAccountView.swift b/Tusker/Screens/Preferences/PrefsAccountView.swift index f9a4b250c3..b4ed2617ce 100644 --- a/Tusker/Screens/Preferences/PrefsAccountView.swift +++ b/Tusker/Screens/Preferences/PrefsAccountView.swift @@ -16,7 +16,7 @@ struct PrefsAccountView: View { var body: some View { HStack { LocalAccountAvatarView(localAccountInfo: account) - VStack(alignment: .leading) { + VStack(alignment: .prefsAvatar) { Text(verbatim: account.username) .foregroundColor(.primary) let instance = if let domain = WebURL.Domain(account.instanceURL.host!) { @@ -28,10 +28,23 @@ struct PrefsAccountView: View { .font(.caption) .foregroundColor(.primary) } + .alignmentGuide(.prefsAvatar, computeValue: { dimension in + dimension[.leading] + }) } } } +private struct AvatarAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + 0 + } +} + +extension HorizontalAlignment { + static let prefsAvatar = HorizontalAlignment(AvatarAlignment.self) +} + //#Preview { // PrefsAccountView() //}