gitbay/Account/NotificationsViewModel.swift

v1.13.1
gitbay-ios/gitbay/Account/NotificationsViewModel.swift history · blame · raw

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}