Files
byre/mac/Sources/Byre/Notifications.swift
T

180 lines
6.6 KiB
Swift

import AppKit
import UserNotifications
@MainActor
final class Notifier: NSObject, UNUserNotificationCenterDelegate {
static let shared = Notifier()
private var pending: (origin: String, taskId: String)?
func install() {
UNUserNotificationCenter.current().delegate = self
}
private func open(_ origin: String, _ taskId: String) {
NSApp.activate()
Servers.shared.show(origin)
pending = (origin, taskId)
deliver()
}
func deliver() {
guard let p = pending, let store = Servers.shared.connections[p.origin]?.store else {
return
}
pending = nil
if !p.taskId.isEmpty {
store.focusTask(p.taskId)
}
}
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
[.banner, .list, .sound]
}
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
let info = response.notification.request.content.userInfo
let origin = info["origin"] as? String ?? ""
let taskId = info["task_id"] as? String ?? ""
await MainActor.run {
open(origin, taskId)
}
}
}
@MainActor
private var queue: Task<Void, Never>?
@MainActor
private func enqueue(_ op: @escaping @MainActor () async -> Void) {
let prev = queue
queue = Task {
await prev?.value
await op()
}
}
@MainActor
private func preview(_ text: String) -> String {
var lines: [String] = []
for raw in text.split(separator: "\n", omittingEmptySubsequences: false) {
var line = trimmed(String(raw))
if line.isEmpty || line.hasPrefix("#") || line.hasPrefix("<") || line.hasPrefix("```") {
if lines.isEmpty {
continue
}
break
}
for marker in ["- ", "* ", "> "] where line.hasPrefix(marker) {
line = trimmed(String(line.dropFirst(marker.count)))
}
lines.append((try? AttributedString(markdown: line)).map { String($0.characters) } ?? line)
}
let body = lines.joined(separator: " ")
return body.count > 200 ? "\(body.prefix(199))…" : body
}
@MainActor
private func show(_ store: Store, _ taskId: String, _ tag: String, _ kind: String, _ text: String) {
let content = UNMutableNotificationContent()
content.title = store.findTask(taskId).map { "byre · \($0.name)" } ?? "byre"
content.subtitle = kind
content.body = preview(text)
content.sound = .default
content.userInfo = ["task_id": taskId, "origin": store.api.origin.absoluteString]
let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil)
enqueue {
let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
if status != .authorized && status != .provisional {
return
}
do {
try await UNUserNotificationCenter.current().add(request)
} catch {
log.error("show notification: \(errorText(error), privacy: .public)")
}
}
}
@MainActor
func notifyInbox(_ store: Store, _ item: InboxItem) {
guard let me = store.me, me.notificationPrefs.questions, item.state == .open else {
return
}
show(store, item.taskId, item.id, "Question", str(item.payload.value, "question") ?? "")
}
@MainActor
func notifyError(_ store: Store, _ ev: ByreEvent) {
guard let me = store.me, let taskId = ev.taskId, !taskId.isEmpty else {
return
}
let limited = ev.kind == "session.rate_limited"
if !(limited ? me.notificationPrefs.rateLimits : me.notificationPrefs.errors) {
return
}
show(store, taskId, "error-\(ev.seq)@\(store.api.origin.absoluteString)", limited ? "Rate limited" : "Error", limited ? "" : str(ev.payload.value, "error") ?? "")
}
@MainActor
func clearInbox(_ item: InboxItem) {
if item.state == .open {
return
}
enqueue {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [item.id])
}
}
@MainActor
func clearTurns(_ taskId: String, through seq: Int64) {
enqueue {
let ids = await UNUserNotificationCenter.current().deliveredNotifications().map(\.request).filter { r in
guard r.content.userInfo["task_id"] as? String == taskId, r.identifier.hasPrefix("turn-"), let s = Int64(r.identifier.dropFirst(5).prefix { $0 != "@" }) else {
return false
}
return s <= seq
}.map(\.identifier)
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
}
}
@MainActor
func notifyTask(_ store: Store, _ ev: ByreEvent) {
guard let me = store.me, let taskId = ev.taskId, !taskId.isEmpty else {
return
}
let prefs = me.notificationPrefs
let origin = store.api.origin.absoluteString
let background = store.findTask(taskId)?.background == true
switch ev.kind {
case "task.ready" where prefs.ready:
show(store, taskId, "task-\(taskId)@\(origin)", "Ready to merge", str(ev.payload.value, "overview") ?? "")
case "task.discard_suggested" where prefs.discard && !background:
show(store, taskId, "task-\(taskId)@\(origin)", "Suggests discard", str(ev.payload.value, "reason") ?? "")
case "task.merge_stopped" where prefs.mergeProblems:
show(store, taskId, "task-\(taskId)@\(origin)", "Merge stopped", str(ev.payload.value, "reason") ?? "")
case "task.merge_after_review_dropped" where prefs.mergeProblems:
show(store, taskId, "task-\(taskId)@\(origin)", "Didn't merge after the review", str(ev.payload.value, "reason") ?? "")
case "task.workflow_finished" where prefs.workflows && !background:
show(store, taskId, "workflow-\(ev.seq)@\(origin)", "Finished \(str(ev.payload.value, "workflow") ?? "")", str(ev.payload.value, "output") ?? "")
default:
break
}
}
@MainActor
func clearTask(_ store: Store, _ taskId: String) {
let id = "task-\(taskId)@\(store.api.origin.absoluteString)"
enqueue {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
}
}
@MainActor
func notifyTurn(_ store: Store, _ ev: ByreEvent) {
guard let me = store.me, me.notificationPrefs.reports, ev.payload.value["summary"] as? Bool == true, let taskId = ev.taskId, !taskId.isEmpty, store.findTask(taskId)?.background != true else {
return
}
show(store, taskId, "turn-\(ev.seq)@\(store.api.origin.absoluteString)", "Turn finished", str(ev.payload.value, "result") ?? "")
}