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 {
1313 let kind: String // user | org
1414 let description: String?
1515 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]?
1621 let orgs: [Member]?
1722 let members: [Member]?
1823 let repos: [Repo]
@@ -20,12 +25,31 @@ final class ProfileViewModel {
2025 let activityTotal: Int
2126
2227 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"
2430 case activityTotal = "activity_total"
2531 }
2632
2733 var isOrg: Bool { kind == "org" }
2834
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
2953 nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable {
3054 let name: String
3155 let role: String?
gitbay/Views/Discovery/ProfileView.swift +23
@@ -20,6 +20,7 @@ struct ProfileView: View {
2020 if let profile = model.state.value {
2121 header(profile)
2222 peopleSection(profile)
23 aboutSection(profile)
2324 activitySection(profile)
2425 reposSection(profile)
2526 if profile.isOrg {
@@ -64,11 +65,33 @@ struct ProfileView: View {
6465 .lineLimit(1)
6566 }
6667 }
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 }
6779 }
6880 .padding(.vertical, 4)
6981 }
7082 }
7183
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
7295 /// A user's organizations, or an organization's members — either way,
7396 /// who they work with, each one openable.
7497 @ViewBuilder
gitbayTests/ProfileCommitTests.swift +11
@@ -15,6 +15,8 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
1515private let userProfileJSON = """
1616 {"protocol_version":1,"data":{"name":"cmc","kind":"user",\
1717 "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"}],\
1820 "orgs":[{"name":"krz","role":"admin"},{"name":"audit-labs","role":"admin"}],\
1921 "repos":[{"path":"cmc/notes","visibility":"private","description":"notes",\
2022 "topics":["org","writing"],"license":"MIT","default_branch":"main","updated":"2026-08-28"},\
@@ -50,6 +52,10 @@ struct ProfileAggregateTests {
5052 // A row the server reported without any of it still renders.
5153 #expect(profile.repos[1].topics == nil)
5254 #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"])
5359 #expect(profile.activityTotal == 15)
5460 // One read builds the whole page.
5561 #expect(stub.seen.count == 1)
@@ -68,6 +74,11 @@ struct ProfileAggregateTests {
6874 #expect(profile.members?.map(\.name) == ["cmc"])
6975 #expect(profile.orgs == nil)
7076 #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")
7182 }
7283
7384 @Test func anUnknownNameIsAnEmptyState() async throws {