Commit 8d2421afbe
8d2421afbed80769ce8055328b087c584f83d884
parent: d56d8c6c96
Verified · cmc
cmc <hello@cleberg.net> · 2026-08-31 16:30 UTC
Profiles carry their about text and links
profile show reports about, about_format and links; the app dropped all
three. The about text renders under the format the server stored it in,
through the same ReadmeView the repo READMEs use, and the links sit in
the header below the website, labelled or bare URL as the web shows them.
Layout: unified · split
gitbay/Discovery/ProfileViewModel.swift
+25 −1
| @@ -13,6 +13,11 @@ final class ProfileViewModel { |
| 13 | 13 | let kind: String // user | org |
| 14 | 14 | let description: String? |
| 15 | 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 | 21 | let orgs: [Member]? |
| 17 | 22 | let members: [Member]? |
| 18 | 23 | let repos: [Repo] |
| @@ -20,12 +25,31 @@ final class ProfileViewModel { |
| 20 | 25 | let activityTotal: Int |
| 21 | 26 | |
| 22 | 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 | 30 | case activityTotal = "activity_total" |
| 25 | 31 | } |
| 26 | 32 | |
| 27 | 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 | 53 | nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable { |
| 30 | 54 | let name: String |
| 31 | 55 | let role: String? |
gitbay/Views/Discovery/ProfileView.swift
+23
| @@ -20,6 +20,7 @@ struct ProfileView: View { |
| 20 | 20 | if let profile = model.state.value { |
| 21 | 21 | header(profile) |
| 22 | 22 | peopleSection(profile) |
| 23 | aboutSection(profile) |
| 23 | 24 | activitySection(profile) |
| 24 | 25 | reposSection(profile) |
| 25 | 26 | if profile.isOrg { |
| @@ -64,11 +65,33 @@ struct ProfileView: View { |
| 64 | 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 | 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 | 95 | /// A user's organizations, or an organization's members — either way, |
| 73 | 96 | /// who they work with, each one openable. |
| 74 | 97 | @ViewBuilder |
gitbayTests/ProfileCommitTests.swift
+11
| @@ -15,6 +15,8 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { |
| 15 | 15 | private let userProfileJSON = """ |
| 16 | 16 | {"protocol_version":1,"data":{"name":"cmc","kind":"user",\ |
| 17 | 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 | 20 | "orgs":[{"name":"krz","role":"admin"},{"name":"audit-labs","role":"admin"}],\ |
| 19 | 21 | "repos":[{"path":"cmc/notes","visibility":"private","description":"notes",\ |
| 20 | 22 | "topics":["org","writing"],"license":"MIT","default_branch":"main","updated":"2026-08-28"},\ |
| @@ -50,6 +52,10 @@ struct ProfileAggregateTests { |
| 50 | 52 | // A row the server reported without any of it still renders. |
| 51 | 53 | #expect(profile.repos[1].topics == nil) |
| 52 | 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 | 59 | #expect(profile.activityTotal == 15) |
| 54 | 60 | // One read builds the whole page. |
| 55 | 61 | #expect(stub.seen.count == 1) |
| @@ -68,6 +74,11 @@ struct ProfileAggregateTests { |
| 68 | 74 | #expect(profile.members?.map(\.name) == ["cmc"]) |
| 69 | 75 | #expect(profile.orgs == nil) |
| 70 | 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 | 84 | @Test func anUnknownNameIsAnEmptyState() async throws { |