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.

fix: proactive token refresh and auto-refresh feed on foreground return

Auth tokens now refresh on foreground return if expiring within 60s,
fixing silent failures (e.g. favorites) after backgrounding. Feed also
auto-refreshes if the app has been inactive for 5+ minutes.

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

+19
+6
Grain/API/AuthManager.swift
··· 133 133 await fetchAndStoreAvatar() 134 134 } 135 135 136 + /// Refresh the access token only if it expires within 60 seconds. 137 + func refreshIfNeeded() async throws { 138 + guard let expiresAt = TokenStorage.tokenExpiresAt, expiresAt.timeIntervalSinceNow < 60 else { return } 139 + try await refresh() 140 + } 141 + 136 142 /// Refresh the access token using the refresh token. Coalesces concurrent calls. 137 143 func refresh() async throws { 138 144 if let existing = refreshTask {
+12
Grain/Views/Feed/FeedView.swift
··· 217 217 218 218 private struct FeedTabContent: View { 219 219 @Environment(AuthManager.self) private var auth 220 + @Environment(\.scenePhase) private var scenePhase 220 221 @State private var viewModel: FeedViewModel 221 222 @State private var selectedUri: String? 222 223 @State private var selectedProfileDid: String? ··· 227 228 @State private var cardStoryAuthor: GrainStoryAuthor? 228 229 @State private var suggestedFollows: [SuggestedItem] = [] 229 230 @State private var suggestedLoaded = false 231 + @State private var lastLoadTime: Date = .now 230 232 let client: XRPCClient 231 233 let storyAuthors: [GrainStoryAuthor] 232 234 let userAvatar: String? ··· 296 298 async let stories: ()? = onRefresh?() 297 299 async let prefs: () = prefsViewModel.refresh(auth: auth) 298 300 _ = await (feed, stories, prefs) 301 + lastLoadTime = .now 299 302 } 300 303 .navigationDestination(item: $selectedUri) { uri in 301 304 GalleryDetailView(client: client, galleryUri: uri, deletedGalleryUri: $deletedGalleryUri) ··· 324 327 .task { 325 328 if viewModel.galleries.isEmpty { 326 329 await viewModel.loadInitial(auth: auth.authContext()) 330 + lastLoadTime = .now 327 331 } 328 332 if !suggestedLoaded, let did = auth.userDID { 329 333 do { ··· 331 335 suggestedFollows = response.items ?? [] 332 336 } catch {} 333 337 suggestedLoaded = true 338 + } 339 + } 340 + .onChange(of: scenePhase) { 341 + if scenePhase == .active, Date.now.timeIntervalSince(lastLoadTime) > 300 { 342 + Task { 343 + await viewModel.loadInitial(auth: auth.authContext()) 344 + lastLoadTime = .now 345 + } 334 346 } 335 347 } 336 348 .onChange(of: deletedGalleryUri) { _, uri in
+1
Grain/Views/MainTabView.swift
··· 94 94 .onChange(of: scenePhase) { 95 95 if scenePhase == .active { 96 96 Task { 97 + try? await auth.refreshIfNeeded() 97 98 await notificationsVM.fetchUnseenCount(auth: auth.authContext()) 98 99 await labelDefsCache.loadIfNeeded(client: client, auth: auth.authContext()) 99 100 }