Files
wristcode/watch/WristCode/Notifications/NotificationHandler.swift
T

241 lines
11 KiB
Swift

// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny,
// Reply 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 open(Route?)
case none
enum Action {
static let allow = "allow"
static let deny = "deny"
static let reply = "reply"
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.<n>` 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.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<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")
let answer = UNNotificationAction(identifier: Action.answer, title: "Answer", options: [.foreground])
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, 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 model.mode != .unpaired, 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):
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)
}
}
}
/// 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<InputNotificationView> {
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)
}
}