1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import SwiftUI
struct SuggestedFollowsView: View {
@Environment(AuthManager.self) private var auth
let client: XRPCClient
@Binding var suggestions: [SuggestedItem]
var onProfileTap: ((String) -> Void)?
@State private var dismissedDids: Set<String> = []
@State private var followedDids: Set<String> = []
@State private var followingInProgress: Set<String> = []
private var visibleItems: [SuggestedItem] {
suggestions.filter { !dismissedDids.contains($0.did) && !followedDids.contains($0.did) }
}
var body: some View {
if !visibleItems.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Suggested for you")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 12)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(visibleItems) { item in
suggestionCard(item)
}
}
.padding(.horizontal, 12)
}
}
.padding(.vertical, 4)
}
}
private func suggestionCard(_ item: SuggestedItem) -> some View {
VStack(spacing: 10) {
AvatarView(url: item.avatar, size: 64)
.onTapGesture { onProfileTap?(item.did) }
Text(item.displayName ?? item.handle ?? "")
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(item.description ?? "")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.multilineTextAlignment(.center)
.frame(height: 32)
Button {
followUser(item)
} label: {
Text("Follow")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(followingInProgress.contains(item.did))
}
.frame(width: 150)
.padding(.vertical, 14)
.padding(.horizontal, 10)
.background {
RoundedRectangle(cornerRadius: 12)
.fill(.secondary.opacity(0.1))
}
.overlay(alignment: .topTrailing) {
Button {
withAnimation { _ = dismissedDids.insert(item.did) }
} label: {
Image(systemName: "xmark")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
.frame(width: 24, height: 24)
}
.padding(6)
}
}
private func followUser(_ item: SuggestedItem) {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
followingInProgress.insert(item.did)
Task {
guard let authContext = await auth.authContext() else { return }
let record = AnyCodable([
"subject": item.did,
"createdAt": DateFormatting.nowISO(),
])
let repo = TokenStorage.userDID ?? ""
do {
_ = try await client.createRecord(collection: "social.grain.graph.follow", repo: repo, record: record, auth: authContext)
withAnimation { _ = followedDids.insert(item.did) }
} catch {}
followingInProgress.remove(item.did)
}
}
}
#Preview {
@Previewable @State var suggestions = [
SuggestedItem(did: "did:plc:a", handle: "alice.grain.social", displayName: "Alice", description: "Landscape & travel photographer based in SF.", followersCount: 1240),
SuggestedItem(did: "did:plc:b", handle: "bob.grain.social", displayName: "Bob", description: "Street photography from Tokyo.", followersCount: 873),
SuggestedItem(did: "did:plc:c", handle: "cara.grain.social", displayName: "Cara", description: "Film photographer. Mostly medium format.", followersCount: 4200),
SuggestedItem(did: "did:plc:d", handle: "david.grain.social", displayName: "David", description: "Documentary work and long-form essays.", followersCount: 310),
SuggestedItem(did: "did:plc:e", handle: "elena.grain.social", displayName: "Elena", description: "Portrait & editorial. Available for hire.", followersCount: 6700),
SuggestedItem(did: "did:plc:f", handle: "felix.grain.social", displayName: "Felix", description: "Night sky and astrophotography.", followersCount: 520),
SuggestedItem(did: "did:plc:g", handle: "grace.grain.social", displayName: "Grace", description: "Color theory nerd. Fuji only.", followersCount: 2100),
]
SuggestedFollowsView(
client: .preview,
suggestions: $suggestions
)
.previewEnvironments()
.preferredColorScheme(.dark)
.tint(Color.accentColor)
}