import NukeUI import SwiftUI enum FollowListMode: Hashable { case followers case following case knownFollowers /// Everyone who favorited a gallery. `did` is unused in this mode — the /// gallery URI carries the subject. case galleryFavorites(String) } /// Navigation payload for a gallery's favorites list. A distinct type keeps it /// from colliding with the other `String?` destinations a feed view registers. struct FavoritesDestination: Hashable, Identifiable { let galleryUri: String var id: String { galleryUri } } struct FollowListView: View { @Environment(AuthManager.self) private var auth let client: XRPCClient /// The profile whose graph is being listed. Unused for `.galleryFavorites`. var did: String = "" let mode: FollowListMode @State private var items: [FollowListItem] = [] @State private var cursor: String? @State private var totalCount: Int? @State private var isLoading = false @State private var hasLoaded = false @State private var selectedProfileDid: String? @State private var cardStoryAuthor: GrainStoryAuthor? @Environment(StoryStatusCache.self) private var storyStatusCache @Environment(ViewedStoryStorage.self) private var viewedStories private var title: String { switch mode { case .followers: "Followers" case .following: "Following" case .knownFollowers: "Followers you know" case .galleryFavorites: "Favorited by" } } private var displayCount: String { "\(totalCount ?? items.count) \(title.lowercased())" } private var emptyTitle: String { switch mode { case .followers, .knownFollowers: "No followers" case .following: "No following" case .galleryFavorites: "No favorites" } } private var emptyIcon: String { switch mode { case .followers, .knownFollowers: "person.2" case .following: "person.badge.plus" case .galleryFavorites: "heart" } } private var emptyDescription: String { switch mode { case .followers: "No one is following this account yet." case .following: "This account isn't following anyone yet." case .knownFollowers: "None of the people you follow are following this account." case .galleryFavorites: "No one has favorited this gallery yet." } } var body: some View { Group { if !hasLoaded, isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if hasLoaded, items.isEmpty { ContentUnavailableView( emptyTitle, systemImage: emptyIcon, description: Text(emptyDescription) ) } else { List { ForEach(items) { item in Button { selectedProfileDid = item.did } label: { rowContent(item: item) } .buttonStyle(.plain) .listRowSeparator(.visible) .onAppear { if item.id == items.last?.id { Task { await loadMore() } } } } if isLoading { HStack { Spacer() ProgressView() Spacer() } } } .listStyle(.plain) .refreshable { await reload() } } } .navigationTitle(title) .navigationDestination(item: $selectedProfileDid) { did in ProfileView(client: client, did: did) } .fullScreenCover(item: $cardStoryAuthor) { author in StoryViewer( authors: [author], client: client, onProfileTap: { did in cardStoryAuthor = nil selectedProfileDid = did }, onDismiss: { cardStoryAuthor = nil } ) .environment(auth) } .task { await reload() } .onAppear { if hasLoaded { Task { await reload() } } } } private func rowContent(item: FollowListItem) -> some View { HStack(alignment: .center, spacing: 14) { StoryRingView(hasStory: storyStatusCache.hasStory(for: item.did), viewed: item.did != auth.userDID && viewedStories.hasViewedAll(did: item.did, storyStatusCache: storyStatusCache), size: 50) { AvatarView(url: item.avatar, size: 50) } .onTapGesture { if let author = storyStatusCache.author(for: item.did) { cardStoryAuthor = author } else { selectedProfileDid = item.did } } .profileContextMenu( handle: item.handle, hasStory: storyStatusCache.hasStory(for: item.did), onViewProfile: { selectedProfileDid = item.did }, onViewStory: { if let author = storyStatusCache.author(for: item.did) { cardStoryAuthor = author } } ) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { if let displayName = item.displayName, !displayName.isEmpty { Text(displayName) .font(.body.weight(.semibold)) .lineLimit(1) } if let handle = item.handle { Text("@\(handle)") .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) } } if let desc = item.description, !desc.isEmpty { Text(desc) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) } } Spacer() } .padding(.vertical, 4) } private func reload() async { cursor = nil do { switch mode { case .followers: let response = try await client.getFollowers(actor: did, cursor: nil, auth: auth.authContext()) items = (response.items ?? []).map { FollowListItem(from: $0) } cursor = response.cursor totalCount = response.totalCount case .following: let response = try await client.getFollowing(actor: did, cursor: nil, auth: auth.authContext()) items = (response.items ?? []).map { FollowListItem(from: $0) } cursor = response.cursor totalCount = response.totalCount case .knownFollowers: if auth.userDID != nil { let response = try await client.getKnownFollowers(actor: did, auth: auth.authContext()) items = (response.items ?? []).map { FollowListItem(from: $0) } totalCount = items.count } case let .galleryFavorites(uri): let response = try await client.getGalleryFavorites(gallery: uri, cursor: nil, auth: auth.authContext()) items = (response.items ?? []).map { FollowListItem(from: $0) } cursor = response.cursor totalCount = response.totalCount } } catch { // keep existing items on error } hasLoaded = true } private func loadMore() async { guard !isLoading else { return } if !items.isEmpty, cursor == nil { return } isLoading = true defer { isLoading = false } do { let existingIDs = Set(items.map(\.id)) switch mode { case .followers: let response = try await client.getFollowers(actor: did, cursor: cursor, auth: auth.authContext()) if let newItems = response.items { let filtered = newItems.filter { !existingIDs.contains($0.did) } items.append(contentsOf: filtered.map { FollowListItem(from: $0) }) } cursor = response.cursor case .following: let response = try await client.getFollowing(actor: did, cursor: cursor, auth: auth.authContext()) if let newItems = response.items { let filtered = newItems.filter { !existingIDs.contains($0.did) } items.append(contentsOf: filtered.map { FollowListItem(from: $0) }) } cursor = response.cursor case .knownFollowers: break // No pagination for known followers case let .galleryFavorites(uri): let response = try await client.getGalleryFavorites(gallery: uri, cursor: cursor, auth: auth.authContext()) if let newItems = response.items { let filtered = newItems.filter { !existingIDs.contains($0.did) } items.append(contentsOf: filtered.map { FollowListItem(from: $0) }) } cursor = response.cursor } } catch { // silently fail } } private func toggleFollow(for targetDid: String) async { guard let authContext = await auth.authContext(), let repo = auth.userDID else { return } guard let index = items.firstIndex(where: { $0.did == targetDid }) else { return } let item = items[index] if let followUri = item.followingUri, followUri != "pending" { let rkey = followUri.split(separator: "/").last.map(String.init) ?? "" items[index].followingUri = nil do { try await client.deleteRecord(collection: "social.grain.graph.follow", rkey: rkey, auth: authContext) if mode == .following, did == repo { if let idx = items.firstIndex(where: { $0.did == targetDid }) { items.remove(at: idx) } if let count = totalCount { totalCount = max(0, count - 1) } } } catch { if let idx = items.firstIndex(where: { $0.did == targetDid }) { items[idx].followingUri = followUri } } } else if item.followingUri == nil { items[index].followingUri = "pending" do { let record = AnyCodable([ "subject": item.did, "createdAt": DateFormatting.nowISO(), ]) let result = try await client.createRecord( collection: "social.grain.graph.follow", repo: repo, record: record, auth: authContext ) if let idx = items.firstIndex(where: { $0.did == targetDid }) { items[idx].followingUri = result.uri } } catch { if let idx = items.firstIndex(where: { $0.did == targetDid }) { items[idx].followingUri = nil } } } } } struct FollowListItem: Identifiable { let did: String var handle: String? var displayName: String? var description: String? var avatar: String? var followingUri: String? var id: String { did } init(from follower: FollowerItem) { did = follower.did handle = follower.handle displayName = follower.displayName description = follower.description avatar = follower.avatar followingUri = follower.viewer?.following } init(from favorite: FavoriteItem) { did = favorite.did handle = favorite.handle displayName = favorite.displayName description = favorite.description avatar = favorite.avatar followingUri = favorite.viewer?.following } init(from following: FollowingItem) { did = following.did handle = following.handle displayName = following.displayName description = following.description avatar = following.avatar followingUri = following.viewer?.following } } #Preview { NavigationStack { FollowListView( client: .preview, did: "did:plc:preview", mode: .followers ) } .previewEnvironments() .preferredColorScheme(.dark) .tint(Color.accentColor) .frame(maxHeight: .infinity, alignment: .top) }