damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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 }