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 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) {
15private let userProfileJSON = """ 15private 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 {