gitbay/Account/NotificationsViewModel.swift
119 lines · 3866 bytes
1import Foundation
2import Observation
3
4/// One row of `notifications list`. `read_at` is omitempty: absent means
5/// unread, which is the common case for the default (unread-only) listing.
6nonisolated struct InboxNotification: Decodable, Sendable, Hashable, Identifiable {
7 let id: Int64
8 let repo: String
9 let kind: String
10 let actor: String
11 let summary: String
12 let path: String
13 let createdAt: Date
14 let readAt: Date?
15
16 enum CodingKeys: String, CodingKey {
17 case id, repo, kind, actor, summary, path
18 case createdAt = "created_at"
19 case readAt = "read_at"
20 }
21
22 var isUnread: Bool { readAt == nil }
23
24 /// Where this notification points, derived from `path` — a
25 /// web-style route, `<owner>/<repo>/<section>/<n>` — not from `kind`
26 /// or the `repo` field. A shape the app cannot route (wrong section,
27 /// missing or non-numeric id, wrong number of components) yields
28 /// nil, which a row renders as untappable rather than crashing.
29 var destination: NotificationDestination? {
30 let parts = path.split(separator: "/", omittingEmptySubsequences: false)
31 guard parts.count == 4, let number = Int64(parts[3]) else { return nil }
32 let repo = "\(parts[0])/\(parts[1])"
33 switch parts[2] {
34 case "issues": return .issue(repo: repo, number: number)
35 case "mrs": return .mr(repo: repo, number: number)
36 case "builds": return .build(repo: repo, number: number)
37 default: return nil
38 }
39 }
40}
41
42/// The screen a notification's `destination` opens. The merge request
43/// section is `mrs` on the wire, never `merge_requests`.
44nonisolated enum NotificationDestination: Hashable, Sendable {
45 case issue(repo: String, number: Int64)
46 case mr(repo: String, number: Int64)
47 case build(repo: String, number: Int64)
48}
49
50/// The notification inbox: `notifications list [--all]`, paginated, plus
51/// `notifications read`.
52@Observable
53@MainActor
54final class NotificationsViewModel {
55
56 let list: PagedListModel<InboxNotification>
57 /// Off shows unread only, the default listing; on adds `--all`. A
58 /// segmented control is quick to tap repeatedly, so a stale reload
59 /// from an earlier tap must not land after a later one.
60 var showAll = false {
61 didSet {
62 guard showAll != oldValue else { return }
63 actionError = nil
64 configureList()
65 reloadTask?.cancel()
66 reloadTask = Task { await list.reload() }
67 }
68 }
69
70 private(set) var actionError: String?
71 private(set) var working = false
72
73 private let client: GitbayClient
74 private var reloadTask: Task<Void, Never>?
75
76 init(client: GitbayClient) {
77 self.client = client
78 list = PagedListModel(
79 client: client,
80 argv: ["notifications", "list"],
81 emptyMessage: "No notifications."
82 )
83 }
84
85 var state: LoadState<[InboxNotification]> { list.state }
86
87 func load() async {
88 reloadTask?.cancel()
89 actionError = nil
90 await list.reload()
91 }
92
93 func markRead(_ id: Int64) async {
94 guard !working else { return }
95 await perform(["notifications", "read", String(id)])
96 }
97
98 func markAllRead() async {
99 await perform(["notifications", "read", "--all"])
100 }
101
102 private func configureList() {
103 list.argv = ["notifications", "list"] + (showAll ? ["--all"] : [])
104 }
105
106 private func perform(_ argv: [String]) async {
107 working = true
108 actionError = nil
109 defer { working = false }
110 do {
111 try await client.run(argv)
112 await load()
113 } catch let error as GitbayError {
114 actionError = error.userFacingMessage
115 } catch {
116 actionError = GitbayError.transport(error).userFacingMessage
117 }
118 }
119}