import SwiftUI import UserNotifications /// Where a notification that was tapped leads, read from what the server put in /// it: as the inbox's row for it does, its thread, else its repository, else who /// did it. One that names nothing opens the inbox. struct PushTarget: Equatable, Sendable { var repo: RepoRef? var subject: String? var author: String? init(_ info: [AnyHashable: Any]) { if let repo = info["repo"] as? [String: String], let did = repo["did"], let handle = repo["handle"], let name = repo["name"] { self.repo = RepoRef(did: did, handle: handle, name: name) } subject = info["subject"] as? String author = info["author"] as? String } var route: Route? { if let repo, let subject, kind(ofURI: subject) != nil { return .thread(repo, uri: subject) } if let repo { return .repo(repo) } if let author { return .profile(author) } return nil } } /// This device's standing with the push service, and a notification just tapped. @MainActor @Observable final class Pushes { static let shared = Pushes() var opened: PushTarget? private var token: String? /// Debug builds are signed for Apple's sandbox, and those from an archive for /// production, each with tokens only that service knows. private static var environment: String { #if DEBUG "sandbox" #else "production" #endif } /// Asks once to send notifications, then asks Apple for this device's token, /// which it gives at each launch and may change. func start() async { guard SessionStore.token != nil else { return } let center = UNUserNotificationCenter.current() guard (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) == true else { return } UIApplication.shared.registerForRemoteNotifications() } /// Apple gave a token: the server tells this device of the account's /// notifications from now on. func registered(_ token: String) async { self.token = token try? await Client.shared.send(Writes.registerPush(token: token, environment: Self.environment)) } /// Signing out: this device no longer hears of the account. func stop() async { guard let token else { return } self.token = nil try? await Client.shared.send(Writes.unregisterPush(token: token)) clearBadge() } /// Forgets this device's token, for an account the server has already forgotten. func forget() { token = nil clearBadge() } func clearBadge() { UNUserNotificationCenter.current().setBadgeCount(0) } } final class PushDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { UNUserNotificationCenter.current().delegate = self return true } func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { String(format: "%02x", $0) }.joined() Task { await Pushes.shared.registered(token) } } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { print("no push token: \(error.localizedDescription)") } /// Shown while the app is open too. nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { completionHandler([.banner, .list, .badge, .sound]) } nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let target = PushTarget(response.notification.request.content.userInfo) Task { @MainActor in Pushes.shared.opened = target } completionHandler() } }