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: suggested follows carousel in feed

Show "Suggested for you" horizontal card strip after the 5th
gallery in the feed. Cards show avatar, name, bio, follow button,
and dismiss X. Follows and dismissals are session-local.

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

+114 -1
+98
Grain/Views/Components/SuggestedFollowsView.swift
··· 1 + import SwiftUI 2 + 3 + struct SuggestedFollowsView: View { 4 + @Environment(AuthManager.self) private var auth 5 + let client: XRPCClient 6 + @Binding var suggestions: [SuggestedItem] 7 + var onProfileTap: ((String) -> Void)? 8 + @State private var dismissedDids: Set<String> = [] 9 + @State private var followedDids: Set<String> = [] 10 + @State private var followingInProgress: Set<String> = [] 11 + 12 + private var visibleItems: [SuggestedItem] { 13 + suggestions.filter { !dismissedDids.contains($0.did) && !followedDids.contains($0.did) } 14 + } 15 + 16 + var body: some View { 17 + if !visibleItems.isEmpty { 18 + VStack(alignment: .leading, spacing: 10) { 19 + Text("Suggested for you") 20 + .font(.subheadline.weight(.semibold)) 21 + .padding(.horizontal, 12) 22 + 23 + ScrollView(.horizontal, showsIndicators: false) { 24 + HStack(spacing: 12) { 25 + ForEach(visibleItems) { item in 26 + suggestionCard(item) 27 + } 28 + } 29 + .padding(.horizontal, 12) 30 + } 31 + } 32 + .padding(.vertical, 4) 33 + } 34 + } 35 + 36 + private func suggestionCard(_ item: SuggestedItem) -> some View { 37 + VStack(spacing: 10) { 38 + AvatarView(url: item.avatar, size: 64) 39 + .onTapGesture { onProfileTap?(item.did) } 40 + 41 + Text(item.displayName ?? item.handle ?? "") 42 + .font(.subheadline.weight(.semibold)) 43 + .lineLimit(1) 44 + 45 + Text(item.description ?? "") 46 + .font(.caption) 47 + .foregroundStyle(.secondary) 48 + .lineLimit(2) 49 + .multilineTextAlignment(.center) 50 + .frame(height: 32) 51 + 52 + Button { 53 + followUser(item) 54 + } label: { 55 + Text("Follow") 56 + .font(.subheadline.weight(.semibold)) 57 + .frame(maxWidth: .infinity) 58 + } 59 + .buttonStyle(.borderedProminent) 60 + .disabled(followingInProgress.contains(item.did)) 61 + } 62 + .frame(width: 150) 63 + .padding(.vertical, 14) 64 + .padding(.horizontal, 10) 65 + .background { 66 + RoundedRectangle(cornerRadius: 12) 67 + .fill(.secondary.opacity(0.1)) 68 + } 69 + .overlay(alignment: .topTrailing) { 70 + Button { 71 + withAnimation { _ = dismissedDids.insert(item.did) } 72 + } label: { 73 + Image(systemName: "xmark") 74 + .font(.caption2.weight(.bold)) 75 + .foregroundStyle(.secondary) 76 + .frame(width: 24, height: 24) 77 + } 78 + .padding(6) 79 + } 80 + } 81 + 82 + private func followUser(_ item: SuggestedItem) { 83 + guard let authContext = auth.authContext() else { return } 84 + followingInProgress.insert(item.did) 85 + Task { 86 + let record = AnyCodable([ 87 + "subject": item.did, 88 + "createdAt": DateFormatting.nowISO() 89 + ]) 90 + let repo = TokenStorage.userDID ?? "" 91 + do { 92 + _ = try await client.createRecord(collection: "social.grain.graph.follow", repo: repo, record: record, auth: authContext) 93 + withAnimation { _ = followedDids.insert(item.did) } 94 + } catch {} 95 + followingInProgress.remove(item.did) 96 + } 97 + } 98 + }
+16 -1
Grain/Views/Feed/FeedView.swift
··· 210 210 @State private var deletedGalleryUri: String? 211 211 @State private var zoomState = ImageZoomState() 212 212 @State private var cardStoryAuthor: GrainStoryAuthor? 213 + @State private var suggestedFollows: [SuggestedItem] = [] 214 + @State private var suggestedLoaded = false 213 215 let client: XRPCClient 214 216 let storyAuthors: [GrainStoryAuthor] 215 217 let userAvatar: String? ··· 240 242 onCreateTap: onStoryCreateTap 241 243 ) 242 244 243 - ForEach($viewModel.galleries) { $gallery in 245 + ForEach(Array($viewModel.galleries.enumerated()), id: \.element.id) { index, $gallery in 244 246 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 245 247 selectedUri = gallery.uri 246 248 }, onProfileTap: { did in ··· 257 259 Task { await viewModel.loadMore(auth: auth.authContext()) } 258 260 } 259 261 } 262 + 263 + if index == 4 { 264 + SuggestedFollowsView(client: client, suggestions: $suggestedFollows, onProfileTap: { did in 265 + selectedProfileDid = did 266 + }) 267 + } 260 268 } 261 269 262 270 if viewModel.isLoading { ··· 301 309 .task { 302 310 if viewModel.galleries.isEmpty { 303 311 await viewModel.loadInitial(auth: auth.authContext()) 312 + } 313 + if !suggestedLoaded, let did = auth.userDID { 314 + do { 315 + let response = try await client.getSuggestedFollows(actor: did, auth: auth.authContext()) 316 + suggestedFollows = response.items ?? [] 317 + } catch {} 318 + suggestedLoaded = true 304 319 } 305 320 } 306 321 .onChange(of: deletedGalleryUri) { _, uri in