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: instrument cold start and disk-cache first-page feed

Adds AppLaunch OSSignposter intervals/events across the tab/feed/
search/notifications cold path so Instruments can see where startup
time lands. Also:

- FeedCache: synchronous JSON-on-disk cache for the first page of a
pinned feed, keyed by feed id. FeedViewModel reads it in init and
writes back after a successful fetch, so the feed renders real
content on first body eval instead of an empty state that fills in
a few frames later.
- hasFetchedInitial: new flag on FeedViewModel so FeedTabContent
still runs a fresh network fetch on first appear even when the
disk cache pre-populated galleries.
- Background cleanup (viewedStories + storyStatusCache.purgeExpired)
moves out of GrainApp.scenePhase and into MainTabView's scenePhase
observer — MainTabView is the only place they're actually needed
and GrainApp was paying for an extra scenePhase observer on every
app.

authored by

Hima Aramona and committed by
Chad Miller
728a2f6b 1fa23d4a

+128 -14
+2 -9
Grain/GrainApp.swift
··· 25 25 } 26 26 27 27 @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate 28 - @Environment(\.scenePhase) private var scenePhase 29 28 @State private var authManager = AuthManager() 30 29 @State private var pushManager = PushManager() 31 30 @State private var storyStatusCache = StoryStatusCache() ··· 35 34 36 35 var body: some Scene { 37 36 WindowGroup { 37 + let _ = appSignposter.emitEvent("WindowGroupBodyBegin") 38 38 Group { 39 39 if authManager.isAuthenticated { 40 40 MainTabView(pendingDeepLink: $pendingDeepLink) ··· 45 45 .environment(labelDefsCache) 46 46 .tint(Color("AccentColor")) 47 47 .onAppear { 48 + appSignposter.emitEvent("WindowOnAppear") 48 49 Task { 49 50 viewedStoryStorage.cleanup() 50 51 storyStatusCache.purgeExpired() ··· 68 69 .onOpenURL { url in 69 70 if let deepLink = DeepLink.from(url: url) { 70 71 pendingDeepLink = deepLink 71 - } 72 - } 73 - .onChange(of: scenePhase) { 74 - if scenePhase == .background { 75 - Task { 76 - viewedStoryStorage.cleanup() 77 - storyStatusCache.purgeExpired() 78 - } 79 72 } 80 73 } 81 74 }
+42
Grain/Utilities/FeedCache.swift
··· 1 + import Foundation 2 + 3 + /// Synchronous disk cache for the first-page feed response. 4 + /// 5 + /// Allows `FeedViewModel.init()` to pre-populate `galleries` before the first 6 + /// SwiftUI body evaluation, so the feed renders with real content immediately 7 + /// while the background network refresh runs. 8 + final class FeedCache: @unchecked Sendable { 9 + static let shared = FeedCache() 10 + 11 + private let directory: URL 12 + 13 + private init() { 14 + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] 15 + directory = caches.appendingPathComponent("grain_feed_cache", isDirectory: true) 16 + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) 17 + } 18 + 19 + /// Load cached galleries for `key`. Returns `[]` if no cache exists or decode fails. 20 + func load(key: String) -> [GrainGallery] { 21 + guard let data = try? Data(contentsOf: fileURL(for: key)), 22 + let galleries = try? JSONDecoder().decode([GrainGallery].self, from: data) 23 + else { return [] } 24 + return galleries 25 + } 26 + 27 + /// Persist `galleries` to disk for `key`. No-ops on empty arrays. 28 + func save(_ galleries: [GrainGallery], key: String) { 29 + guard !galleries.isEmpty, 30 + let data = try? JSONEncoder().encode(galleries) 31 + else { return } 32 + try? data.write(to: fileURL(for: key), options: .atomic) 33 + } 34 + 35 + private func fileURL(for key: String) -> URL { 36 + let safe = key 37 + .replacingOccurrences(of: "/", with: "-") 38 + .replacingOccurrences(of: ":", with: "-") 39 + .replacingOccurrences(of: " ", with: "_") 40 + return directory.appendingPathComponent("\(safe).json") 41 + } 42 + }
+20 -2
Grain/ViewModels/FeedViewModel.swift
··· 6 6 var galleries: [GrainGallery] = [] 7 7 var isLoading = false 8 8 var error: Error? 9 + /// Set to `true` after the first network fetch completes (success or failure). 10 + /// Used by FeedTabContent to always run a fresh fetch on first appear, even when 11 + /// galleries are pre-populated from the disk cache. 12 + var hasFetchedInitial = false 9 13 10 14 private var cursor: String? 11 15 private var hasMore = true ··· 16 20 private let camera: String? 17 21 private let location: String? 18 22 private let tag: String? 23 + private let cacheKey: String? 19 24 20 25 init( 21 26 client: XRPCClient, ··· 23 28 actor: String? = nil, 24 29 camera: String? = nil, 25 30 location: String? = nil, 26 - tag: String? = nil 31 + tag: String? = nil, 32 + cacheKey: String? = nil 27 33 ) { 28 34 self.client = client 29 35 self.feedName = feedName ··· 31 37 self.camera = camera 32 38 self.location = location 33 39 self.tag = tag 40 + self.cacheKey = cacheKey 41 + if let cacheKey { 42 + galleries = FeedCache.shared.load(key: cacheKey) 43 + } 34 44 } 35 45 36 46 convenience init(client: XRPCClient, pinnedFeed: PinnedFeed, userDID: String? = nil) { ··· 40 50 actor: (pinnedFeed.id == "following" || pinnedFeed.id == "foryou") ? userDID : nil, 41 51 camera: pinnedFeed.type == "camera" ? pinnedFeed.feedValue : nil, 42 52 location: pinnedFeed.type == "location" ? pinnedFeed.feedValue : nil, 43 - tag: pinnedFeed.type == "hashtag" ? pinnedFeed.feedValue : nil 53 + tag: pinnedFeed.type == "hashtag" ? pinnedFeed.feedValue : nil, 54 + cacheKey: pinnedFeed.id 44 55 ) 45 56 } 46 57 ··· 65 76 galleries = response.items ?? [] 66 77 cursor = response.cursor 67 78 hasMore = response.cursor != nil 79 + if let key = cacheKey { 80 + let toCache = galleries 81 + Task.detached(priority: .utility) { 82 + FeedCache.shared.save(toCache, key: key) 83 + } 84 + } 68 85 } catch { 69 86 guard !Task.isCancelled else { return } 70 87 self.error = error 71 88 } 72 89 isLoading = false 90 + hasFetchedInitial = true 73 91 } 74 92 loadTask = task 75 93 await task.value
+27 -2
Grain/Views/Feed/FeedView.swift
··· 3 3 import SwiftUI 4 4 5 5 private let fvLogger = Logger(subsystem: "social.grain.grain", category: "FeedView") 6 + private let feedLaunchSignposter = OSSignposter(subsystem: "social.grain.grain", category: "AppLaunch") 6 7 7 8 struct FeedView: View { 8 9 @Environment(AuthManager.self) private var auth ··· 27 28 self.client = client 28 29 _pendingDeepLink = pendingDeepLink 29 30 _showCreate = showCreate 31 + let _spid = feedLaunchSignposter.makeSignpostID() 32 + let _state = feedLaunchSignposter.beginInterval("FeedViewModelInit", id: _spid) 30 33 _prefsViewModel = State(initialValue: FeedPreferencesViewModel(client: client)) 31 34 _storyViewModel = State(initialValue: StoryStripViewModel(client: client)) 35 + feedLaunchSignposter.endInterval("FeedViewModelInit", _state) 32 36 } 33 37 34 38 var body: some View { 39 + let _ = feedLaunchSignposter.emitEvent("FeedViewBodyBegin") 35 40 let storySortVersion = storyViewModel.version 36 41 let _ = fvLogger.info("[body] eval storyViewerDid=\(storyViewerDid ?? "nil") authors.count=\(storyViewModel.authors.count) version=\(storySortVersion)") 37 42 NavigationStack { ··· 72 77 } 73 78 .task { 74 79 guard !isPreview else { return } 80 + let _spid = feedLaunchSignposter.makeSignpostID() 81 + let _state = feedLaunchSignposter.beginInterval("FeedPrefsLoad", id: _spid) 75 82 await prefsViewModel.loadIfNeeded(auth: auth.authContext()) 83 + feedLaunchSignposter.endInterval("FeedPrefsLoad", _state) 76 84 await storyViewModel.load(auth: auth.authContext(), storyStatusCache: storyStatusCache) 77 85 } 78 86 .onAppear { ··· 277 285 @State private var deletedGalleryUri: String? 278 286 @State private var zoomState = ImageZoomState() 279 287 @State private var cardStoryAuthor: GrainStoryAuthor? 288 + @State private var avatarOverlayURL: String? 280 289 @State private var commentSheetUri: String? 281 290 @State private var reportGallery: GrainGallery? 282 291 @State private var deleteGalleryUri: String? ··· 328 337 sortVersion: storySortVersion, 329 338 onAuthorTap: onStoryAuthorTap, 330 339 onAuthorLongPress: { did in selectedProfileDid = did }, 340 + onViewPhoto: { url in avatarOverlayURL = url }, 331 341 onCreateTap: onStoryCreateTap 332 342 ) 333 343 ··· 414 424 ) 415 425 .environment(auth) 416 426 } 427 + .fullScreenCover(isPresented: Binding( 428 + get: { avatarOverlayURL != nil }, 429 + set: { if !$0 { avatarOverlayURL = nil } } 430 + )) { 431 + if let url = avatarOverlayURL { 432 + AvatarOverlay(url: url) { avatarOverlayURL = nil } 433 + } 434 + } 417 435 .sheet(isPresented: Binding( 418 436 get: { commentSheetUri != nil }, 419 437 set: { if !$0 { commentSheetUri = nil } } ··· 469 487 #endif 470 488 return 471 489 } 472 - if viewModel.galleries.isEmpty { 473 - await viewModel.loadInitial(auth: auth.authContext()) 490 + feedLaunchSignposter.emitEvent("FeedTaskBegin") 491 + if !viewModel.hasFetchedInitial { 492 + let _authCtx = await auth.authContext() 493 + feedLaunchSignposter.emitEvent("FeedAuthResolved") 494 + let _spid = feedLaunchSignposter.makeSignpostID() 495 + let _state = feedLaunchSignposter.beginInterval("FeedInitialLoad", id: _spid) 496 + await viewModel.loadInitial(auth: _authCtx) 497 + feedLaunchSignposter.endInterval("FeedInitialLoad", _state) 498 + feedLaunchSignposter.emitEvent("FeedGalleriesReady") 474 499 lastLoadTime = .now 475 500 } 476 501 if showSuggestedUsers, !suggestedLoaded, let did = auth.userDID {
+10
Grain/Views/MainTabView.swift
··· 24 24 @Binding var pendingDeepLink: DeepLink? 25 25 26 26 @MainActor static let badgeAppearanceConfigured: Bool = MainActor.assumeIsolated { 27 + let _spid = launchSignposter.makeSignpostID() 28 + let _state = launchSignposter.beginInterval("BadgeAppearanceSetup", id: _spid) 27 29 let color = UIColor(named: "AccentColor") 28 30 let textAttrs: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.white] 29 31 let appearance = UITabBarAppearance() ··· 38 40 apply(appearance.compactInlineLayoutAppearance) 39 41 UITabBar.appearance().standardAppearance = appearance 40 42 UITabBar.appearance().scrollEdgeAppearance = appearance 43 + launchSignposter.endInterval("BadgeAppearanceSetup", _state) 41 44 return true 42 45 } 43 46 44 47 var body: some View { 48 + let _ = launchSignposter.emitEvent("MainTabViewBodyBegin") 45 49 let _ = Self.badgeAppearanceConfigured 50 + let _ = launchSignposter.emitEvent("TabViewBodyBegin") 46 51 TabView(selection: $selectedTab) { 47 52 Tab("Feed", systemImage: "photo.on.rectangle", value: AppTab.feed) { 48 53 FeedView(client: client, pendingDeepLink: $pendingDeepLink, showCreate: $showCreate) ··· 144 149 try? await auth.refreshIfNeeded() 145 150 await notificationsVM.fetchUnseenCount(auth: auth.authContext()) 146 151 await labelDefsCache.loadIfNeeded(client: client, auth: auth.authContext()) 152 + } 153 + } else if scenePhase == .background { 154 + Task { 155 + viewedStories.cleanup() 156 + storyStatusCache.purgeExpired() 147 157 } 148 158 } 149 159 }
+5
Grain/Views/Notifications/NotificationsView.swift
··· 1 1 import Nuke 2 + import os 2 3 import SwiftUI 4 + 5 + private let notificationsLaunchSignposter = OSSignposter(subsystem: "social.grain.grain", category: "AppLaunch") 3 6 4 7 struct NotificationsView: View { 5 8 @Environment(AuthManager.self) private var auth ··· 14 17 init(client: XRPCClient, viewModel: NotificationsViewModel) { 15 18 self.client = client 16 19 self.viewModel = viewModel 20 + notificationsLaunchSignposter.emitEvent("NotificationsViewInit") 17 21 } 18 22 19 23 var body: some View { 24 + let _ = notificationsLaunchSignposter.emitEvent("NotificationsViewBodyBegin") 20 25 NavigationStack { 21 26 NotificationListContent( 22 27 viewModel: viewModel,
+22 -1
Grain/Views/Search/SearchView.swift
··· 1 + import os 1 2 import SwiftUI 3 + 4 + private let searchLaunchSignposter = OSSignposter(subsystem: "social.grain.grain", category: "AppLaunch") 2 5 3 6 struct SearchView: View { 4 7 @Environment(AuthManager.self) private var auth ··· 16 19 @State private var reportGallery: GrainGallery? 17 20 @State private var deleteGalleryUri: String? 18 21 @State private var showDeleteConfirmation = false 19 - @State private var recentSearches = RecentSearchStorage() 22 + @State private var recentSearches: RecentSearchStorage 20 23 @State private var searchIsPresented = false 21 24 let client: XRPCClient 22 25 23 26 init(client: XRPCClient) { 24 27 self.client = client 28 + let _spid = searchLaunchSignposter.makeSignpostID() 29 + let _state = searchLaunchSignposter.beginInterval("SearchViewModelInit", id: _spid) 25 30 _viewModel = State(initialValue: SearchViewModel(client: client)) 31 + _recentSearches = State(initialValue: RecentSearchStorage()) 32 + searchLaunchSignposter.endInterval("SearchViewModelInit", _state) 26 33 } 27 34 28 35 var body: some View { 36 + let _ = searchLaunchSignposter.emitEvent("SearchViewBodyBegin") 29 37 NavigationStack { 30 38 Group { 31 39 if viewModel.searchText.isEmpty { ··· 76 84 ) { 77 85 AvatarView(url: profile.avatar, size: 40) 78 86 } 87 + .profileContextMenu( 88 + handle: profile.handle, 89 + hasStory: storyStatusCache.hasStory(for: profile.did), 90 + onViewProfile: { 91 + recentSearches.addProfile(did: profile.did, displayName: profile.displayName, handle: profile.handle, avatar: profile.avatar) 92 + selectedProfileDid = profile.did 93 + }, 94 + onViewStory: { 95 + if let author = storyStatusCache.author(for: profile.did) { 96 + cardStoryAuthor = author 97 + } 98 + } 99 + ) 79 100 VStack(alignment: .leading) { 80 101 Text(profile.displayName ?? profile.handle ?? "") 81 102 .font(.subheadline.bold())