Files
wristcode/watch/WristCode/Notifications/NotificationHandler.swift
T
gibandClaude Opus 5.5 7ad1045be4 Watch: treat an empty threadId in a push as no thread
The gateway sends threadId "" on host notifications; tapping one now opens
the feed instead of a thread that does not exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:35:58 -05:00

141 lines
5.9 KiB
Swift

// 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<UNNotificationCategory> {
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, []),
]
}
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 = [] }
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)
}
}
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)
}
}
}