252 lines
12 KiB
Swift
252 lines
12 KiB
Swift
// 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.<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.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<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])
|
|
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 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)
|
|
}
|
|
case let .settle(hostId, threadId):
|
|
guard model.mode != .unpaired else { return }
|
|
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.
|
|
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)
|
|
}
|
|
}
|