gitbay/Account/NotificationsViewModel.swift
192 lines · 6955 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? { notificationDestination(forPath: path) }
30}
31
32/// Where a notification's `path` points — a web-style route,
33/// `<owner>/<repo>/<section>/<n>`. A shape the app cannot route (wrong
34/// section, missing or non-numeric id, wrong number of components)
35/// yields nil. The merge request section is `mrs` on the wire, never
36/// `merge_requests`.
37nonisolated func notificationDestination(forPath path: String) -> NotificationDestination? {
38 let parts = path.split(separator: "/", omittingEmptySubsequences: false)
39 guard parts.count == 4, let number = Int64(parts[3]) else { return nil }
40 let repo = "\(parts[0])/\(parts[1])"
41 switch parts[2] {
42 case "issues": return .issue(repo: repo, number: number)
43 case "mrs": return .mr(repo: repo, number: number)
44 case "builds": return .build(repo: repo, number: number)
45 default: return nil
46 }
47}
48
49/// The screen a notification's `destination` opens. The merge request
50/// section is `mrs` on the wire, never `merge_requests`.
51nonisolated enum NotificationDestination: Hashable, Sendable {
52 case issue(repo: String, number: Int64)
53 case mr(repo: String, number: Int64)
54 case build(repo: String, number: Int64)
55}
56
57/// The notification inbox: `notifications list [--all]`, paginated, plus
58/// `notifications read`.
59@Observable
60@MainActor
61final class NotificationsViewModel {
62
63 /// One row of `notifications device list`. `token` is already
64 /// truncated by the server — never the whole value.
65 nonisolated struct PushDevice: Decodable, Sendable, Identifiable {
66 let id: Int64
67 let label: String
68 let token: String
69 let added: String
70 }
71
72 /// `notifications settings show` reports mail, watch and push.
73 nonisolated struct PushSettings: Decodable, Sendable {
74 let mail: Bool
75 let watch: Bool
76 let push: Bool
77 }
78
79 let list: PagedListModel<InboxNotification>
80 /// Off shows unread only, the default listing; on adds `--all`. A
81 /// segmented control is quick to tap repeatedly, so a stale reload
82 /// from an earlier tap must not land after a later one.
83 var showAll = false {
84 didSet {
85 guard showAll != oldValue else { return }
86 actionError = nil
87 configureList()
88 reloadTask?.cancel()
89 reloadTask = Task { await list.reload() }
90 }
91 }
92
93 private(set) var actionError: String?
94 private(set) var working = false
95 /// The server-side push preference; both this and iOS authorization
96 /// must be on for push to arrive.
97 private(set) var pushEnabled = false
98 private(set) var devices: [PushDevice] = []
99
100 private let client: GitbayClient
101 private var reloadTask: Task<Void, Never>?
102
103 init(client: GitbayClient) {
104 self.client = client
105 list = PagedListModel(
106 client: client,
107 argv: ["notifications", "list"],
108 emptyMessage: "No notifications."
109 )
110 }
111
112 var state: LoadState<[InboxNotification]> { list.state }
113
114 func load() async {
115 reloadTask?.cancel()
116 actionError = nil
117 await list.reload()
118 }
119
120 func markRead(_ id: Int64) async {
121 guard !working else { return }
122 await perform(["notifications", "read", String(id)])
123 }
124
125 func markAllRead() async {
126 await perform(["notifications", "read", "--all"])
127 // Nothing else tells iOS the count dropped: a push sets the badge
128 // on arrival and no one lowers it, so it would sit at whatever the
129 // last one said. Zero is exact here. Marking a single row read has
130 // no reliable count to work from — the inbox list is paged, so the
131 // rows in hand are not the unread total — and is left to the
132 // dashboard's resync.
133 await setAppBadge(0)
134 }
135
136 func loadPushSettings() async {
137 guard let settings = try? await client.read(
138 ["notifications", "settings", "show"], as: PushSettings.self) else { return }
139 pushEnabled = settings.push
140 devices = (try? await client.readList(
141 ["notifications", "device", "list"], of: PushDevice.self)) ?? []
142 }
143
144 /// Unlike `markRead`/`markAllRead`, this does not reload the inbox —
145 /// only its own state, so the list on screen does not blank out
146 /// behind a spinner for an unrelated fetch.
147 func setPush(_ on: Bool) async {
148 guard !working else { return }
149 await performWrite(["notifications", "settings", "push", on ? "on" : "off"])
150 await loadPushSettings()
151 }
152
153 /// See `setPush` — no inbox reload here either.
154 func removeDevice(_ id: Int64) async {
155 guard !working else { return }
156 await performWrite(["notifications", "device", "remove", String(id)])
157 await loadPushSettings()
158 }
159
160 private func configureList() {
161 list.argv = ["notifications", "list"] + (showAll ? ["--all"] : [])
162 }
163
164 /// `markRead`/`markAllRead`'s write: the shared error handling, plus
165 /// the inbox reload that makes sense for them but not for the push
166 /// settings below.
167 private func perform(_ argv: [String]) async {
168 if await performWrite(argv) {
169 await load()
170 }
171 }
172
173 /// The write and its error handling alone, shared by every caller in
174 /// this file. Returns whether it succeeded, so a caller can decide
175 /// what to reload.
176 @discardableResult
177 private func performWrite(_ argv: [String]) async -> Bool {
178 working = true
179 actionError = nil
180 defer { working = false }
181 do {
182 try await client.run(argv)
183 return true
184 } catch let error as GitbayError {
185 actionError = error.userFacingMessage
186 return false
187 } catch {
188 actionError = GitbayError.transport(error).userFacingMessage
189 return false
190 }
191 }
192}