iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: known followers display on profiles

Show "Followed by X, Y and Z others you follow" with overlapping
avatars on other users' profiles, tappable to a full list. Fetches
known followers concurrently with profile/feed/stories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+90 -11
+20 -8
Grain/ViewModels/ProfileDetailViewModel.swift
··· 6 6 var profile: GrainProfileDetailed? 7 7 var galleries: [GrainGallery] = [] 8 8 var stories: [GrainStory] = [] 9 + var knownFollowers: [FollowerItem] = [] 9 10 var isLoading = false 10 11 var error: Error? 11 12 ··· 22 23 error = nil 23 24 24 25 do { 25 - let p = try await client.getActorProfile(actor: did, viewer: viewer, auth: auth) 26 - let f = try await client.getFeed(feed: "actor", actor: did, auth: auth) 27 - let s = try await client.getStories(actor: did, auth: auth) 28 - profile = p 29 - galleries = f.items ?? [] 30 - galleryCursor = f.cursor 31 - hasMoreGalleries = f.cursor != nil 32 - stories = s.stories 26 + async let p = client.getActorProfile(actor: did, viewer: viewer, auth: auth) 27 + async let f = client.getFeed(feed: "actor", actor: did, auth: auth) 28 + async let s = client.getStories(actor: did, auth: auth) 29 + async let kf: [FollowerItem] = { 30 + guard let viewer, viewer != did else { return [] } 31 + let response = try? await client.getKnownFollowers(actor: did, viewer: viewer, auth: auth) 32 + return response?.items ?? [] 33 + }() 34 + 35 + let profileResult = try await p 36 + let feedResult = try await f 37 + let storiesResult = try await s 38 + 39 + profile = profileResult 40 + galleries = feedResult.items ?? [] 41 + galleryCursor = feedResult.cursor 42 + hasMoreGalleries = feedResult.cursor != nil 43 + stories = storiesResult.stories 44 + knownFollowers = await kf 33 45 } catch { 34 46 self.error = error 35 47 }
+16 -3
Grain/Views/Profile/FollowListView.swift
··· 4 4 enum FollowListMode: Hashable { 5 5 case followers 6 6 case following 7 + case knownFollowers 7 8 } 8 9 9 10 struct FollowListView: View { ··· 21 22 @Environment(StoryStatusCache.self) private var storyStatusCache 22 23 23 24 private var title: String { 24 - mode == .followers ? "Followers" : "Following" 25 + switch mode { 26 + case .followers: "Followers" 27 + case .following: "Following" 28 + case .knownFollowers: "Followers you know" 29 + } 25 30 } 26 31 27 32 private var displayCount: String { ··· 36 41 } else if hasLoaded && items.isEmpty { 37 42 ContentUnavailableView( 38 43 "No \(title)", 39 - systemImage: mode == .followers ? "person.2" : "person.badge.plus", 40 - description: Text(mode == .followers ? "No one is following this account yet." : "This account isn't following anyone yet.") 44 + systemImage: mode == .knownFollowers ? "person.2" : mode == .followers ? "person.2" : "person.badge.plus", 45 + description: Text(mode == .knownFollowers ? "None of the people you follow are following this account." : mode == .followers ? "No one is following this account yet." : "This account isn't following anyone yet.") 41 46 ) 42 47 } else { 43 48 List { ··· 150 155 items = (response.items ?? []).map { FollowListItem(from: $0) } 151 156 cursor = response.cursor 152 157 totalCount = response.totalCount 158 + case .knownFollowers: 159 + if let viewer = auth.userDID { 160 + let response = try await client.getKnownFollowers(actor: did, viewer: viewer, auth: auth.authContext()) 161 + items = (response.items ?? []).map { FollowListItem(from: $0) } 162 + totalCount = items.count 163 + } 153 164 } 154 165 } catch { 155 166 // keep existing items on error ··· 180 191 items.append(contentsOf: filtered.map { FollowListItem(from: $0) }) 181 192 } 182 193 cursor = response.cursor 194 + case .knownFollowers: 195 + break // No pagination for known followers 183 196 } 184 197 } catch { 185 198 // silently fail
+54
Grain/Views/Profile/ProfileView.swift
··· 103 103 .frame(maxWidth: .infinity, alignment: .leading) 104 104 .padding(.horizontal) 105 105 106 + // Known followers 107 + if !viewModel.knownFollowers.isEmpty && did != auth.userDID { 108 + NavigationLink { 109 + FollowListView(client: client, did: did, mode: .knownFollowers) 110 + } label: { 111 + knownFollowersRow 112 + } 113 + .buttonStyle(.plain) 114 + } 115 + 106 116 // Follow + Germ DM buttons 107 117 if did != auth.userDID { 108 118 HStack(spacing: 8) { ··· 286 296 deletedGalleryUri = nil 287 297 } 288 298 } 299 + } 300 + 301 + @ViewBuilder 302 + private var knownFollowersRow: some View { 303 + let followers = viewModel.knownFollowers 304 + let displayCount = max(followers.count, 0) 305 + let avatars = Array(followers.prefix(3)) 306 + let names = followers.prefix(2).compactMap { f -> String? in 307 + if let name = f.displayName, !name.isEmpty { return name } 308 + return f.handle 309 + } 310 + let othersCount = displayCount - names.count 311 + 312 + HStack(spacing: 6) { 313 + // Overlapping avatars 314 + HStack(spacing: -8) { 315 + ForEach(Array(avatars.enumerated()), id: \.element.did) { index, follower in 316 + AvatarView(url: follower.avatar, size: 24) 317 + .background(Circle().fill(Color(.systemBackground))) 318 + .overlay(Circle().stroke(Color(.systemBackground), lineWidth: 2)) 319 + .zIndex(Double(3 - index)) 320 + } 321 + } 322 + 323 + // "Followed by X, Y and Z others" text 324 + Group { 325 + if names.count == 1 && othersCount == 0 { 326 + Text("Followed by **\(names[0])**") 327 + } else if names.count == 2 && othersCount == 0 { 328 + Text("Followed by **\(names[0])** and **\(names[1])**") 329 + } else if names.count == 1 && othersCount > 0 { 330 + Text("Followed by **\(names[0])** and \(othersCount) \(othersCount == 1 ? "other" : "others") you follow") 331 + } else if names.count >= 2 && othersCount > 0 { 332 + Text("Followed by **\(names[0])**, **\(names[1])** and \(othersCount) \(othersCount == 1 ? "other" : "others") you follow") 333 + } else { 334 + Text("Followed by \(displayCount) you follow") 335 + } 336 + } 337 + .font(.caption) 338 + .foregroundStyle(.secondary) 339 + .lineLimit(2) 340 + } 341 + .frame(maxWidth: .infinity, alignment: .leading) 342 + .padding(.horizontal) 289 343 } 290 344 291 345 @ViewBuilder