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? { 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 { 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 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"]) } private func configureList() { list.argv = ["notifications", "list"] + (showAll ? ["--all"] : []) } private func perform(_ argv: [String]) async { working = true actionError = nil defer { working = false } do { try await client.run(argv) await load() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } }