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: parallel gallery fetch, restore HTTP caching, scope story cleanup to background

- Remove reloadIgnoringLocalCacheData from all XRPC GET requests so
URLSession respects server Cache-Control headers
- Parallelize gallery + comments fetch with async let in GalleryDetailViewModel
- Trigger ViewedStoryStorage.cleanup() on app background (scenePhase)
in addition to the existing startup call
- Add ViewedStoryStorageTests covering cleanup: old author removal,
recent entry preservation, URI cap at 200 after exceeding 500

+39 -7
-1
Grain/API/XRPCClient.swift
··· 52 52 53 53 var request = URLRequest(url: url) 54 54 request.httpMethod = "GET" 55 - request.cachePolicy = .reloadIgnoringLocalCacheData 56 55 57 56 return try await executeWithRetry(request, auth: auth, as: type) 58 57 }
+6
Grain/GrainApp.swift
··· 12 12 } 13 13 14 14 @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate 15 + @Environment(\.scenePhase) private var scenePhase 15 16 @State private var authManager = AuthManager() 16 17 @State private var pushManager = PushManager() 17 18 @State private var storyStatusCache = StoryStatusCache() ··· 51 52 .onOpenURL { url in 52 53 if let deepLink = DeepLink.from(url: url) { 53 54 pendingDeepLink = deepLink 55 + } 56 + } 57 + .onChange(of: scenePhase) { 58 + if scenePhase == .background { 59 + viewedStoryStorage.cleanup() 54 60 } 55 61 } 56 62 }
+7 -6
Grain/ViewModels/GalleryDetailViewModel.swift
··· 21 21 error = nil 22 22 23 23 do { 24 - let galleryResponse = try await client.getGallery(uri: uri, auth: auth) 25 - let commentsResponse = try await client.getGalleryThread(gallery: uri, auth: auth) 26 - gallery = galleryResponse.gallery 27 - comments = commentsResponse.comments 28 - commentCursor = commentsResponse.cursor 29 - hasMoreComments = commentsResponse.cursor != nil 24 + async let galleryResponse = client.getGallery(uri: uri, auth: auth) 25 + async let commentsResponse = client.getGalleryThread(gallery: uri, auth: auth) 26 + let (galleryResult, commentsResult) = try await (galleryResponse, commentsResponse) 27 + gallery = galleryResult.gallery 28 + comments = commentsResult.comments 29 + commentCursor = commentsResult.cursor 30 + hasMoreComments = commentsResult.cursor != nil 30 31 } catch { 31 32 self.error = error 32 33 }
+26
GrainTests/ViewedStoryStorageTests.swift
··· 115 115 let stories: [StubStory] = [] 116 116 XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0) 117 117 } 118 + 119 + // MARK: - cleanup 120 + 121 + func testCleanupRemovesOldAuthorEntries() { 122 + storage.markViewed(uri: "at://story/old", authorDid: "did:plc:old", createdAt: "2020-01-01T12:00:00.000Z") 123 + storage.markViewed(uri: "at://story/new", authorDid: "did:plc:new", createdAt: "2099-01-01T12:00:00.000Z") 124 + storage.cleanup() 125 + XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:old", latestAt: "2020-01-01T12:00:00.000Z")) 126 + XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:new", latestAt: "2099-01-01T12:00:00.000Z")) 127 + } 128 + 129 + func testCleanupPreservesRecentAuthorEntries() { 130 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2099-06-15T12:00:00.000Z") 131 + storage.cleanup() 132 + XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2099-06-15T12:00:00.000Z")) 133 + } 134 + 135 + func testCleanupCapsViewedUrisWhenOver500() { 136 + for i in 0 ..< 600 { 137 + storage.markViewed(uri: "at://story/\(i)", authorDid: "did:plc:alice", createdAt: "2099-01-01T12:00:00.000Z") 138 + } 139 + storage.cleanup() 140 + // After cleanup, total viewed URIs should be capped — at least some should no longer be tracked 141 + let stillViewedCount = (0 ..< 600).count(where: { storage.isViewed(uri: "at://story/\($0)") }) 142 + XCTAssertLessThanOrEqual(stillViewedCount, 200) 143 + } 118 144 }