gitbay/Discovery/ProfileViewModel.swift
115 lines · 4090 bytes
1import Foundation
2import Observation
3
4/// `profile show <name>` — the whole profile in one read: description,
5/// links, org membership (or an org's members), the repositories the
6/// account may see, and a year of activity.
7@Observable
8@MainActor
9final class ProfileViewModel {
10
11 nonisolated struct Profile: Decodable, Sendable, Hashable {
12 let name: String
13 let kind: String // user | org
14 let description: String?
15 let website: String?
16 /// Long-form profile text, rendered between the header and the
17 /// activity graph the way the web renders it.
18 let about: String?
19 let aboutFormat: String?
20 let links: [Link]?
21 let orgs: [Member]?
22 let members: [Member]?
23 let repos: [Repo]
24 let activity: [Day]?
25 let activityTotal: Int
26
27 enum CodingKeys: String, CodingKey {
28 case name, kind, description, website, about, links, orgs, members, repos, activity
29 case aboutFormat = "about_format"
30 case activityTotal = "activity_total"
31 }
32
33 var isOrg: Bool { kind == "org" }
34
35 /// The filename the about text is rendered under. The server has
36 /// none to dispatch on either, so the stored format picks the
37 /// extension and anything but org is markdown.
38 var aboutFile: String { aboutFormat == "org" ? "about.org" : "about.md" }
39
40 /// One of the free-form links a profile carries beside its
41 /// website. The server caps the list at five.
42 nonisolated struct Link: Decodable, Sendable, Hashable {
43 let label: String?
44 let url: String
45
46 /// Labels are optional; a link without one shows its URL.
47 var title: String {
48 if let label, !label.isEmpty { return label }
49 return url
50 }
51 }
52
53 nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable {
54 let name: String
55 let role: String?
56 var id: String { name }
57 }
58
59 nonisolated struct Repo: Decodable, Sendable, Hashable, Identifiable {
60 let path: String
61 let visibility: String
62 let description: String?
63 let archived: Bool?
64 let topics: [String]?
65 let license: String?
66 let defaultBranch: String?
67 let updated: String?
68
69 enum CodingKeys: String, CodingKey {
70 case path, visibility, description, archived, topics, license, updated
71 case defaultBranch = "default_branch"
72 }
73
74 var id: String { path }
75 var isPrivate: Bool { visibility == "private" }
76 var isArchived: Bool { archived ?? false }
77 var name: String { String(path.split(separator: "/").last ?? "") }
78
79 /// "main · MIT · updated 2026-08-30", the line the web's repo
80 /// rows carry. Parts the server did not report are left out.
81 var meta: String {
82 var parts: [String] = []
83 if let defaultBranch, !defaultBranch.isEmpty { parts.append(defaultBranch) }
84 if let license, !license.isEmpty { parts.append(license) }
85 if let updated, !updated.isEmpty { parts.append("updated \(updated)") }
86 return parts.joined(separator: " · ")
87 }
88 }
89
90 /// One day's contributions. Days with none are omitted, so the
91 /// calendar is filled in by the view.
92 nonisolated struct Day: Decodable, Sendable, Hashable {
93 let date: String
94 let count: Int
95 }
96 }
97
98 private(set) var state: LoadState<Profile> = .loading
99
100 private let client: GitbayClient
101 let name: String
102
103 init(client: GitbayClient, name: String) {
104 self.client = client
105 self.name = name
106 }
107
108 func load() async {
109 do {
110 state = .loaded(try await client.read(["profile", "show", name], as: Profile.self))
111 } catch {
112 state = .from(error)
113 }
114 }
115}