Profiles carry their about text and links !33

merged merged by cmc on 2026-08-31 16:35 UTC · krz/gitbay-ios:profile-about-links into main

3 files changed, +59 −1

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 {