Watch: what changed on Recent cards and the thread screen, search in All threads, a switching complication with Allow and Deny in the Smart Stack, and question options on notifications
This commit is contained in:
1 parent
f8241e42bb
commit
bfcbb69550
29 files changed
+1100
-137
No files matched your search
@@ -1,12 +1,22 @@
|
||||
// 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.
|
||||
// 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
|
||||
@@ -16,9 +26,16 @@ enum NotificationCommand: Equatable, Sendable {
|
||||
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, `requestId`.
|
||||
/// `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? {
|
||||
@@ -29,6 +46,14 @@ enum NotificationCommand: Equatable, Sendable {
|
||||
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 }
|
||||
@@ -39,13 +64,31 @@ enum NotificationCommand: Equatable, Sendable {
|
||||
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:
|
||||
// The Open action, or a tap on the notification itself.
|
||||
// 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
|
||||
@@ -66,13 +109,15 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
||||
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]),
|
||||
category(.input, [open]),
|
||||
// A question push replaces these with its options; see `inputActions`.
|
||||
category(.input, [answer, open]),
|
||||
category(.finished, [reply, open]),
|
||||
category(.failed, [open]),
|
||||
category(.host, []),
|
||||
@@ -80,6 +125,24 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
||||
]
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -107,12 +170,10 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
||||
model.reloadComplication()
|
||||
await model.refreshFeed()
|
||||
return
|
||||
case let .respond(hostId, threadId, requestId, decision):
|
||||
guard model.mode != .unpaired else { 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: hostId, threadId: threadId,
|
||||
request: .decision(requestId: requestId, decision: decision))
|
||||
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 }
|
||||
@@ -141,3 +202,39 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user