|
| 1 | +// LocalNotificationScheduler |
| 2 | +// |
| 3 | +// Schedules a macOS `UNUserNotification` banner for a single |
| 4 | +// `InterlinedDomain.Notification` value. The scheduler embeds a typed |
| 5 | +// `userInfo` dict — keyed by `NotificationUserInfoKeys` — so that |
| 6 | +// `AppDelegate` can reconstruct a `NotificationTarget` when the user |
| 7 | +// taps the delivered banner (the deep-link routing half of the |
| 8 | +// notification feature, per PLAN.md §6 M5.x). |
| 9 | +// |
| 10 | +// Responsibilities |
| 11 | +// ---------------- |
| 12 | +// * Build a `UNMutableNotificationContent` with title, sound, and userInfo. |
| 13 | +// * Schedule an immediate `UNNotificationRequest` via the shared |
| 14 | +// `UNUserNotificationCenter`. |
| 15 | +// * NOT request UN permission — that belongs to |
| 16 | +// `NotificationsPermissionCoordinator`. |
| 17 | +// * NOT de-duplicate previously-shown notifications — callers own that |
| 18 | +// set. The composition root can track shown IDs in `UserDefaults`. |
| 19 | +// |
| 20 | +// Per Decision 0003 this file imports only `InterlinedDomain`; it does |
| 21 | +// not import `InterlinedKit`. The `NotificationUserInfoKeys` constants |
| 22 | +// are in `App/Composition/` and are visible to all files in the |
| 23 | +// `InterlinedList` module without an extra import. |
| 24 | + |
| 25 | +import Foundation |
| 26 | +import UserNotifications |
| 27 | +import InterlinedDomain |
| 28 | + |
| 29 | +// MARK: - Protocol |
| 30 | + |
| 31 | +/// Narrow scheduling surface for App-layer code that needs to surface |
| 32 | +/// an `InterlinedDomain.Notification` as a macOS system banner. A |
| 33 | +/// protocol so unit tests can inject a recording stub without touching |
| 34 | +/// `UNUserNotificationCenter`. |
| 35 | +protocol LocalNotificationScheduling: Sendable { |
| 36 | + /// Schedules a local notification banner for `notification`. |
| 37 | + /// Idempotent by request identifier (`notification.id`) — scheduling |
| 38 | + /// the same id twice replaces the earlier pending request. |
| 39 | + func schedule(_ notification: InterlinedDomain.Notification) async |
| 40 | +} |
| 41 | + |
| 42 | +// MARK: - Live implementation |
| 43 | + |
| 44 | +/// Production scheduler. Delegates to `UNUserNotificationCenter`; the |
| 45 | +/// center is injected so test overrides work without the real system. |
| 46 | +final class LocalNotificationScheduler: LocalNotificationScheduling, @unchecked Sendable { |
| 47 | + |
| 48 | + private let center: UNUserNotificationCenter |
| 49 | + |
| 50 | + init(center: UNUserNotificationCenter = .current()) { |
| 51 | + self.center = center |
| 52 | + } |
| 53 | + |
| 54 | + func schedule(_ notification: InterlinedDomain.Notification) async { |
| 55 | + let content = UNMutableNotificationContent() |
| 56 | + content.title = derivedTitle(for: notification) |
| 57 | + if let body = notification.body, !body.isEmpty { |
| 58 | + content.body = body |
| 59 | + } |
| 60 | + content.sound = .default |
| 61 | + content.userInfo = Self.userInfo(for: notification) |
| 62 | + |
| 63 | + let request = UNNotificationRequest( |
| 64 | + identifier: notification.id, |
| 65 | + content: content, |
| 66 | + trigger: nil // nil trigger = deliver immediately |
| 67 | + ) |
| 68 | + // Swallow scheduling errors: if the user has denied permission the |
| 69 | + // banner silently drops; the in-app tray still shows the row. |
| 70 | + try? await center.add(request) |
| 71 | + } |
| 72 | + |
| 73 | + // MARK: - userInfo builder |
| 74 | + |
| 75 | + /// Assembles the `userInfo` dict that `AppDelegate` reads back from |
| 76 | + /// `UNNotificationResponse.notification.request.content.userInfo` |
| 77 | + /// when the user taps the delivered banner. |
| 78 | + /// |
| 79 | + /// All keys use the `NotificationUserInfoKeys` constants so the |
| 80 | + /// writing and reading sides of the contract share a single source of |
| 81 | + /// truth and are immune to typos. |
| 82 | + static func userInfo( |
| 83 | + for notification: InterlinedDomain.Notification |
| 84 | + ) -> [String: String] { |
| 85 | + var dict: [String: String] = [:] |
| 86 | + dict[NotificationUserInfoKeys.notificationId] = notification.id |
| 87 | + dict[NotificationUserInfoKeys.type] = notification.kind.rawValue |
| 88 | + |
| 89 | + if let username = notification.actor?.username, !username.isEmpty { |
| 90 | + dict[NotificationUserInfoKeys.actorUsername] = username |
| 91 | + } |
| 92 | + |
| 93 | + switch notification.target { |
| 94 | + case .message(let id): |
| 95 | + dict[NotificationUserInfoKeys.targetMessageId] = id |
| 96 | + case .list(let id): |
| 97 | + dict[NotificationUserInfoKeys.targetListId] = id |
| 98 | + case .user(let id): |
| 99 | + dict[NotificationUserInfoKeys.targetUserId] = id |
| 100 | + case .organization(let id): |
| 101 | + dict[NotificationUserInfoKeys.targetOrgId] = id |
| 102 | + case .unknown(let url): |
| 103 | + if let urlString = url?.absoluteString { |
| 104 | + dict[NotificationUserInfoKeys.actionUrl] = urlString |
| 105 | + } |
| 106 | + case .none: |
| 107 | + break |
| 108 | + } |
| 109 | + |
| 110 | + return dict |
| 111 | + } |
| 112 | + |
| 113 | + // MARK: - Title derivation |
| 114 | + |
| 115 | + /// Falls back to `NotificationRowCopy.copy` when the server-supplied |
| 116 | + /// title is absent so every banner has human-readable text. |
| 117 | + private func derivedTitle(for note: InterlinedDomain.Notification) -> String { |
| 118 | + NotificationRowCopy.copy( |
| 119 | + for: note.kind, |
| 120 | + actor: note.actor, |
| 121 | + title: note.title, |
| 122 | + body: nil |
| 123 | + ) |
| 124 | + } |
| 125 | +} |
0 commit comments