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 {
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 {