Commit 8d2421afbe
Verified · cmc
Layout: unified · split
gitbay/Discovery/ProfileViewModel.swift +25 −1
| @@ -13,6 +13,11 @@ final class ProfileViewModel { | |||
| 13 | let kind: String // user | org | 13 | let kind: String // user | org |
| 14 | let description: String? | 14 | let description: String? |
| 15 | let website: 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]? | ||
| 16 | let orgs: [Member]? | 21 | let orgs: [Member]? |
| 17 | let members: [Member]? | 22 | let members: [Member]? |
| 18 | let repos: [Repo] | 23 | let repos: [Repo] |
| @@ -20,12 +25,31 @@ final class ProfileViewModel { | |||
| 20 | let activityTotal: Int | 25 | let activityTotal: Int |
| 21 | 26 | ||
| 22 | enum CodingKeys: String, CodingKey { | 27 | enum CodingKeys: String, CodingKey { |
| 23 | case name, kind, description, website, orgs, members, repos, activity | 28 | case name, kind, description, website, about, links, orgs, members, repos, activity |
| 29 | case aboutFormat = "about_format" | ||
| 24 | case activityTotal = "activity_total" | 30 | case activityTotal = "activity_total" |
| 25 | } | 31 | } |
| 26 | 32 | ||
| 27 | var isOrg: Bool { kind == "org" } | 33 | var isOrg: Bool { kind == "org" } |
| 28 | 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 | |||
| 29 | nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable { | 53 | nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable { |
| 30 | let name: String | 54 | let name: String |
| 31 | let role: String? | 55 | let role: String? |
gitbay/Views/Discovery/ProfileView.swift +23
| @@ -20,6 +20,7 @@ struct ProfileView: View { | |||
| 20 | if let profile = model.state.value { | 20 | if let profile = model.state.value { |
| 21 | header(profile) | 21 | header(profile) |
| 22 | peopleSection(profile) | 22 | peopleSection(profile) |
| 23 | aboutSection(profile) | ||
| 23 | activitySection(profile) | 24 | activitySection(profile) |
| 24 | reposSection(profile) | 25 | reposSection(profile) |
| 25 | if profile.isOrg { | 26 | if profile.isOrg { |
| @@ -64,11 +65,33 @@ struct ProfileView: View { | |||
| 64 | .lineLimit(1) | 65 | .lineLimit(1) |
| 65 | } | 66 | } |
| 66 | } | 67 | } |
| 68 | if let links = profile.links, !links.isEmpty { | ||
| 69 | ForEach(Array(links.enumerated()), id: \.offset) { _, link in | ||
| 70 | if let url = URL(string: link.url) { | ||
| 71 | Link(destination: url) { | ||
| 72 | Label(link.title, systemImage: "arrow.up.right") | ||
| 73 | .font(.gbSans(.caption)) | ||
| 74 | .lineLimit(1) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | } | ||
| 67 | } | 79 | } |
| 68 | .padding(.vertical, 4) | 80 | .padding(.vertical, 4) |
| 69 | } | 81 | } |
| 70 | } | 82 | } |
| 71 | 83 | ||
| 84 | /// The long-form about text, in whichever format it was written. | ||
| 85 | @ViewBuilder | ||
| 86 | private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View { | ||
| 87 | if let about = profile.about, !about.isEmpty { | ||
| 88 | Section("About") { | ||
| 89 | ReadmeView(name: profile.aboutFile, content: about) | ||
| 90 | .padding(.vertical, 4) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 72 | /// A user's organizations, or an organization's members — either way, | 95 | /// A user's organizations, or an organization's members — either way, |
| 73 | /// who they work with, each one openable. | 96 | /// who they work with, each one openable. |
| 74 | @ViewBuilder | 97 | @ViewBuilder |
gitbayTests/ProfileCommitTests.swift +11
| @@ -15,6 +15,8 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |||
| 15 | private let userProfileJSON = """ | 15 | private let userProfileJSON = """ |
| 16 | {"protocol_version":1,"data":{"name":"cmc","kind":"user",\ | 16 | {"protocol_version":1,"data":{"name":"cmc","kind":"user",\ |
| 17 | "description":"Org-Mode · Self-Hosting · Privacy","website":"https://cleberg.net",\ | 17 | "description":"Org-Mode · Self-Hosting · Privacy","website":"https://cleberg.net",\ |
| 18 | "about":"* Christian\\n\\nOrg-Mode.","about_format":"org",\ | ||
| 19 | "links":[{"label":"Tor","url":"http://example.onion/"},{"url":"https://krz.sh"}],\ | ||
| 18 | "orgs":[{"name":"krz","role":"admin"},{"name":"audit-labs","role":"admin"}],\ | 20 | "orgs":[{"name":"krz","role":"admin"},{"name":"audit-labs","role":"admin"}],\ |
| 19 | "repos":[{"path":"cmc/notes","visibility":"private","description":"notes",\ | 21 | "repos":[{"path":"cmc/notes","visibility":"private","description":"notes",\ |
| 20 | "topics":["org","writing"],"license":"MIT","default_branch":"main","updated":"2026-08-28"},\ | 22 | "topics":["org","writing"],"license":"MIT","default_branch":"main","updated":"2026-08-28"},\ |
| @@ -50,6 +52,10 @@ struct ProfileAggregateTests { | |||
| 50 | // A row the server reported without any of it still renders. | 52 | // A row the server reported without any of it still renders. |
| 51 | #expect(profile.repos[1].topics == nil) | 53 | #expect(profile.repos[1].topics == nil) |
| 52 | #expect(profile.repos[1].meta.isEmpty) | 54 | #expect(profile.repos[1].meta.isEmpty) |
| 55 | #expect(profile.about == "* Christian\n\nOrg-Mode.") | ||
| 56 | // The format picks the filename the about text renders under. | ||
| 57 | #expect(profile.aboutFile == "about.org") | ||
| 58 | #expect(profile.links?.map(\.title) == ["Tor", "https://krz.sh"]) | ||
| 53 | #expect(profile.activityTotal == 15) | 59 | #expect(profile.activityTotal == 15) |
| 54 | // One read builds the whole page. | 60 | // One read builds the whole page. |
| 55 | #expect(stub.seen.count == 1) | 61 | #expect(stub.seen.count == 1) |
| @@ -68,6 +74,11 @@ struct ProfileAggregateTests { | |||
| 68 | #expect(profile.members?.map(\.name) == ["cmc"]) | 74 | #expect(profile.members?.map(\.name) == ["cmc"]) |
| 69 | #expect(profile.orgs == nil) | 75 | #expect(profile.orgs == nil) |
| 70 | #expect(profile.repos.isEmpty) | 76 | #expect(profile.repos.isEmpty) |
| 77 | // A profile with no about text and no links renders neither. | ||
| 78 | #expect(profile.about == nil) | ||
| 79 | #expect(profile.links == nil) | ||
| 80 | // Markdown is the default for anything but org. | ||
| 81 | #expect(profile.aboutFile == "about.md") | ||
| 71 | } | 82 | } |
| 72 | 83 | ||
| 73 | @Test func anUnknownNameIsAnEmptyState() async throws { | 84 | @Test func anUnknownNameIsAnEmptyState() async throws { |