From 369842a4e47da9f2cef18e10e2da9537194172ac Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 9 Sep 2026 10:06:16 -0700 Subject: [PATCH] feat(lists): show who has contributed rows on the list detail header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS surfaced watchers — who has been *granted* access — but never contributors, who have actually *used* it. On a shared list the latter is the more useful signal, and the web has shown it on the list header since ContributorAvatarStack landed. - add `listContributors(listId:)` in a new `APIClient+ListContributors` feature extension (not `APIClient.swift`), returning the route's full ranked set; `GET /api/lists/{id}/contributors` does not page - add a `ListContributor` model carrying the server's added/edited counts and `score`, with a `ListContributorsResult` wrapper for the total - add `ContributorAvatarStack` (cascading avatars, "+N" overflow) and `ListContributorsSheet`, whose rows mirror the WatchersListView style - mount the stack as a tappable header section in `ListDetailView`, fetched concurrently with the existing schema/items/connections loads - hide the affordance entirely for GitHub-backed lists, whose contributor set the route returns empty by design, and below two contributors, so a single-owner list stays uncluttered (matches the web's threshold) - the contributor fetch is non-fatal: a failure or a 403 leaves the stack hidden rather than failing the whole list Row writes are untouched — GitHub rows still go through the full-row `updateItem`, since a partial PUT renames the issue to "Untitled". Closes #58 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013Xq12igmUH4tsNhKy54dY6 --- InterlinedList.xcodeproj/project.pbxproj | 16 ++ InterlinedList/Models/ListContributor.swift | 50 ++++++ .../Services/APIClient+ListContributors.swift | 25 +++ .../Views/ListContributorsView.swift | 158 ++++++++++++++++++ InterlinedList/Views/ListsView.swift | 55 ++++++ .../APIClientListContributorsTests.swift | 147 ++++++++++++++++ 6 files changed, 451 insertions(+) create mode 100644 InterlinedList/Models/ListContributor.swift create mode 100644 InterlinedList/Services/APIClient+ListContributors.swift create mode 100644 InterlinedList/Views/ListContributorsView.swift create mode 100644 InterlinedListTests/APIClientTests/APIClientListContributorsTests.swift diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index f6d3089..feb269f 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 0C2A6EE423332702FD29DEBA /* Manrope.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 229B4152945DABCD6D88A59A /* Manrope.ttf */; }; 10A357B7D843EDB843C1B640 /* AIArtifact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F919661375CA53C1864025A /* AIArtifact.swift */; }; 1102DD971BF4AC03027058B9 /* BlockedUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */; }; + 13BB05CD901E0E4960D6CDD2 /* APIClientListContributorsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2935CF30CE3CCA8210774CE5 /* APIClientListContributorsTests.swift */; }; 153437B4818A068C9ADE32DE /* APIClientLinkMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907AC4BA9A6A5DF132AEF075 /* APIClientLinkMetadataTests.swift */; }; 1775C9B4C2CF08267AF3B138 /* WatchersListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5828367E38948582EF1DF2A /* WatchersListView.swift */; }; 17CD6CF35900DC95AA284E3C /* PushService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B79D26B822DCA1188DF37E9 /* PushService.swift */; }; @@ -33,6 +34,7 @@ 2BD179B7617ABFD7271B9FB8 /* GitHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802D2FF0552D510BB51D636 /* GitHub.swift */; }; 362DEA03EA2CDD9E135B1411 /* APIClientDirectMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A3F2C860C6B16F84CFFE12 /* APIClientDirectMessagesTests.swift */; }; 3D042B3B4C9E2317F7A4C04C /* MessageDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544F68F263670763D5E23C59 /* MessageDetailView.swift */; }; + 46DB96578FB6794A1BEE36F0 /* APIClient+ListContributors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */; }; 46E2E2E80CBE246E84A5D78B /* MarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3DF89EFAF63A9287656938C /* MarkdownView.swift */; }; 47FBAC21F771B2813EAD8D37 /* Moderation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1147B978D9E72695E4457454 /* Moderation.swift */; }; 4A2F1A2E2FB0B4BC857F9E30 /* DocumentSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */; }; @@ -60,6 +62,7 @@ 798DC41883DC941055210F39 /* AISuggestionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DC6255396818F5BB523DE /* AISuggestionSheet.swift */; }; 7CD66F2490C7D2D4A3227E6A /* ILWebURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */; }; 7DC9B83A3D2C66E1F6F0AEC3 /* SharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADBF836F0B5BD6524776FE7 /* SharingView.swift */; }; + 7FE95983BF8B17122E22BCEC /* ListContributor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A977FEABD137038AA221BC7 /* ListContributor.swift */; }; 874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */; }; 8BEDD4D904328EC8027314DC /* SpaceGrotesk.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */; }; 8C393D2AE39FAEFD773AAF53 /* AIComposerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF9EA6C9C5C613591038EA9A /* AIComposerLogicTests.swift */; }; @@ -161,6 +164,7 @@ F93C3A953A0B809D718F9DB5 /* PublicDocumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */; }; FA145F808445802781845261 /* DocumentLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */; }; FC004747AE60A6796DAD36AB /* APIClientDocumentSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468BAE51289ECDF42B84CE8B /* APIClientDocumentSyncTests.swift */; }; + FC8FE4C9D951A4C0081C2793 /* ListContributorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */; }; FCA5249B389CBC18040D6011 /* ComposeImageStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */; }; FDF51BC81627FCB138474AFE /* AISeriesGeneratorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77237EF9E0A0D7E3D2B321CF /* AISeriesGeneratorSection.swift */; }; T1E5T1E5T1E50001 /* InterlinedListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50002 /* InterlinedListTests.swift */; }; @@ -236,6 +240,7 @@ 236670B73F2B603E5EB318D7 /* ShareInvite.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvite.swift; sourceTree = ""; }; 256FE9818A171E11DDE43140 /* APIClientTransport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTransport.swift; sourceTree = ""; }; 281721EB23841D6DD2F6EE58 /* AIServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIServiceTests.swift; sourceTree = ""; }; + 2935CF30CE3CCA8210774CE5 /* APIClientListContributorsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientListContributorsTests.swift; sourceTree = ""; }; 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageStrip.swift; sourceTree = ""; }; 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LinkedInPostingTarget.swift; sourceTree = ""; }; 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicListDetailView.swift; sourceTree = ""; }; @@ -248,6 +253,7 @@ 468BAE51289ECDF42B84CE8B /* APIClientDocumentSyncTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentSyncTests.swift; sourceTree = ""; }; 47F77792CB1F50F6A8AC6111 /* AIStatus.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIStatus.swift; sourceTree = ""; }; 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ILWebURLTests.swift; sourceTree = ""; }; + 4A977FEABD137038AA221BC7 /* ListContributor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListContributor.swift; sourceTree = ""; }; 4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ILWebURL.swift; sourceTree = ""; }; 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharedResolverTests.swift; sourceTree = ""; }; 4D4EA609D090D3BB9AB12228 /* MessageLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageLinkView.swift; sourceTree = ""; }; @@ -283,6 +289,7 @@ 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GapModelsTests.swift; sourceTree = ""; }; 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSearchUsersTests.swift; sourceTree = ""; }; 956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDeepLinkParseTests.swift; sourceTree = ""; }; + 966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+ListContributors.swift"; sourceTree = ""; }; 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLimitsTests.swift; sourceTree = ""; }; 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicDocumentsView.swift; sourceTree = ""; }; 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharedListsTests.swift; sourceTree = ""; }; @@ -374,6 +381,7 @@ F54C1776AD4A8F204FA8A2BC /* AIArtifactTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIArtifactTests.swift; sourceTree = ""; }; F5569B56DBD6747BB9587870 /* SessionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SessionsView.swift; sourceTree = ""; }; F78B096C366108083D174366 /* MarkdownBlockTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownBlockTests.swift; sourceTree = ""; }; + F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListContributorsView.swift; sourceTree = ""; }; FC5914D730B4355F02E89D81 /* ILColor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterlinedList/Services/ILColor.swift; sourceTree = SOURCE_ROOT; }; T1E5T1E5T1E50000 /* InterlinedListTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InterlinedListTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; T1E5T1E5T1E50002 /* InterlinedListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterlinedListTests.swift; sourceTree = ""; }; @@ -511,6 +519,7 @@ 70A0129BD986243B7BECC74B /* AIRequest.swift */, 5D154417430173A77A358118 /* AISeriesLoading.swift */, DE1B944CA1DA2CC8C8EECFC0 /* AIPoweredTemplate.swift */, + 4A977FEABD137038AA221BC7 /* ListContributor.swift */, ); path = Models; sourceTree = ""; @@ -540,6 +549,7 @@ 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */, 7318B0E3D687653B06626F64 /* APIClient+AI.swift */, C2A39A4CB7CD21E155C1AD77 /* AIService.swift */, + 966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */, ); path = Services; sourceTree = ""; @@ -607,6 +617,7 @@ 77237EF9E0A0D7E3D2B321CF /* AISeriesGeneratorSection.swift */, 5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */, B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */, + F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */, ); path = Views; sourceTree = ""; @@ -669,6 +680,7 @@ 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */, 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */, EEB1570DDE0B0F44F205DC71 /* APIClientAITests.swift */, + 2935CF30CE3CCA8210774CE5 /* APIClientListContributorsTests.swift */, ); path = APIClientTests; sourceTree = ""; @@ -932,6 +944,9 @@ FDF51BC81627FCB138474AFE /* AISeriesGeneratorSection.swift in Sources */, 6DACF0399E254AAA27286324 /* AIPoweredTemplateView.swift in Sources */, E3626ADC322104C3A3BC8760 /* AIPoweredDocumentSheet.swift in Sources */, + 7FE95983BF8B17122E22BCEC /* ListContributor.swift in Sources */, + 46DB96578FB6794A1BEE36F0 /* APIClient+ListContributors.swift in Sources */, + FC8FE4C9D951A4C0081C2793 /* ListContributorsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1018,6 +1033,7 @@ 016599A41F91048F2AA1AF10 /* AIArtifactTests.swift in Sources */, 8C393D2AE39FAEFD773AAF53 /* AIComposerLogicTests.swift in Sources */, C4C3B533A4C5F76394318FAB /* AIServiceTests.swift in Sources */, + 13BB05CD901E0E4960D6CDD2 /* APIClientListContributorsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/Models/ListContributor.swift b/InterlinedList/Models/ListContributor.swift new file mode 100644 index 0000000..c2d495a --- /dev/null +++ b/InterlinedList/Models/ListContributor.swift @@ -0,0 +1,50 @@ +// +// ListContributor.swift +// InterlinedList +// + +import Foundation + +/// A person who has actually written rows into a list, aggregated by the backend +/// across every row's created-by / last-edited-by attribution. +/// +/// Distinct from `ListWatcher`: a watcher has been *granted* access, a +/// contributor has *used* it. On a shared list the latter is the more useful +/// signal, which is why the web surfaces it on the list header. +struct ListContributor: Identifiable, Codable { + let id: String + let username: String + let displayName: String? + let avatar: String? + /// Rows this person created. + let addedCount: Int + /// Rows this person was the last to edit. + let editedCount: Int + /// `addedCount + editedCount` — the server's ranking key. + let score: Int + + var displayNameOrUsername: String { + displayName?.isEmpty == false ? (displayName ?? username) : username + } + + /// Matches the web's contributor subtitle: "added 4 · edited 2". + var contributionSummary: String { + "added \(addedCount) · edited \(editedCount)" + } +} + +/// `GET /api/lists/{id}/contributors` — the **full** ranked set with no server +/// paging, so `totalContributors` always equals `contributors.count`. It is +/// decoded anyway because it is what the web's "+N" overflow bubble counts +/// against, and trusting the server's own total keeps iOS and web in step. +struct ListContributorsResult: Decodable { + let contributors: [ListContributor] + let totalContributors: Int + + static let empty = ListContributorsResult(contributors: [], totalContributors: 0) + + init(contributors: [ListContributor], totalContributors: Int) { + self.contributors = contributors + self.totalContributors = totalContributors + } +} diff --git a/InterlinedList/Services/APIClient+ListContributors.swift b/InterlinedList/Services/APIClient+ListContributors.swift new file mode 100644 index 0000000..e7f0765 --- /dev/null +++ b/InterlinedList/Services/APIClient+ListContributors.swift @@ -0,0 +1,25 @@ +// +// APIClient+ListContributors.swift +// InterlinedList +// + +import Foundation + +/// Who has actually contributed rows to a list. +/// +/// Read-only: this extension adds no write path. Row mutations — GitHub-backed +/// ones especially, which must send the full row through `updateItem` — are +/// untouched by anything here. +extension APIClient { + + /// `GET /api/lists/{id}/contributors` — the full ranked contributor set, + /// ordered by score (added + edited) descending, then added, then username. + /// The route does not page. + /// + /// Returns an empty set for GitHub-backed lists by design; callers should + /// skip the request entirely for those rather than rely on the empty answer. + func listContributors(listId: String) async throws -> ListContributorsResult { + let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + return try await get("/api/lists/\(encoded)/contributors") + } +} diff --git a/InterlinedList/Views/ListContributorsView.swift b/InterlinedList/Views/ListContributorsView.swift new file mode 100644 index 0000000..4b0e48c --- /dev/null +++ b/InterlinedList/Views/ListContributorsView.swift @@ -0,0 +1,158 @@ +// +// ListContributorsView.swift +// InterlinedList +// + +import SwiftUI + +/// Cascading avatars for the top contributors to a list, sized to sit inline in +/// the list detail header. Overflow beyond `maxAvatars` collapses into a "+N" +/// bubble, mirroring the web's `ContributorAvatarStack`. +struct ContributorAvatarStack: View { + let contributors: [ListContributor] + let total: Int + var size: CGFloat = 28 + var maxAvatars: Int = 3 + + private var shown: [ListContributor] { Array(contributors.prefix(maxAvatars)) } + private var overflow: Int { max(0, total - shown.count) } + private var overlap: CGFloat { size * 0.35 } + + var body: some View { + HStack(spacing: -overlap) { + ForEach(shown) { contributor in + ContributorAvatar(avatar: contributor.avatar, size: size) + .overlay(Circle().stroke(ILColor.surface, lineWidth: 2)) + } + if overflow > 0 { + Text("+\(overflow)") + .font(.ilMono(size * 0.4)) + .foregroundStyle(.white) + .frame(width: size, height: size) + .background(Color.secondary, in: Circle()) + .overlay(Circle().stroke(ILColor.surface, lineWidth: 2)) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Self.label(for: total)) + } + + static func label(for total: Int) -> String { + total == 1 ? "1 contributor" : "\(total) contributors" + } +} + +/// The full ranked contributor set. The route returns everything at once, so +/// this scrolls rather than paging (the web's 6-per-page modal is a desktop +/// affordance). Row style mirrors `WatchersListView`. +struct ListContributorsSheet: View { + let contributors: [ListContributor] + let total: Int + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + if contributors.isEmpty { + ContentUnavailableView { + Label("No contributors yet", systemImage: "person.2") + } description: { + Text("Nobody has added or edited rows on this list.") + } + } else { + ForEach(contributors) { contributor in + ContributorRow(contributor: contributor) + } + } + } + .navigationTitle("Contributors (\(total))") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +private struct ContributorRow: View { + let contributor: ListContributor + + var body: some View { + HStack(spacing: 12) { + ContributorAvatar(avatar: contributor.avatar, size: 36) + VStack(alignment: .leading, spacing: 2) { + Text(contributor.displayNameOrUsername) + .font(.ilBody()) + Text("@\(contributor.username) · \(contributor.contributionSummary)") + .font(.ilMono()) + .foregroundStyle(.secondary) + } + Spacer() + Text("\(contributor.score)") + .font(.ilMono()) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(ILColor.surface2) + .clipShape(Capsule()) + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(contributor.displayNameOrUsername), added \(contributor.addedCount), edited \(contributor.editedCount)" + ) + } +} + +/// Circular remote avatar with a person-glyph fallback, matching the watcher row. +private struct ContributorAvatar: View { + let avatar: String? + let size: CGFloat + + var body: some View { + if let url = avatar.flatMap({ URL(string: $0) }) { + AsyncImage(url: url) { phase in + if let image = phase.image { + image.resizable().scaledToFill() + } else { + placeholder + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } else { + placeholder + .frame(width: size, height: size) + .clipShape(Circle()) + } + } + + private var placeholder: some View { + Image(systemName: "person.circle.fill") + .resizable() + .scaledToFit() + .foregroundStyle(.secondary) + } +} + +#Preview("Stack") { + ContributorAvatarStack(contributors: ListContributor.previewSet, total: 5) + .padding() +} + +#Preview("Sheet") { + ListContributorsSheet(contributors: ListContributor.previewSet, total: 3) +} + +private extension ListContributor { + static let previewSet: [ListContributor] = [ + ListContributor(id: "u1", username: "adron", displayName: "Adron Hall", + avatar: nil, addedCount: 12, editedCount: 4, score: 16), + ListContributor(id: "u2", username: "rhea", displayName: "Rhea Kim", + avatar: nil, addedCount: 7, editedCount: 1, score: 8), + ListContributor(id: "u3", username: "sam", displayName: nil, + avatar: nil, addedCount: 2, editedCount: 3, score: 5) + ] +} diff --git a/InterlinedList/Views/ListsView.swift b/InterlinedList/Views/ListsView.swift index d33b711..681ec64 100644 --- a/InterlinedList/Views/ListsView.swift +++ b/InterlinedList/Views/ListsView.swift @@ -399,6 +399,8 @@ struct ListDetailView: View { @State private var isRefreshingGitHub = false @State private var gitHubRefreshError: String? @State private var gitHubStateFilter: GitHubStateFilter = .open + @State private var contributors = ListContributorsResult.empty + @State private var showContributors = false /// Open/closed filter for GitHub-backed lists. GitHub issues carry a `state` /// of `open`/`closed`; the list defaults to showing open issues only. @@ -445,6 +447,13 @@ struct ListDetailView: View { : "No closed issues. Switch to Open to see open issues." } + /// GitHub-backed lists have no contributor set — the route returns empty by + /// design — and a single-owner list with nobody else on it is noise, so the + /// stack only appears from two distinct contributors up (matching the web). + private var showsContributors: Bool { + !list.isGitHubBacked && contributors.totalContributors >= 2 + } + var body: some View { Group { if isLoading && items.isEmpty { @@ -463,6 +472,9 @@ struct ListDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List { + if showsContributors { + contributorsSection + } if list.isGitHubBacked { gitHubStatusSection if !items.isEmpty { @@ -664,6 +676,12 @@ struct ListDetailView: View { Task { await saveEdit(item: item, rowData: rowData) } } } + .sheet(isPresented: $showContributors) { + ListContributorsSheet( + contributors: contributors.contributors, + total: contributors.totalContributors + ) + } .confirmationDialog("Delete this item?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { Button("Delete", role: .destructive) { if let item = deletingItem { Task { await deleteItem(item) } } @@ -672,6 +690,33 @@ struct ListDetailView: View { } } + @ViewBuilder + private var contributorsSection: some View { + Section { + Button { + showContributors = true + } label: { + HStack(spacing: 10) { + ContributorAvatarStack( + contributors: contributors.contributors, + total: contributors.totalContributors + ) + Text(ContributorAvatarStack.label(for: contributors.totalContributors)) + .font(.ilBody(15)) + .foregroundStyle(.secondary) + Spacer() + Image(systemName: "chevron.right") + .font(.ilBody(12)) + .foregroundStyle(.tertiary) + } + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(ContributorAvatarStack.label(for: contributors.totalContributors)) + .accessibilityHint("Opens the full list of contributors") + } + } + @ViewBuilder private var gitHubStatusSection: some View { Section { @@ -844,6 +889,7 @@ struct ListDetailView: View { async let itemsTask = APIClient.shared.listItems(listId: list.id) async let connectionsTask = APIClient.shared.listConnections() async let allListsTask = APIClient.shared.lists() + async let contributorsTask = fetchContributors() let (fetchedSchema, fetchedItems) = try await (schemaTask, itemsTask) // The server returns the synthetic issue schema for GitHub-backed // lists (backend fix). The client fallback is a safety net for an @@ -858,6 +904,7 @@ struct ListDetailView: View { connections = (try? await connectionsTask)? .filter { $0.fromListId == listId || $0.toListId == listId } ?? [] allLists = (try? await allListsTask) ?? [] + contributors = await contributorsTask } catch APIError.status(401) { authState.handleUnauthorized() errorMessage = "Session expired or not authorized." @@ -867,6 +914,14 @@ struct ListDetailView: View { errorMessage = "Failed to load list." } } + + /// Non-fatal: a failed or forbidden contributor fetch simply leaves the stack + /// hidden rather than failing the whole list. Skipped entirely for + /// GitHub-backed lists, whose contributor set is empty by design. + private func fetchContributors() async -> ListContributorsResult { + guard !list.isGitHubBacked else { return .empty } + return (try? await APIClient.shared.listContributors(listId: list.id)) ?? .empty + } } // MARK: - Dynamic item row diff --git a/InterlinedListTests/APIClientTests/APIClientListContributorsTests.swift b/InterlinedListTests/APIClientTests/APIClientListContributorsTests.swift new file mode 100644 index 0000000..e7ec22c --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientListContributorsTests.swift @@ -0,0 +1,147 @@ +import XCTest +@testable import InterlinedList + +/// W6 (#58). `GET /api/lists/{id}/contributors` returns the full ranked +/// contributor set with no server paging. These tests pin the route, the decode +/// of the aggregation counts the header stack renders, and the two shapes that +/// must degrade quietly: the empty set GitHub-backed lists get by design, and a +/// 403 on a list the viewer can read but not aggregate. +final class APIClientListContributorsTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + /// Live shape: camelCase keys straight off `serialize()`, ranked by score + /// descending, with `totalContributors` alongside. + private var contributorsJSON: String { + #""" + {"contributors":[ + {"id":"u1","username":"adron","displayName":"Adron Hall", + "avatar":"https://example.com/a.png","addedCount":12,"editedCount":4,"score":16}, + {"id":"u2","username":"rhea","displayName":"Rhea Kim", + "avatar":null,"addedCount":7,"editedCount":1,"score":8}, + {"id":"u3","username":"sam","displayName":null, + "avatar":null,"addedCount":2,"editedCount":3,"score":5} + ], + "totalContributors":3} + """# + } + + func test_listContributors_sendsGetToContributorsPath() async throws { + session.stub(json: contributorsJSON) + _ = try await sut.listContributors(listId: "list-1") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/list-1/contributors") + } + + func test_listContributors_percentEncodesListId() async throws { + session.stub(json: contributorsJSON) + _ = try await sut.listContributors(listId: "list one") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/list one/contributors") + XCTAssertTrue( + session.lastRequest?.url?.absoluteString.contains("list%20one") == true, + session.lastRequest?.url?.absoluteString ?? "nil" + ) + } + + func test_listContributors_decodesFullSetAndTotal() async throws { + session.stub(json: contributorsJSON) + let result = try await sut.listContributors(listId: "list-1") + XCTAssertEqual(result.totalContributors, 3) + XCTAssertEqual(result.contributors.count, 3) + } + + func test_listContributors_decodesAggregationCounts() async throws { + session.stub(json: contributorsJSON) + let result = try await sut.listContributors(listId: "list-1") + let top = try XCTUnwrap(result.contributors.first) + XCTAssertEqual(top.id, "u1") + XCTAssertEqual(top.username, "adron") + XCTAssertEqual(top.displayName, "Adron Hall") + XCTAssertEqual(top.avatar, "https://example.com/a.png") + XCTAssertEqual(top.addedCount, 12) + XCTAssertEqual(top.editedCount, 4) + XCTAssertEqual(top.score, 16) + } + + /// The server ranks; the client must not re-sort or the stack would disagree + /// with the web's order. + func test_listContributors_preservesServerRanking() async throws { + session.stub(json: contributorsJSON) + let result = try await sut.listContributors(listId: "list-1") + XCTAssertEqual(result.contributors.map(\.id), ["u1", "u2", "u3"]) + XCTAssertEqual(result.contributors.map(\.score), [16, 8, 5]) + } + + /// `displayName` is nullable; the row headline falls back to the username. + func test_contributor_nullDisplayNameFallsBackToUsername() async throws { + session.stub(json: contributorsJSON) + let result = try await sut.listContributors(listId: "list-1") + let sam = try XCTUnwrap(result.contributors.last) + XCTAssertNil(sam.displayName) + XCTAssertEqual(sam.displayNameOrUsername, "sam") + XCTAssertEqual(result.contributors[0].displayNameOrUsername, "Adron Hall") + } + + /// An empty displayName is as good as absent — same fallback. + func test_contributor_emptyDisplayNameFallsBackToUsername() throws { + let json = #"{"id":"u9","username":"kit","displayName":"","avatar":null,"addedCount":1,"editedCount":0,"score":1}"# + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let contributor = try decoder.decode(ListContributor.self, from: Data(json.utf8)) + XCTAssertEqual(contributor.displayNameOrUsername, "kit") + } + + /// Matches the web's contributor subtitle exactly. + func test_contributor_contributionSummaryMatchesWebCopy() async throws { + session.stub(json: contributorsJSON) + let result = try await sut.listContributors(listId: "list-1") + XCTAssertEqual(result.contributors[0].contributionSummary, "added 12 · edited 4") + XCTAssertEqual(result.contributors[1].contributionSummary, "added 7 · edited 1") + } + + /// What a GitHub-backed list returns by design. It must decode cleanly, not throw. + func test_listContributors_emptySetDecodes() async throws { + session.stub(json: #"{"contributors":[],"totalContributors":0}"#) + let result = try await sut.listContributors(listId: "gh-list") + XCTAssertTrue(result.contributors.isEmpty) + XCTAssertEqual(result.totalContributors, 0) + } + + func test_listContributors_403_throws() async { + session.stub(data: Data(), statusCode: 403) + do { + _ = try await sut.listContributors(listId: "list-1") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_listContributors_401_throws() async { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.listContributors(listId: "list-1") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + /// The stack's accessibility label singularizes at one. + func test_avatarStackLabel_pluralization() { + XCTAssertEqual(ContributorAvatarStack.label(for: 1), "1 contributor") + XCTAssertEqual(ContributorAvatarStack.label(for: 3), "3 contributors") + XCTAssertEqual(ContributorAvatarStack.label(for: 0), "0 contributors") + } +}