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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
import Foundation
import OSLog
import UIKit
private let profileLogger = Logger(subsystem: "social.grain.grain", category: "Profile")
@Observable
@MainActor
final class ProfileDetailViewModel {
var profile: GrainProfileDetailed?
var galleries: [GrainGallery] = []
var stories: [GrainStory] = []
var archivedStories: [GrainStory] = []
var favoriteGalleries: [GrainGallery] = []
var knownFollowers: [FollowerItem] = []
var isLoading = false
var error: Error?
var showReauthAlert = false
/// Set to `true` only after the first favorites network fetch completes.
/// Views should use this (not `favoriteGalleries.isEmpty`) to decide between
/// "loading" and "empty" — an empty array before the server has answered is
/// not the same as a confirmed empty list.
var favoritesLoaded = false
var isLoadingFavorites = false
var favoritesError: Error?
/// URIs whose thumb a HEAD probe has confirmed is gone (404/410) — usually
/// dangling refs to since-deleted blobs. `visibleFavorites` filters these
/// out at render time so they vanish without a "deleted" placeholder, and
/// `hydratedFavorites` strips them on the next load so they stay gone.
var brokenFavoriteUris: Set<String> = []
/// URIs whose thumb has already been probed this session (either confirmed
/// reachable or confirmed broken). Used to skip redundant HEAD requests
/// when new favorite batches come in. Transient probe failures don't add
/// here so they're retried next pass.
var probedFavoriteUris: Set<String> = []
/// Favorites currently safe to render — drops anything a probe has
/// confirmed broken. `favoriteGalleries` keeps the raw list so broken
/// items can be retried if they come back; this computed view is what the
/// grid iterates.
var visibleFavorites: [GrainGallery] {
favoriteGalleries.filter { !brokenFavoriteUris.contains($0.uri) }
}
private var galleryCursor: String?
private(set) var hasMoreGalleries = true
private var archiveCursor: String?
private var hasMoreArchive = true
private var archiveLoaded = false
private var favoritesCursor: String?
private(set) var hasMoreFavorites = true
private let client: XRPCClient
/// Max favorites persisted to disk. Enough for an instant top-of-list on
/// re-open without bloating the cache.
private static let favoritesDiskCacheLimit = 30
init(client: XRPCClient) {
self.client = client
}
func load(did: String, viewer: String? = nil, auth: AuthContext? = nil) async {
isLoading = true
error = nil
favoritesLoaded = false
archiveLoaded = false
let isOwnProfile = viewer != nil && viewer == did
if isOwnProfile, favoriteGalleries.isEmpty {
let cached = FeedCache.shared.load(key: Self.favoritesCacheKey(did: did))
let hydrated = hydratedFavorites(cached)
if !hydrated.isEmpty {
favoriteGalleries = hydrated
}
}
async let profileFetch = client.getActorProfile(actor: did, auth: auth)
async let feedFetch = client.getFeed(feed: "actor", actor: did, auth: auth)
async let storiesFetch = client.getStories(actor: did, auth: auth)
async let knownFollowersFetch: [FollowerItem] = {
guard let viewer, viewer != did else { return [] }
let response = try? await client.getKnownFollowers(actor: did, auth: auth)
return response?.items ?? []
}()
// Await every `async let` binding in declaration order with no early
// throw. `async let` child tasks are allocated on a stack and must be
// torn down in reverse (LIFO) order; if `try await profileFetch` threw
// before the later bindings were awaited, their tasks got finished out
// of allocation order, tripping the Swift task allocator and aborting
// the app (SIGABRT in asyncLet_finish_after_task_completion — App Store
// crash at ProfileView.swift:487, builds 53/56). Capturing each result
// first guarantees all four tasks are awaited in-scope before teardown.
let profileResult: GrainProfileDetailed?
do {
profileResult = try await profileFetch
} catch {
self.error = error
profileResult = nil
}
// Publish the header the moment its own fetch lands rather than gating
// it on the feed/stories/known-followers requests. Purely an assignment
// — the await order below is untouched, per the note above.
if let profileResult {
profile = profileResult
}
let feedResult = try? await feedFetch
let storiesResult = try? await storiesFetch
let knownResult = await knownFollowersFetch
if profileResult != nil {
galleries = feedResult?.items ?? []
galleryCursor = feedResult?.cursor
hasMoreGalleries = feedResult?.cursor != nil
stories = storiesResult?.stories ?? []
knownFollowers = knownResult
}
isLoading = false
if isOwnProfile {
Task { await self.loadFavorites(did: did, auth: auth) }
}
}
func loadMoreGalleries(did: String, auth: AuthContext? = nil) async {
guard !isLoading, hasMoreGalleries, let cursor = galleryCursor else { return }
isLoading = true
do {
let response = try await client.getFeed(feed: "actor", cursor: cursor, actor: did, auth: auth)
galleries.append(contentsOf: response.items ?? [])
galleryCursor = response.cursor
hasMoreGalleries = response.cursor != nil
} catch {
self.error = error
}
isLoading = false
}
func loadStoryArchive(did: String, auth: AuthContext? = nil) async {
guard !archiveLoaded else { return }
archiveLoaded = true
do {
let response = try await client.getStoryArchive(actor: did, auth: auth)
archivedStories = response.stories
archiveCursor = response.cursor
hasMoreArchive = response.cursor != nil
} catch {}
}
func loadMoreArchive(did: String, auth: AuthContext? = nil) async {
guard !isLoading, hasMoreArchive, let cursor = archiveCursor else { return }
isLoading = true
do {
let response = try await client.getStoryArchive(actor: did, cursor: cursor, auth: auth)
archivedStories.append(contentsOf: response.stories)
archiveCursor = response.cursor
hasMoreArchive = response.cursor != nil
} catch {}
isLoading = false
}
func loadFavorites(did: String, auth: AuthContext? = nil) async {
guard !favoritesLoaded, !isLoadingFavorites else { return }
isLoadingFavorites = true
favoritesError = nil
profileLogger.info("loadFavorites start did=\(did, privacy: .public) hasAuth=\(auth != nil, privacy: .public)")
do {
let response = try await client.getActorFavorites(actor: did, auth: auth)
favoriteGalleries = hydratedFavorites(response.items ?? [])
favoritesCursor = response.cursor
hasMoreFavorites = response.cursor != nil
let loadedCount = favoriteGalleries.count
profileLogger.info("loadFavorites ok count=\(loadedCount, privacy: .public)")
let toCache = Array(favoriteGalleries.prefix(Self.favoritesDiskCacheLimit))
let key = Self.favoritesCacheKey(did: did)
// Captured before the hop, for the same reason as `FeedViewModel`:
// the detached task would otherwise resolve the owner after the fact.
let owner = AccountScopedStorage.activeAccountID
Task.detached(priority: .utility) {
FeedCache.shared.save(toCache, key: key, did: owner)
}
} catch {
favoritesError = error
profileLogger.error("loadFavorites failed: \(error, privacy: .public)")
}
favoritesLoaded = true
isLoadingFavorites = false
}
private static func favoritesCacheKey(did: String) -> String {
"favorites_\(did)"
}
/// Drops favorites that would render empty: galleries with no photos
/// (deleted or never-populated), galleries whose thumb URL is missing or
/// unparseable (LazyImage can't attempt a load so it'd sit blank forever),
/// and galleries whose thumbnail was already confirmed broken this session
/// (tracked in `brokenFavoriteUris`). Applied on every load path.
private func hydratedFavorites(_ galleries: [GrainGallery]) -> [GrainGallery] {
galleries.filter { gallery in
guard !(gallery.items?.isEmpty ?? true) else { return false }
guard !brokenFavoriteUris.contains(gallery.uri) else { return false }
guard let thumb = gallery.items?.first?.thumb,
!thumb.isEmpty,
URL(string: thumb) != nil
else { return false }
return true
}
}
func loadMoreFavorites(did: String, auth: AuthContext? = nil) async {
guard !isLoading, hasMoreFavorites, let cursor = favoritesCursor else { return }
isLoading = true
do {
let response = try await client.getActorFavorites(actor: did, cursor: cursor, auth: auth)
favoriteGalleries.append(contentsOf: hydratedFavorites(response.items ?? []))
favoritesCursor = response.cursor
hasMoreFavorites = response.cursor != nil
} catch {}
isLoading = false
}
/// Whether the profile content should be hidden due to a block
var isBlockHidden: Bool {
profile?.viewer?.blocking != nil || profile?.viewer?.blockedBy == true
}
func toggleBlock(auth: AuthContext?) async {
guard let profile, let auth else { return }
let prevViewer = profile.viewer
let did = profile.did
if let blockUri = profile.viewer?.blocking {
// Optimistic unblock
self.profile?.viewer?.blocking = nil
do {
try await client.unblockActor(blockUri: blockUri, auth: auth)
} catch {
self.profile?.viewer = prevViewer
}
} else {
// Optimistic block
self.profile?.viewer?.blocking = "pending"
do {
let response = try await client.blockActor(did: did, auth: auth)
self.profile?.viewer?.blocking = response.uri
} catch {
self.profile?.viewer = prevViewer
showReauthAlert = true
}
}
}
func toggleMute(auth: AuthContext?) async {
guard let profile, let auth else { return }
let prevViewer = profile.viewer
let did = profile.did
if profile.viewer?.muted == true {
// Optimistic unmute
self.profile?.viewer?.muted = false
do {
try await client.unmuteActor(did: did, auth: auth)
} catch {
self.profile?.viewer = prevViewer
}
} else {
// Optimistic mute
self.profile?.viewer?.muted = true
do {
try await client.muteActor(did: did, auth: auth)
} catch {
self.profile?.viewer = prevViewer
}
}
}
func toggleFollow(auth: AuthContext?) async {
guard profile != nil, let auth else { return }
// Capture all values before any mutation
let followUri = profile?.viewer?.following
let prevViewer = profile?.viewer
let prevCount = profile?.followersCount
let did = profile!.did
if let followUri {
// Optimistic unfollow
UIImpactFeedbackGenerator(style: .light).impactOccurred()
profile?.viewer?.following = nil
profile?.followersCount = max((prevCount ?? 1) - 1, 0)
let rkey = followUri.split(separator: "/").last.map(String.init) ?? ""
do {
try await client.deleteRecord(collection: "social.grain.graph.follow", rkey: rkey, auth: auth)
} catch {
profile?.viewer = prevViewer
profile?.followersCount = prevCount
}
} else {
// Optimistic follow — preserve existing block/mute state
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
if profile?.viewer == nil {
profile?.viewer = ActorViewerState()
}
profile?.viewer?.following = "pending"
profile?.followersCount = (prevCount ?? 0) + 1
let record = AnyCodable([
"subject": did,
"createdAt": DateFormatting.nowISO(),
])
let repo = TokenStorage.userDID ?? ""
do {
let response = try await client.createRecord(
collection: "social.grain.graph.follow",
repo: repo,
record: record,
auth: auth
)
profile?.viewer?.following = response.uri
} catch {
profile?.viewer = prevViewer
profile?.followersCount = prevCount
}
}
}
}