PushNotificationClient.swift (11202B)
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_experimental_push_notifications && settings.notifications_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.send_device_token_to_localhost ? .local_test(host: nil) : .production 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), .production] 205 206 case local_test(host: String?) 207 case production 208 209 func text_description() -> String { 210 switch self { 211 case .local_test: 212 return NSLocalizedString("Test (local)", comment: "Label indicating a local test environment for Push notification functionality (Developer feature)") 213 case .production: 214 return NSLocalizedString("Production", comment: "Label indicating the production environment for Push notification functionality") 215 } 216 } 217 218 func api_base_url() -> URL { 219 switch self { 220 case .local_test(let host): 221 URL(string: "http://\(host ?? "localhost:8000")") ?? Constants.PUSH_NOTIFICATION_SERVER_TEST_BASE_URL 222 case .production: 223 Constants.PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL 224 225 } 226 } 227 228 func custom_host() -> String? { 229 switch self { 230 case .local_test(let host): 231 return host 232 default: 233 return nil 234 } 235 } 236 237 init?(from string: String) { 238 switch string { 239 case "local_test": 240 self = .local_test(host: nil) 241 case "production": 242 self = .production 243 default: 244 let components = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) 245 if components.count == 2 && components[0] == "local_test" { 246 self = .local_test(host: String(components[1])) 247 } else { 248 return nil 249 } 250 } 251 } 252 253 func to_string() -> String { 254 switch self { 255 case .local_test(let host): 256 if let host { 257 return "local_test:\(host)" 258 } 259 return "local_test" 260 case .production: 261 return "production" 262 } 263 } 264 265 var id: String { 266 switch self { 267 case .local_test(let host): 268 if let host { 269 return "local_test:\(host)" 270 } 271 else { 272 return "local_test" 273 } 274 case .production: 275 return "production" 276 } 277 } 278 } 279 }