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.

perf: disk-cache profile favorites and use a real loaded sentinel

Prime `favoriteGalleries` synchronously from `FeedCache` on own-profile load
so the favorites tab shows instantly on re-open, then kick off a background
`loadFavorites` at the tail of `load()` so fresh data is ready before the
user swipes over.

Split the favorites state into two flags:

- `favoritesLoaded` is now set only *after* the network call completes,
not speculatively before it. Views can trust it as "we've actually
talked to the server."
- `isLoadingFavorites` handles the concurrent-fetch guard that the old
`favoritesLoaded = true` line was doing.

`favoritesGrid` uses the new flag to show a spinner until confirmed empty,
so "No favorites yet" only appears after a real round-trip. The cache
writes the first 30 items to the shared `FeedCache` under a
`favorites_<did>` key via a detached utility task; this is independent of
the Settings "Clear Image Cache" button, which only touches Nuke.

authored by

Hima Aramona and committed by
Chad Miller
89b1a11c 29f5b254

+56 -14
+37 -5
Grain/ViewModels/ProfileDetailViewModel.swift
··· 13 13 var isLoading = false 14 14 var error: Error? 15 15 var showReauthAlert = false 16 + /// Set to `true` only after the first favorites network fetch completes. 17 + /// Views should use this (not `favoriteGalleries.isEmpty`) to decide between 18 + /// "loading" and "empty" — an empty array before the server has answered is 19 + /// not the same as a confirmed empty list. 20 + var favoritesLoaded = false 21 + var isLoadingFavorites = false 16 22 17 23 private var galleryCursor: String? 18 24 private var hasMoreGalleries = true ··· 21 27 private var archiveLoaded = false 22 28 private var favoritesCursor: String? 23 29 private var hasMoreFavorites = true 24 - private var favoritesLoaded = false 25 30 private let client: XRPCClient 31 + 32 + /// Max favorites persisted to disk. Enough for an instant top-of-list on 33 + /// re-open without bloating the cache. 34 + private static let favoritesDiskCacheLimit = 30 26 35 27 36 init(client: XRPCClient) { 28 37 self.client = client ··· 31 40 func load(did: String, viewer: String? = nil, auth: AuthContext? = nil) async { 32 41 isLoading = true 33 42 error = nil 43 + favoritesLoaded = false 44 + archiveLoaded = false 45 + 46 + let isOwnProfile = viewer != nil && viewer == did 47 + if isOwnProfile, favoriteGalleries.isEmpty { 48 + let cached = FeedCache.shared.load(key: Self.favoritesCacheKey(did: did)) 49 + if !cached.isEmpty { 50 + favoriteGalleries = cached 51 + } 52 + } 34 53 35 54 do { 36 55 async let profileFetch = client.getActorProfile(actor: did, viewer: viewer, auth: auth) ··· 51 70 hasMoreGalleries = feedResult.cursor != nil 52 71 stories = storiesResult.stories 53 72 knownFollowers = await knownFollowersFetch 54 - favoritesLoaded = false 55 - archiveLoaded = false 56 73 } catch { 57 74 self.error = error 58 75 } 59 76 isLoading = false 77 + 78 + if isOwnProfile { 79 + Task { await self.loadFavorites(did: did, auth: auth) } 80 + } 60 81 } 61 82 62 83 func loadMoreGalleries(did: String, auth: AuthContext? = nil) async { ··· 98 119 } 99 120 100 121 func loadFavorites(did: String, auth: AuthContext? = nil) async { 101 - guard !favoritesLoaded else { return } 102 - favoritesLoaded = true 122 + guard !favoritesLoaded, !isLoadingFavorites else { return } 123 + isLoadingFavorites = true 103 124 do { 104 125 let response = try await client.getActorFavorites(actor: did, auth: auth) 105 126 favoriteGalleries = response.items ?? [] 106 127 favoritesCursor = response.cursor 107 128 hasMoreFavorites = response.cursor != nil 129 + let toCache = Array(favoriteGalleries.prefix(Self.favoritesDiskCacheLimit)) 130 + let key = Self.favoritesCacheKey(did: did) 131 + Task.detached(priority: .utility) { 132 + FeedCache.shared.save(toCache, key: key) 133 + } 108 134 } catch {} 135 + favoritesLoaded = true 136 + isLoadingFavorites = false 137 + } 138 + 139 + private static func favoritesCacheKey(did: String) -> String { 140 + "favorites_\(did)" 109 141 } 110 142 111 143 func loadMoreFavorites(did: String, auth: AuthContext? = nil) async {
+19 -9
Grain/Views/Profile/ProfileView.swift
··· 19 19 @State private var showStoryCreate = false 20 20 @State private var showAvatarOverlay = false 21 21 @State private var viewModel: ProfileDetailViewModel 22 - @State private var selectedGalleryUri: String? 22 + @State private var selectedGallery: ProfileGallerySelection? 23 23 @State private var selectedProfileDid: String? 24 24 @State private var selectedHashtag: String? 25 25 @State private var deletedGalleryUri: String? ··· 383 383 } 384 384 } 385 385 } 386 - .navigationDestination(item: $selectedGalleryUri) { uri in 387 - ProfileGalleryFeedView(viewModel: viewModel, client: client, did: did, initialUri: uri) 388 - .navigationTransition(.zoom(sourceID: uri, in: galleryZoomNS)) 386 + .navigationDestination(item: $selectedGallery) { selection in 387 + ProfileGalleryFeedView( 388 + viewModel: viewModel, 389 + client: client, 390 + did: did, 391 + initialUri: selection.uri, 392 + source: selection.source 393 + ) 394 + .navigationTransition(.zoom(sourceID: selection.uri, in: galleryZoomNS)) 389 395 } 390 396 .navigationDestination(item: $selectedProfileDid) { did in 391 397 ProfileView(client: client, did: did) ··· 680 686 ], spacing: 2) { 681 687 ForEach(viewModel.galleries) { gallery in 682 688 Button { 683 - selectedGalleryUri = nil 689 + selectedGallery = nil 684 690 DispatchQueue.main.async { 685 - selectedGalleryUri = gallery.uri 691 + selectedGallery = ProfileGallerySelection(uri: gallery.uri, source: .galleries) 686 692 } 687 693 } label: { 688 694 Color.clear ··· 793 799 794 800 @ViewBuilder 795 801 private var favoritesGrid: some View { 796 - if viewModel.favoriteGalleries.isEmpty, !viewModel.isLoading { 802 + if viewModel.favoriteGalleries.isEmpty, !viewModel.favoritesLoaded { 803 + ProgressView() 804 + .frame(maxWidth: .infinity) 805 + .padding(.top, 60) 806 + } else if viewModel.favoriteGalleries.isEmpty { 797 807 Text("No favorites yet") 798 808 .font(.subheadline) 799 809 .foregroundStyle(.tertiary) ··· 807 817 ], spacing: 2) { 808 818 ForEach(viewModel.favoriteGalleries) { gallery in 809 819 Button { 810 - selectedGalleryUri = nil 820 + selectedGallery = nil 811 821 DispatchQueue.main.async { 812 - selectedGalleryUri = gallery.uri 822 + selectedGallery = ProfileGallerySelection(uri: gallery.uri, source: .favorites) 813 823 } 814 824 } label: { 815 825 Color.clear