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]>
141 lines
5.9 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|