gitbay/Account/NotificationsViewModel.swift

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

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}