// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny, // Reply, Settle and a question's answers run in the background against the gateway; tapping a // notification opens its thread. // // An `input` push that carries `question` gets one button per option, plus "Say an answer" when the // question takes its own answer. watchOS only lets a notification's actions change per push from // its hosting controller, so `InputNotificationController` sets them when the push arrives. Any other // `input` push keeps the category's Answer and Open, which open the thread's question screen. import Foundation import SwiftUI import UserNotifications import WatchKit /// 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) /// One answer to a push's single question: an option's value, or what was said or typed. case answer(hostId: String, threadId: String, requestId: String, questionId: String, value: String) case reply(hostId: String, threadId: String, text: String) case settle(hostId: String, threadId: String) case open(Route?) case none enum Action { static let allow = "allow" static let deny = "deny" static let reply = "reply" static let settle = "settle" static let open = "open" /// Opens the thread, on a question the notification cannot answer itself. static let answer = "answer" /// A spoken or typed answer to a push's question. static let say = "say" /// `option.` picks the push question's n-th option. static let optionPrefix = "option." } /// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals and questions, /// `requestId`, and for a lone single-choice question, `question`. 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 } /// The question's answer, or the thread when the answer or the keys it needs are missing. func answer(_ value: String?) -> Self { let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let hostId, let threadId, let requestId, let question = PushQuestion(userInfo: userInfo), !value.isEmpty else { return .open(thread) } return .answer(hostId: hostId, threadId: threadId, requestId: requestId, questionId: question.id, value: value) } 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 Action.settle: guard let hostId, let threadId else { self = .open(thread); return } self = .settle(hostId: hostId, threadId: threadId) case Action.say: self = answer(text) case let id where id.hasPrefix(Action.optionPrefix): let index = Int(id.dropFirst(Action.optionPrefix.count)) let options = PushQuestion(userInfo: userInfo)?.options ?? [] self = answer(index.flatMap { options.indices.contains($0) ? options[$0].value : nil }) case UNNotificationDismissActionIdentifier: self = .none default: // Open, Answer, or a tap on the notification itself. self = .open(thread) } } /// The respond route's thread and body, for Allow, Deny and a question's answer. var response: (hostId: String, threadId: String, request: RespondRequest)? { switch self { case let .respond(hostId, threadId, requestId, decision): (hostId, threadId, .decision(requestId: requestId, decision: decision)) case let .answer(hostId, threadId, requestId, questionId, value): (hostId, threadId, .answers(requestId: requestId, answers: [questionId: .text(value)])) default: nil } } } @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") let answer = UNNotificationAction(identifier: Action.answer, title: "Answer", options: [.foreground]) let settle = UNNotificationAction(identifier: Action.settle, title: "Settle", options: []) func category(_ id: PushCategory, _ actions: [UNNotificationAction]) -> UNNotificationCategory { UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: []) } return [ category(.approval, [allow, deny]), // A question push replaces these with its options; see `inputActions`. category(.input, [answer, open]), category(.finished, [reply, settle, open]), category(.failed, [open]), category(.host, []), category(.test, []), ] } /// An `input` push's actions: its question's options, "Say an answer" when the question takes /// one, and Open. Without a question, Answer and Open, as the category registers them. static func inputActions(_ question: PushQuestion?) -> [UNNotificationAction] { typealias Action = NotificationCommand.Action let open = UNNotificationAction(identifier: Action.open, title: "Open", options: [.foreground]) guard let question else { return [UNNotificationAction(identifier: Action.answer, title: "Answer", options: [.foreground]), open] } let options = question.options.enumerated().map { index, option in UNNotificationAction(identifier: Action.optionPrefix + String(index), title: option.label, options: []) } let say = UNTextInputNotificationAction( identifier: Action.say, title: "Say an answer", options: [], icon: UNNotificationActionIcon(systemImageName: "mic.fill"), textInputButtonTitle: "Send", textInputPlaceholder: "Answer") return options + (question.allowCustomAnswer ? [say] : []) + [open] } 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 .respond, .answer: guard let response = command.response else { return } await attempt("Couldn't answer") { try await model.api.respond(hostId: response.hostId, threadId: response.threadId, request: response.request) } case let .reply(hostId, threadId, text): await attempt("Couldn't send your reply") { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) } case let .settle(hostId, threadId): await attempt("Couldn't settle") { try await model.api.settle(hostId: hostId, threadId: threadId) } } model.reloadComplication() await model.refreshFeed() } /// Runs a background action. If it fails, says so with a local notification, since nothing is /// on screen. Only a paired watch acts: after unpairing, or in demo mode, the action would go /// nowhere or to the demo gateway, so the notification says the watch is not paired instead. private func attempt(_ title: String, _ action: () async throws -> Void) async { guard AppModel.shared.isPaired else { await notify(title, "This watch isn't paired. Open WristCode to pair it.") return } AppModel.shared.beginChange() do { try await action() } catch let error as GatewayError where error.isStale { return } catch { guard let message = AppModel.shared.message(for: error) else { return } await notify(title, message) } } private func notify(_ title: String, _ body: String) async { let content = UNMutableNotificationContent() content.title = title content.body = body let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) try? await UNUserNotificationCenter.current().add(request) } } /// The long look for `input` pushes. It shows the push's title and question, and sets the push's /// own actions, which is the one place watchOS takes per-push action titles. final class InputNotificationController: WKUserNotificationHostingController { private var threadTitle = "" private var questionText = "" override var body: InputNotificationView { InputNotificationView(title: threadTitle, question: questionText) } override func didReceive(_ notification: UNNotification) { let content = notification.request.content threadTitle = content.title questionText = content.body notificationActions = NotificationHandler.inputActions(PushQuestion(userInfo: content.userInfo)) } } /// The thread's title and its question, above the actions the system draws. struct InputNotificationView: View { var title: String var question: String var body: some View { VStack(alignment: .leading, spacing: 3) { Text(title) .font(.system(size: 15, weight: .semibold)) Text(question) .font(.system(size: 13)) .foregroundStyle(Theme.secondaryText) } .frame(maxWidth: .infinity, alignment: .leading) .fontDesign(.rounded) } }