PushNotificationClient.swift (11658B)
1 // 2 // PushNotificationClient.swift 3 // damus 4 // 5 // Created by Daniel D’Aquino on 2024-05-17. 6 // 7 8 import Foundation 9 10 struct PushNotificationClient { 11 let keypair: Keypair 12 let settings: UserSettingsStore 13 private(set) var device_token: Data? = nil 14 var device_token_hex: String? { 15 guard let device_token else { return nil } 16 return device_token.map { String(format: "%02.2hhx", $0) }.joined() 17 } 18 19 mutating func set_device_token(new_device_token: Data) async throws { 20 self.device_token = new_device_token 21 if settings.enable_push_notifications && settings.notification_mode == .push { 22 try await self.send_token() 23 } 24 } 25 26 func send_token() async throws { 27 // Send the device token and pubkey to the server 28 guard let token = device_token_hex else { return } 29 30 Log.info("Sending device token to server: %s", for: .push_notifications, token) 31 32 // create post request 33 let url = self.current_push_notification_environment().api_base_url() 34 .appendingPathComponent("user-info") 35 .appendingPathComponent(self.keypair.pubkey.hex()) 36 .appendingPathComponent(token) 37 38 let (data, response) = try await make_nip98_authenticated_request( 39 method: .put, 40 url: url, 41 payload: nil, 42 payload_type: .json, 43 auth_keypair: self.keypair 44 ) 45 46 if let httpResponse = response as? HTTPURLResponse { 47 switch httpResponse.statusCode { 48 case 200: 49 Log.info("Sent device token to Damus push notification server successfully", for: .push_notifications) 50 default: 51 Log.error("Error in sending device_token to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") 52 throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data) 53 } 54 } 55 56 return 57 } 58 59 func revoke_token() async throws { 60 guard let token = device_token_hex else { return } 61 62 Log.info("Revoking device token from server: %s", for: .push_notifications, token) 63 64 let pubkey = self.keypair.pubkey 65 66 // create post request 67 let url = self.current_push_notification_environment().api_base_url() 68 .appendingPathComponent("user-info") 69 .appendingPathComponent(pubkey.hex()) 70 .appendingPathComponent(token) 71 72 73 let (data, response) = try await make_nip98_authenticated_request( 74 method: .delete, 75 url: url, 76 payload: nil, 77 payload_type: .json, 78 auth_keypair: self.keypair 79 ) 80 81 if let httpResponse = response as? HTTPURLResponse { 82 switch httpResponse.statusCode { 83 case 200: 84 Log.info("Sent device token removal request to Damus push notification server successfully", for: .push_notifications) 85 default: 86 Log.error("Error in sending device_token removal to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") 87 throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data) 88 } 89 } 90 91 return 92 } 93 94 func set_settings(_ new_settings: NotificationSettings? = nil) async throws { 95 // Send the device token and pubkey to the server 96 guard let token = device_token_hex else { return } 97 98 Log.info("Sending notification preferences to the server", for: .push_notifications) 99 100 let url = self.current_push_notification_environment().api_base_url() 101 .appendingPathComponent("user-info") 102 .appendingPathComponent(self.keypair.pubkey.hex()) 103 .appendingPathComponent(token) 104 .appendingPathComponent("preferences") 105 106 let json_payload = try JSONEncoder().encode(new_settings ?? NotificationSettings.from(settings: settings)) 107 108 let (data, response) = try await make_nip98_authenticated_request( 109 method: .put, 110 url: url, 111 payload: json_payload, 112 payload_type: .json, 113 auth_keypair: self.keypair 114 ) 115 116 if let httpResponse = response as? HTTPURLResponse { 117 switch httpResponse.statusCode { 118 case 200: 119 Log.info("Sent notification settings to Damus push notification server successfully", for: .push_notifications) 120 default: 121 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") 122 throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data) 123 } 124 } 125 126 return 127 } 128 129 func get_settings() async throws -> NotificationSettings { 130 // Send the device token and pubkey to the server 131 guard let token = device_token_hex else { 132 throw ClientError.no_device_token 133 } 134 135 let url = self.current_push_notification_environment().api_base_url() 136 .appendingPathComponent("user-info") 137 .appendingPathComponent(self.keypair.pubkey.hex()) 138 .appendingPathComponent(token) 139 .appendingPathComponent("preferences") 140 141 let (data, response) = try await make_nip98_authenticated_request( 142 method: .get, 143 url: url, 144 payload: nil, 145 payload_type: .json, 146 auth_keypair: self.keypair 147 ) 148 149 if let httpResponse = response as? HTTPURLResponse { 150 switch httpResponse.statusCode { 151 case 200: 152 guard let notification_settings = NotificationSettings.from(json_data: data) else { throw ClientError.json_decoding_error } 153 return notification_settings 154 default: 155 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") 156 throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data) 157 } 158 } 159 throw ClientError.could_not_process_response 160 } 161 162 func current_push_notification_environment() -> Environment { 163 return self.settings.push_notification_environment 164 } 165 } 166 167 // MARK: Helper structures 168 169 extension PushNotificationClient { 170 enum ClientError: Error { 171 case http_response_error(status_code: Int, response: Data) 172 case could_not_process_response 173 case no_device_token 174 case json_decoding_error 175 } 176 177 struct NotificationSettings: Codable, Equatable { 178 let zap_notifications_enabled: Bool 179 let mention_notifications_enabled: Bool 180 let repost_notifications_enabled: Bool 181 let reaction_notifications_enabled: Bool 182 let dm_notifications_enabled: Bool 183 let only_notifications_from_following_enabled: Bool 184 185 static func from(json_data: Data) -> Self? { 186 guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil } 187 return decoded 188 } 189 190 static func from(settings: UserSettingsStore) -> Self { 191 return NotificationSettings( 192 zap_notifications_enabled: settings.zap_notification, 193 mention_notifications_enabled: settings.mention_notification, 194 repost_notifications_enabled: settings.repost_notification, 195 reaction_notifications_enabled: settings.like_notification, 196 dm_notifications_enabled: settings.dm_notification, 197 only_notifications_from_following_enabled: settings.notification_only_from_following 198 ) 199 } 200 201 } 202 203 enum Environment: CaseIterable, Codable, Identifiable, StringCodable, Equatable, Hashable { 204 static var allCases: [Environment] = [.local_test(host: nil), .staging, .production] 205 206 case local_test(host: String?) 207 case staging 208 case production 209 210 func text_description() -> String { 211 switch self { 212 case .local_test: 213 return NSLocalizedString("Test (local)", comment: "Label indicating a local test environment for Push notification functionality (Developer feature)") 214 case .production: 215 return NSLocalizedString("Production", comment: "Label indicating the production environment for Push notification functionality") 216 case .staging: 217 return NSLocalizedString("Staging (for dev builds)", comment: "Label indicating the staging environment for Push notification functionality") 218 } 219 } 220 221 func api_base_url() -> URL { 222 switch self { 223 case .local_test(let host): 224 URL(string: "http://\(host ?? "localhost:8000")") ?? Constants.PUSH_NOTIFICATION_SERVER_TEST_BASE_URL 225 case .production: 226 Constants.PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL 227 case .staging: 228 Constants.PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL 229 } 230 } 231 232 func custom_host() -> String? { 233 switch self { 234 case .local_test(let host): 235 return host 236 default: 237 return nil 238 } 239 } 240 241 init?(from string: String) { 242 switch string { 243 case "local_test": 244 self = .local_test(host: nil) 245 case "production": 246 self = .production 247 case "staging": 248 self = .staging 249 default: 250 let components = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) 251 if components.count == 2 && components[0] == "local_test" { 252 self = .local_test(host: String(components[1])) 253 } else { 254 return nil 255 } 256 } 257 } 258 259 func to_string() -> String { 260 switch self { 261 case .local_test(let host): 262 if let host { 263 return "local_test:\(host)" 264 } 265 return "local_test" 266 case .staging: 267 return "staging" 268 case .production: 269 return "production" 270 } 271 } 272 273 var id: String { 274 switch self { 275 case .local_test(let host): 276 if let host { 277 return "local_test:\(host)" 278 } 279 else { 280 return "local_test" 281 } 282 case .production: 283 return "production" 284 case .staging: 285 return "staging" 286 } 287 } 288 } 289 }