From 740c10c9b27a21204cc0097ddc6a954a5fea365d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Sat, 3 Aug 2024 20:03:14 -0700 Subject: [PATCH] Implement push notification preferences and update API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements push notification preferences with the push notifications server, as well as updates itself to the new push notifications API. Testing ------- Device: iPhone 15 simulator iOS: 17.5 Damus: this commit notepush: 3ca3a8325707535fdbc98d681d5e4a47dc313c67 Steps: 1. Enable push notifications. Settings should get synced and success message should appear 2. Disable push notifications. Sync message should disappear as it no longer applies 3. Enable push notifications again, and tweak notifications. Settings should sync with no errors 4. Leave settings screen and come back. Settings should be declared as synced Signed-off-by: Daniel D’Aquino Closes: https://github.com/damus-io/damus/issues/2360 --- damus/Models/PushNotificationClient.swift | 218 ++++++++++++++++-- damus/Util/Constants.swift | 8 +- .../Settings/NotificationSettingsView.swift | 121 +++++++++- 3 files changed, 311 insertions(+), 36 deletions(-) diff --git a/damus/Models/PushNotificationClient.swift b/damus/Models/PushNotificationClient.swift index 13e75758a..707757836 100644 --- a/damus/Models/PushNotificationClient.swift +++ b/damus/Models/PushNotificationClient.swift @@ -11,6 +11,10 @@ struct PushNotificationClient { let keypair: Keypair let settings: UserSettingsStore private(set) var device_token: Data? = nil + var device_token_hex: String? { + guard let device_token else { return nil } + return device_token.map { String(format: "%02.2hhx", $0) }.joined() + } mutating func set_device_token(new_device_token: Data) async throws { self.device_token = new_device_token @@ -20,26 +24,21 @@ struct PushNotificationClient { } func send_token() async throws { - guard let device_token else { return } // Send the device token and pubkey to the server - let token = device_token.map { String(format: "%02.2hhx", $0) }.joined() + guard let token = device_token_hex else { return } Log.info("Sending device token to server: %s", for: .push_notifications, token) - let pubkey = self.keypair.pubkey - - // Send those as JSON to the server - let json: [String: Any] = ["deviceToken": token, "pubkey": pubkey.hex()] - // create post request - let url = self.settings.send_device_token_to_localhost ? Constants.DEVICE_TOKEN_RECEIVER_TEST_URL : Constants.DEVICE_TOKEN_RECEIVER_PRODUCTION_URL - let json_data = try JSONSerialization.data(withJSONObject: json) - + let url = self.current_push_notification_environment().api_base_url() + .appendingPathComponent("user-info") + .appendingPathComponent(self.keypair.pubkey.hex()) + .appendingPathComponent(token) let (data, response) = try await make_nip98_authenticated_request( - method: .post, + method: .put, url: url, - payload: json_data, + payload: nil, payload_type: .json, auth_keypair: self.keypair ) @@ -58,26 +57,23 @@ struct PushNotificationClient { } func revoke_token() async throws { - guard let device_token else { return } - // Send the device token and pubkey to the server - let token = device_token.map { String(format: "%02.2hhx", $0) }.joined() + guard let token = device_token_hex else { return } Log.info("Revoking device token from server: %s", for: .push_notifications, token) let pubkey = self.keypair.pubkey - // Send those as JSON to the server - let json: [String: Any] = ["deviceToken": token, "pubkey": pubkey.hex()] - // create post request - let url = self.settings.send_device_token_to_localhost ? Constants.DEVICE_TOKEN_REVOKER_TEST_URL : Constants.DEVICE_TOKEN_REVOKER_PRODUCTION_URL - let json_data = try JSONSerialization.data(withJSONObject: json) + let url = self.current_push_notification_environment().api_base_url() + .appendingPathComponent("user-info") + .appendingPathComponent(pubkey.hex()) + .appendingPathComponent(token) let (data, response) = try await make_nip98_authenticated_request( - method: .post, + method: .delete, url: url, - payload: json_data, + payload: nil, payload_type: .json, auth_keypair: self.keypair ) @@ -94,6 +90,78 @@ struct PushNotificationClient { return } + + func set_settings(_ new_settings: NotificationSettings? = nil) async throws { + // Send the device token and pubkey to the server + guard let token = device_token_hex else { return } + + Log.info("Sending notification preferences to the server", for: .push_notifications) + + let url = self.current_push_notification_environment().api_base_url() + .appendingPathComponent("user-info") + .appendingPathComponent(self.keypair.pubkey.hex()) + .appendingPathComponent(token) + .appendingPathComponent("preferences") + + let json_payload = try JSONEncoder().encode(new_settings ?? NotificationSettings.from(settings: settings)) + + let (data, response) = try await make_nip98_authenticated_request( + method: .put, + url: url, + payload: json_payload, + payload_type: .json, + auth_keypair: self.keypair + ) + + if let httpResponse = response as? HTTPURLResponse { + switch httpResponse.statusCode { + case 200: + Log.info("Sent notification settings to Damus push notification server successfully", for: .push_notifications) + default: + Log.error("Error in sending notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") + throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data) + } + } + + return + } + + func get_settings() async throws -> NotificationSettings { + // Send the device token and pubkey to the server + guard let token = device_token_hex else { + throw ClientError.no_device_token + } + + let url = self.current_push_notification_environment().api_base_url() + .appendingPathComponent("user-info") + .appendingPathComponent(self.keypair.pubkey.hex()) + .appendingPathComponent(token) + .appendingPathComponent("preferences") + + let (data, response) = try await make_nip98_authenticated_request( + method: .get, + url: url, + payload: nil, + payload_type: .json, + auth_keypair: self.keypair + ) + + if let httpResponse = response as? HTTPURLResponse { + switch httpResponse.statusCode { + case 200: + guard let notification_settings = NotificationSettings.from(json_data: data) else { throw ClientError.json_decoding_error } + return notification_settings + default: + Log.error("Error in getting notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") + throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data) + } + } + throw ClientError.could_not_process_response + } + + func current_push_notification_environment() -> Environment { + return self.settings.send_device_token_to_localhost ? .local_test(host: nil) : .production + } } // MARK: Helper structures @@ -101,5 +169,111 @@ struct PushNotificationClient { extension PushNotificationClient { enum ClientError: Error { case http_response_error(status_code: Int, response: Data) + case could_not_process_response + case no_device_token + case json_decoding_error + } + + struct NotificationSettings: Codable, Equatable { + let zap_notifications_enabled: Bool + let mention_notifications_enabled: Bool + let repost_notifications_enabled: Bool + let reaction_notifications_enabled: Bool + let dm_notifications_enabled: Bool + let only_notifications_from_following_enabled: Bool + + static func from(json_data: Data) -> Self? { + guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil } + return decoded + } + + static func from(settings: UserSettingsStore) -> Self { + return NotificationSettings( + zap_notifications_enabled: settings.zap_notification, + mention_notifications_enabled: settings.mention_notification, + repost_notifications_enabled: settings.repost_notification, + reaction_notifications_enabled: settings.like_notification, + dm_notifications_enabled: settings.dm_notification, + only_notifications_from_following_enabled: settings.notification_only_from_following + ) + } + + } + + enum Environment: CaseIterable, Codable, Identifiable, StringCodable, Equatable, Hashable { + static var allCases: [Environment] = [.local_test(host: nil), .production] + + case local_test(host: String?) + case production + + func text_description() -> String { + switch self { + case .local_test: + return NSLocalizedString("Test (local)", comment: "Label indicating a local test environment for Push notification functionality (Developer feature)") + case .production: + return NSLocalizedString("Production", comment: "Label indicating the production environment for Push notification functionality") + } + } + + func api_base_url() -> URL { + switch self { + case .local_test(let host): + URL(string: "http://\(host ?? "localhost:8000")") ?? Constants.PUSH_NOTIFICATION_SERVER_TEST_BASE_URL + case .production: + Constants.PURPLE_API_PRODUCTION_BASE_URL + + } + } + + func custom_host() -> String? { + switch self { + case .local_test(let host): + return host + default: + return nil + } + } + + init?(from string: String) { + switch string { + case "local_test": + self = .local_test(host: nil) + case "production": + self = .production + default: + let components = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + if components.count == 2 && components[0] == "local_test" { + self = .local_test(host: String(components[1])) + } else { + return nil + } + } + } + + func to_string() -> String { + switch self { + case .local_test(let host): + if let host { + return "local_test:\(host)" + } + return "local_test" + case .production: + return "production" + } + } + + var id: String { + switch self { + case .local_test(let host): + if let host { + return "local_test:\(host)" + } + else { + return "local_test" + } + case .production: + return "production" + } + } } } diff --git a/damus/Util/Constants.swift b/damus/Util/Constants.swift index f3cbf43a3..279b92d77 100644 --- a/damus/Util/Constants.swift +++ b/damus/Util/Constants.swift @@ -10,13 +10,13 @@ import Foundation class Constants { //static let EXAMPLE_DEMOS: DamusState = .empty static let DAMUS_APP_GROUP_IDENTIFIER: String = "group.com.damus" - static let DEVICE_TOKEN_RECEIVER_PRODUCTION_URL: URL = URL(string: "http://45.33.32.5:8000/user-info")! - static let DEVICE_TOKEN_RECEIVER_TEST_URL: URL = URL(string: "http://localhost:8000/user-info")! - static let DEVICE_TOKEN_REVOKER_PRODUCTION_URL: URL = URL(string: "http://45.33.32.5:8000/user-info/remove")! - static let DEVICE_TOKEN_REVOKER_TEST_URL: URL = URL(string: "http://localhost:8000/user-info/remove")! static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2" static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService" + // MARK: Push notification server + static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "http://45.33.32.5:8000")! + static let PUSH_NOTIFICATION_SERVER_TEST_BASE_URL: URL = URL(string: "http://localhost:8000")! + // MARK: Purple // API static let PURPLE_API_LOCAL_TEST_BASE_URL: URL = URL(string: "http://localhost:8989")! diff --git a/damus/Views/Settings/NotificationSettingsView.swift b/damus/Views/Settings/NotificationSettingsView.swift index f27111568..4ce30ef48 100644 --- a/damus/Views/Settings/NotificationSettingsView.swift +++ b/damus/Views/Settings/NotificationSettingsView.swift @@ -7,10 +7,13 @@ import SwiftUI +let MINIMUM_PUSH_NOTIFICATION_SYNC_DELAY_IN_SECONDS = 0.25 + struct NotificationSettingsView: View { let damus_state: DamusState @ObservedObject var settings: UserSettingsStore @State var notification_mode_setting_error: String? = nil + @State var notification_preferences_sync_state: PreferencesSyncState = .undefined @Environment(\.dismiss) var dismiss @@ -32,6 +35,7 @@ struct NotificationSettingsView: View { Task { do { try await damus_state.push_notification_client.send_token() + await self.sync_up_remote_notification_settings() settings.notifications_mode = new_value } catch { @@ -44,6 +48,7 @@ struct NotificationSettingsView: View { do { try await damus_state.push_notification_client.revoke_token() settings.notifications_mode = new_value + notification_preferences_sync_state = .not_applicable } catch { notification_mode_setting_error = String(format: NSLocalizedString("Error disabling push notifications with the server: %@", comment: "Error label shown when user tries to disable push notifications but something fails"), error.localizedDescription) @@ -52,6 +57,61 @@ struct NotificationSettingsView: View { } } + // MARK: - Push notification preference sync management + + func notification_preference_binding(_ raw_binding: Binding) -> Binding { + return Binding( + get: { + return raw_binding.wrappedValue + }, + set: { new_value in + let old_value = raw_binding.wrappedValue + raw_binding.wrappedValue = new_value + if self.settings.notifications_mode == .push { + Task { + await self.send_push_notification_preferences(on_failure: { + raw_binding.wrappedValue = old_value + }) + } + } + } + ) + } + + func sync_up_remote_notification_settings() async { + do { + notification_preferences_sync_state = .syncing + let remote_settings = try await damus_state.push_notification_client.get_settings() + let local_settings = PushNotificationClient.NotificationSettings.from(settings: settings) + if remote_settings != local_settings { + await self.send_push_notification_preferences(local_settings) + } + else { + notification_preferences_sync_state = .success + } + } + catch { + notification_preferences_sync_state = .failure(error: String(format: NSLocalizedString("Failed to get push notification preferences from the server", comment: "Error label indicating about a failure in fetching notification preferences"), error.localizedDescription)) + } + } + + func send_push_notification_preferences(_ new_settings: PushNotificationClient.NotificationSettings? = nil, on_failure: (() -> Void)? = nil) async { + do { + notification_preferences_sync_state = .syncing + try await damus_state.push_notification_client.set_settings(new_settings) + // Make sync appear to take at least a few milliseconds or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias) + DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_PUSH_NOTIFICATION_SYNC_DELAY_IN_SECONDS) { + notification_preferences_sync_state = .success + } + } + catch { + notification_preferences_sync_state = .failure(error: String(format: NSLocalizedString("Error syncing up push notifications preferences with the server: %@", comment: "Error label shown when system tries to sync up notification preferences to the push notification server but something fails"), error.localizedDescription)) + on_failure?() + } + } + + // MARK: - View layout + var body: some View { Form { if settings.enable_experimental_push_notifications { @@ -80,21 +140,40 @@ struct NotificationSettingsView: View { } } - Section(header: Text("Local Notifications", comment: "Section header for damus local notifications user configuration")) { - Toggle(NSLocalizedString("Zaps", comment: "Setting to enable Zap Local Notification"), isOn: $settings.zap_notification) + Section( + header: Text("Notification Preferences", comment: "Section header for Notification Preferences"), + footer: VStack { + switch notification_preferences_sync_state { + case .undefined, .not_applicable: + EmptyView() + case .success: + HStack { + Image("check-circle.fill") + .foregroundStyle(.damusGreen) + Text("Successfully synced", comment: "Label indicating success in syncing notification preferences") + } + case .syncing: + HStack(spacing: 10) { + ProgressView() + Text("Syncing", comment: "Label indicating success in syncing notification preferences") + } + case .failure(let error): + Text(error) + .foregroundStyle(.damusDangerPrimary) + } + } + ) { + Toggle(NSLocalizedString("Zaps", comment: "Setting to enable Zap Local Notification"), isOn: self.notification_preference_binding($settings.zap_notification)) .toggleStyle(.switch) - Toggle(NSLocalizedString("Mentions", comment: "Setting to enable Mention Local Notification"), isOn: $settings.mention_notification) + Toggle(NSLocalizedString("Mentions", comment: "Setting to enable Mention Local Notification"), isOn: self.notification_preference_binding($settings.mention_notification)) .toggleStyle(.switch) - Toggle(NSLocalizedString("Reposts", comment: "Setting to enable Repost Local Notification"), isOn: $settings.repost_notification) + Toggle(NSLocalizedString("Reposts", comment: "Setting to enable Repost Local Notification"), isOn: self.notification_preference_binding($settings.repost_notification)) .toggleStyle(.switch) - Toggle(NSLocalizedString("Likes", comment: "Setting to enable Like Local Notification"), isOn: $settings.like_notification) + Toggle(NSLocalizedString("Likes", comment: "Setting to enable Like Local Notification"), isOn: self.notification_preference_binding($settings.like_notification)) .toggleStyle(.switch) - Toggle(NSLocalizedString("DMs", comment: "Setting to enable DM Local Notification"), isOn: $settings.dm_notification) + Toggle(NSLocalizedString("DMs", comment: "Setting to enable DM Local Notification"), isOn: self.notification_preference_binding($settings.dm_notification)) .toggleStyle(.switch) - } - - Section(header: Text("Notification Preference", comment: "Section header for Notification Preferences")) { - Toggle(NSLocalizedString("Show only from users you follow", comment: "Setting to Show notifications only associated to users your follow"), isOn: $settings.notification_only_from_following) + Toggle(NSLocalizedString("Show only from users you follow", comment: "Setting to Show notifications only associated to users your follow"), isOn: self.notification_preference_binding($settings.notification_only_from_following)) .toggleStyle(.switch) } @@ -113,6 +192,28 @@ struct NotificationSettingsView: View { .onReceive(handle_notify(.switched_timeline)) { _ in dismiss() } + .onAppear(perform: { + Task { + if self.settings.notifications_mode == .push { + await self.sync_up_remote_notification_settings() + } + } + }) + } +} + +extension NotificationSettingsView { + enum PreferencesSyncState { + /// State is unknown + case undefined + /// State is not applicable (e.g. Notifications are set to local) + case not_applicable + /// Preferences are successfully synced + case success + /// Preferences are being synced + case syncing + /// There was a failure during syncing + case failure(error: String) } }