Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
4C5C8544DD7D81FA5C83A967 /* APIClientTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256FE9818A171E11DDE43140 /* APIClientTransport.swift */; };
54CB167C520E0D951820214F /* AIWritingAssistantButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A017C8D6CDA816772A8D4424 /* AIWritingAssistantButton.swift */; };
55457DA5473AFDA9C7764EBF /* DocumentSyncConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */; };
13BB05CD901E0E4960D6CDD2 /* APIClientListContributorsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2935CF30CE3CCA8210774CE5 /* APIClientListContributorsTests.swift */; };
46DB96578FB6794A1BEE36F0 /* APIClient+ListContributors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */; };
7FE95983BF8B17122E22BCEC /* ListContributor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A977FEABD137038AA221BC7 /* ListContributor.swift */; };
FC8FE4C9D951A4C0081C2793 /* ListContributorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */; };
56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */; };
5703F883D4E6186DB66E5833 /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */; };
58151C239F0FDD01BD1BAEBB /* LinkedInPostingTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */; };
Expand Down Expand Up @@ -253,6 +257,10 @@
468BAE51289ECDF42B84CE8B /* APIClientDocumentSyncTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentSyncTests.swift; sourceTree = "<group>"; };
47F77792CB1F50F6A8AC6111 /* AIStatus.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIStatus.swift; sourceTree = "<group>"; };
489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ILWebURLTests.swift; sourceTree = "<group>"; };
2935CF30CE3CCA8210774CE5 /* APIClientListContributorsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientListContributorsTests.swift; sourceTree = "<group>"; };
4A977FEABD137038AA221BC7 /* ListContributor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListContributor.swift; sourceTree = "<group>"; };
966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+ListContributors.swift"; sourceTree = "<group>"; };
F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListContributorsView.swift; sourceTree = "<group>"; };
4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ILWebURL.swift; sourceTree = "<group>"; };
4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharedResolverTests.swift; sourceTree = "<group>"; };
4D4EA609D090D3BB9AB12228 /* MessageLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageLinkView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -495,6 +503,7 @@
A1B1C1D1E1F1002E /* List.swift */,
A1B1C1D1E1F100A4 /* ListSchemaDraft.swift */,
A2B2C2D2E2F20042 /* AppNotification.swift */,
4A977FEABD137038AA221BC7 /* ListContributor.swift */,
A2B2C2D2E2F20044 /* FollowState.swift */,
A3C3D3E3F3A30052 /* Document.swift */,
C1D1E1F1A1B10002 /* Organization.swift */,
Expand Down Expand Up @@ -530,6 +539,7 @@
A1B1C1D1E1F1000A /* KeychainService.swift */,
A1B1C1D1E1F1000C /* APIClient.swift */,
A1B1C1D1E1F1000E /* AuthState.swift */,
966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */,
A3C3D3E3F3A30056 /* URLSessionProtocol.swift */,
B1C1D1E1F1A10002 /* DataCache.swift */,
B1C1D1E1F1A10004 /* AppDataStore.swift */,
Expand Down Expand Up @@ -567,6 +577,7 @@
A1B1C1D1E1F10032 /* EditMessageView.swift */,
A1B1C1D1E1F100A2 /* ListSchemaEditorView.swift */,
A1B1C1D1E1F10034 /* MessageThreadView.swift */,
F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */,
A1B1C1D1E1F10036 /* CreateListView.swift */,
A1B1C1D1E1F10038 /* UserProfileView.swift */,
A1B1C1D1E1F1003A /* ScheduledMessagesView.swift */,
Expand Down Expand Up @@ -644,6 +655,7 @@
T1E5T1E5T1E5N002 /* APIClientMessagesTests.swift */,
T1E5T1E5T1E5N004 /* APIClientListsTests.swift */,
T1E5T1E5T1E5N006 /* APIClientDocumentsTests.swift */,
2935CF30CE3CCA8210774CE5 /* APIClientListContributorsTests.swift */,
T1E5T1E5T1E5N008 /* APIClientNotificationsTests.swift */,
T1E5T1E5T1E5N010 /* APIClientFollowTests.swift */,
T1E5T1E5T1E5N012 /* APIClientProfileTests.swift */,
Expand Down Expand Up @@ -851,6 +863,9 @@
A1B1C1D1E1F100A3 /* ListSchemaDraft.swift in Sources */,
A1B1C1D1E1F10033 /* MessageThreadView.swift in Sources */,
A1B1C1D1E1F10035 /* CreateListView.swift in Sources */,
7FE95983BF8B17122E22BCEC /* ListContributor.swift in Sources */,
46DB96578FB6794A1BEE36F0 /* APIClient+ListContributors.swift in Sources */,
FC8FE4C9D951A4C0081C2793 /* ListContributorsView.swift in Sources */,
A1B1C1D1E1F10037 /* UserProfileView.swift in Sources */,
A1B1C1D1E1F10039 /* ScheduledMessagesView.swift in Sources */,
A2B2C2D2E2F20041 /* AppNotification.swift in Sources */,
Expand Down Expand Up @@ -966,6 +981,7 @@
T1E5T1E5T1E5M003 /* AppNotificationModelTests.swift in Sources */,
T1E5T1E5T1E5M005 /* FollowStateModelTests.swift in Sources */,
T1E5T1E5T1E5M007 /* DocumentModelTests.swift in Sources */,
13BB05CD901E0E4960D6CDD2 /* APIClientListContributorsTests.swift in Sources */,
T1E5T1E5T1E5N001 /* APIClientMessagesTests.swift in Sources */,
T1E5T1E5T1E5N003 /* APIClientListsTests.swift in Sources */,
T1E5T1E5T1E5N005 /* APIClientDocumentsTests.swift in Sources */,
Expand Down
50 changes: 50 additions & 0 deletions InterlinedList/Models/ListContributor.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
25 changes: 25 additions & 0 deletions InterlinedList/Services/APIClient+ListContributors.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
158 changes: 158 additions & 0 deletions InterlinedList/Views/ListContributorsView.swift
Original file line number Diff line number Diff line change
@@ -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)
]
}
Loading
Loading