diff --git a/damus/Models/PushNotificationClient.swift b/damus/Models/PushNotificationClient.swift index 13e75758..70775783 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 f3cbf43a..279b92d7 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 f2711156..4ce30ef4 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) } }