// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny // and Reply run in the background against the gateway; tapping a notification opens its thread. import Foundation import UserNotifications /// What a notification response asks for, parsed out of the payload. enum NotificationCommand: Equatable, Sendable { case respond(hostId: String, threadId: String, requestId: String, decision: ApprovalDecision) case reply(hostId: String, threadId: String, text: String) case open(Route?) case none enum Action { static let allow = "allow" static let deny = "deny" static let reply = "reply" static let open = "open" } /// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals, `requestId`. init(actionIdentifier: String, userInfo: [AnyHashable: Any], text: String?) { // The gateway sends `threadId: ""` on host notifications, so empty means absent. func value(_ key: String) -> String? { (userInfo[key] as? String).flatMap { $0.isEmpty ? nil : $0 } } let hostId = value("hostId") let threadId = value("threadId") let requestId = value("requestId") let thread: Route? = if let hostId, let threadId { .thread(hostId: hostId, threadId: threadId) } else { nil } switch actionIdentifier { case Action.allow, Action.deny: guard let hostId, let threadId, let requestId else { self = .open(thread); return } self = .respond( hostId: hostId, threadId: threadId, requestId: requestId, decision: actionIdentifier == Action.allow ? .accept : .decline) case Action.reply: let text = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let hostId, let threadId, !text.isEmpty else { self = .open(thread); return } self = .reply(hostId: hostId, threadId: threadId, text: text) case UNNotificationDismissActionIdentifier: self = .none default: // The Open action, or a tap on the notification itself. self = .open(thread) } } } @MainActor final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate { static let shared = NotificationHandler() func install() { let center = UNUserNotificationCenter.current() center.delegate = self center.setNotificationCategories(Self.categories) } static var categories: Set { typealias Action = NotificationCommand.Action let allow = UNNotificationAction(identifier: Action.allow, title: "Allow", options: []) let deny = UNNotificationAction(identifier: Action.deny, title: "Deny", options: [.destructive]) let open = UNNotificationAction(identifier: Action.open, title: "Open", options: [.foreground]) let reply = UNTextInputNotificationAction( identifier: Action.reply, title: "Reply", options: [], textInputButtonTitle: "Send", textInputPlaceholder: "Reply") func category(_ id: PushCategory, _ actions: [UNNotificationAction]) -> UNNotificationCategory { UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: []) } return [ category(.approval, [allow, deny]), category(.input, [open]), category(.finished, [reply, open]), category(.failed, [open]), category(.host, []), category(.test, []), ] } nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { await AppModel.shared.notificationArrived() return [.banner, .list, .sound] } nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse ) async { let command = NotificationCommand( actionIdentifier: response.actionIdentifier, userInfo: response.notification.request.content.userInfo, text: (response as? UNTextInputNotificationResponse)?.userText) await run(command) } private func run(_ command: NotificationCommand) async { let model = AppModel.shared switch command { case .none: return case let .open(route): if let route { model.open(route) } else { model.path = [] } model.reloadComplication() await model.refreshFeed() return case let .respond(hostId, threadId, requestId, decision): guard model.mode != .unpaired else { return } await attempt("Couldn't answer") { try await model.api.respond( hostId: hostId, threadId: threadId, request: .decision(requestId: requestId, decision: decision)) } case let .reply(hostId, threadId, text): guard model.mode != .unpaired else { return } await attempt("Couldn't send your reply") { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) } } model.reloadComplication() await model.refreshFeed() } /// Runs a background action. If it fails, says so with a local notification, since nothing is /// on screen. private func attempt(_ title: String, _ action: () async throws -> Void) async { do { try await action() } catch let error as GatewayError where error.isStale { return } catch { guard let message = AppModel.shared.message(for: error) else { return } let content = UNMutableNotificationContent() content.title = title content.body = message let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) try? await UNUserNotificationCenter.current().add(request) } } }