import Foundation import Observation /// One row of `notifications list`. `read_at` is omitempty: absent means /// unread, which is the common case for the default (unread-only) listing. nonisolated struct InboxNotification: Decodable, Sendable, Hashable, Identifiable { let id: Int64 let repo: String let kind: String let actor: String let summary: String let path: String let createdAt: Date let readAt: Date? enum CodingKeys: String, CodingKey { case id, repo, kind, actor, summary, path case createdAt = "created_at" case readAt = "read_at" } var isUnread: Bool { readAt == nil } /// Where this notification points, derived from `path` — a /// web-style route, `//
/` — not from `kind` /// or the `repo` field. A shape the app cannot route (wrong section, /// missing or non-numeric id, wrong number of components) yields /// nil, which a row renders as untappable rather than crashing. var destination: NotificationDestination? { notificationDestination(forPath: path) } } /// Where a notification's `path` points — a web-style route, /// `//
/`. A shape the app cannot route (wrong /// section, missing or non-numeric id, wrong number of components) /// yields nil. The merge request section is `mrs` on the wire, never /// `merge_requests`. nonisolated func notificationDestination(forPath path: String) -> NotificationDestination? { let parts = path.split(separator: "/", omittingEmptySubsequences: false) guard parts.count == 4, let number = Int64(parts[3]) else { return nil } let repo = "\(parts[0])/\(parts[1])" switch parts[2] { case "issues": return .issue(repo: repo, number: number) case "mrs": return .mr(repo: repo, number: number) case "builds": return .build(repo: repo, number: number) default: return nil } } /// The screen a notification's `destination` opens. The merge request /// section is `mrs` on the wire, never `merge_requests`. nonisolated enum NotificationDestination: Hashable, Sendable { case issue(repo: String, number: Int64) case mr(repo: String, number: Int64) case build(repo: String, number: Int64) } /// The notification inbox: `notifications list [--all]`, paginated, plus /// `notifications read`. @Observable @MainActor final class NotificationsViewModel { /// One row of `notifications device list`. `token` is already /// truncated by the server — never the whole value. nonisolated struct PushDevice: Decodable, Sendable, Identifiable { let id: Int64 let label: String let token: String let added: String } /// `notifications settings show` reports mail, watch and push. nonisolated struct PushSettings: Decodable, Sendable { let mail: Bool let watch: Bool let push: Bool } let list: PagedListModel /// Off shows unread only, the default listing; on adds `--all`. A /// segmented control is quick to tap repeatedly, so a stale reload /// from an earlier tap must not land after a later one. var showAll = false { didSet { guard showAll != oldValue else { return } actionError = nil configureList() reloadTask?.cancel() reloadTask = Task { await list.reload() } } } private(set) var actionError: String? private(set) var working = false /// The server-side push preference; both this and iOS authorization /// must be on for push to arrive. private(set) var pushEnabled = false private(set) var devices: [PushDevice] = [] private let client: GitbayClient private var reloadTask: Task? init(client: GitbayClient) { self.client = client list = PagedListModel( client: client, argv: ["notifications", "list"], emptyMessage: "No notifications." ) } var state: LoadState<[InboxNotification]> { list.state } func load() async { reloadTask?.cancel() actionError = nil await list.reload() } func markRead(_ id: Int64) async { guard !working else { return } await perform(["notifications", "read", String(id)]) } func markAllRead() async { await perform(["notifications", "read", "--all"]) // Nothing else tells iOS the count dropped: a push sets the badge // on arrival and no one lowers it, so it would sit at whatever the // last one said. Zero is exact here. Marking a single row read has // no reliable count to work from — the inbox list is paged, so the // rows in hand are not the unread total — and is left to the // dashboard's resync. await setAppBadge(0) } func loadPushSettings() async { guard let settings = try? await client.read( ["notifications", "settings", "show"], as: PushSettings.self) else { return } pushEnabled = settings.push devices = (try? await client.readList( ["notifications", "device", "list"], of: PushDevice.self)) ?? [] } /// Unlike `markRead`/`markAllRead`, this does not reload the inbox — /// only its own state, so the list on screen does not blank out /// behind a spinner for an unrelated fetch. func setPush(_ on: Bool) async { guard !working else { return } await performWrite(["notifications", "settings", "push", on ? "on" : "off"]) await loadPushSettings() } /// See `setPush` — no inbox reload here either. func removeDevice(_ id: Int64) async { guard !working else { return } await performWrite(["notifications", "device", "remove", String(id)]) await loadPushSettings() } private func configureList() { list.argv = ["notifications", "list"] + (showAll ? ["--all"] : []) } /// `markRead`/`markAllRead`'s write: the shared error handling, plus /// the inbox reload that makes sense for them but not for the push /// settings below. private func perform(_ argv: [String]) async { if await performWrite(argv) { await load() } } /// The write and its error handling alone, shared by every caller in /// this file. Returns whether it succeeded, so a caller can decide /// what to reload. @discardableResult private func performWrite(_ argv: [String]) async -> Bool { working = true actionError = nil defer { working = false } do { try await client.run(argv) return true } catch let error as GitbayError { actionError = error.userFacingMessage return false } catch { actionError = GitbayError.transport(error).userFacingMessage return false } } }