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: expire story status cache entries after 24 hours

StoryStatusCache now stores an expiresAt timestamp (latestAt + 86400s)
per author entry. hasStory, author, authorsByDid, and didsWithStories
all filter expired entries; purgeExpired() removes them from the backing
dict and is called on app foreground and background via async Task so
it doesn't block the main-thread launch path.

Also fixes ViewedStoryStorageTests isolation: setUp/tearDown now clear
UserDefaults keys so stale persisted state doesn't pollute later tests.

authored by

Hima Aramona and committed by
Chad Miller
15c75fad 939a732b

+126 -10
+8 -2
Grain/GrainApp.swift
··· 32 32 .environment(labelDefsCache) 33 33 .tint(Color("AccentColor")) 34 34 .onAppear { 35 - viewedStoryStorage.cleanup() 35 + Task { 36 + viewedStoryStorage.cleanup() 37 + storyStatusCache.purgeExpired() 38 + } 36 39 pushManager.configure(authManager: authManager) 37 40 appDelegate.pushManager = pushManager 38 41 appDelegate.onNotificationTap = { deepLink in ··· 56 59 } 57 60 .onChange(of: scenePhase) { 58 61 if scenePhase == .background { 59 - viewedStoryStorage.cleanup() 62 + Task { 63 + viewedStoryStorage.cleanup() 64 + storyStatusCache.purgeExpired() 65 + } 60 66 } 61 67 } 62 68 }
+48 -4
Grain/ViewModels/StoryStatusCache.swift
··· 3 3 @Observable 4 4 @MainActor 5 5 final class StoryStatusCache { 6 - private(set) var authorsByDid: [String: GrainStoryAuthor] = [:] 6 + private struct CachedEntry { 7 + let author: GrainStoryAuthor 8 + let expiresAt: Date 9 + } 10 + 11 + private static let storyLifetime: TimeInterval = 86400 // 24 hours 12 + 13 + private static let dateFormatter: ISO8601DateFormatter = { 14 + let f = ISO8601DateFormatter() 15 + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 16 + return f 17 + }() 18 + 19 + private static let dateFormatterNoFrac: ISO8601DateFormatter = { 20 + let f = ISO8601DateFormatter() 21 + f.formatOptions = [.withInternetDateTime] 22 + return f 23 + }() 24 + 25 + private static func parseDate(_ string: String) -> Date? { 26 + dateFormatter.date(from: string) ?? dateFormatterNoFrac.date(from: string) 27 + } 28 + 29 + private var entries: [String: CachedEntry] = [:] 30 + 31 + /// Live authors whose stories have not yet expired. 32 + var authorsByDid: [String: GrainStoryAuthor] { 33 + let now = Date() 34 + return entries.compactMapValues { $0.expiresAt > now ? $0.author : nil } 35 + } 7 36 8 37 var didsWithStories: Set<String> { 9 38 Set(authorsByDid.keys) 10 39 } 11 40 12 41 func hasStory(for did: String) -> Bool { 13 - authorsByDid[did] != nil 42 + guard let entry = entries[did] else { return false } 43 + return entry.expiresAt > Date() 14 44 } 15 45 16 46 func author(for did: String) -> GrainStoryAuthor? { 17 - authorsByDid[did] 47 + guard let entry = entries[did], entry.expiresAt > Date() else { return nil } 48 + return entry.author 18 49 } 19 50 20 51 func update(from authors: [GrainStoryAuthor]) { 21 - authorsByDid = Dictionary(uniqueKeysWithValues: authors.map { ($0.profile.did, $0) }) 52 + entries = Dictionary(uniqueKeysWithValues: authors.map { author in 53 + let expiresAt: Date = if let latestAt = Self.parseDate(author.latestAt) { 54 + latestAt.addingTimeInterval(Self.storyLifetime) 55 + } else { 56 + .distantPast 57 + } 58 + return (author.profile.did, CachedEntry(author: author, expiresAt: expiresAt)) 59 + }) 60 + } 61 + 62 + /// Remove entries whose stories have expired. Call on app foreground and background. 63 + func purgeExpired() { 64 + let now = Date() 65 + entries = entries.filter { $0.value.expiresAt > now } 22 66 } 23 67 }
+66 -4
GrainTests/StoryStatusCacheTests.swift
··· 3 3 4 4 @MainActor 5 5 final class StoryStatusCacheTests: XCTestCase { 6 - private func makeAuthor(did: String, storyCount: Int = 1) -> GrainStoryAuthor { 7 - GrainStoryAuthor( 6 + private static let iso8601: ISO8601DateFormatter = { 7 + let f = ISO8601DateFormatter() 8 + f.formatOptions = [.withInternetDateTime] 9 + return f 10 + }() 11 + 12 + /// Creates an author whose latest story was created `offset` seconds from now. 13 + /// Positive offset = future (not yet expired). 14 + /// Negative offset = past (expired if |offset| > 86400). 15 + private func makeAuthor(did: String, storyCount: Int = 1, latestAtOffset: TimeInterval = -3600) -> GrainStoryAuthor { 16 + let latestAt = Self.iso8601.string(from: Date().addingTimeInterval(latestAtOffset)) 17 + return GrainStoryAuthor( 8 18 profile: GrainProfile(cid: "cid", did: did, handle: "\(did).test"), 9 19 storyCount: storyCount, 10 - latestAt: "2024-06-15T12:00:00Z" 20 + latestAt: latestAt 11 21 ) 12 22 } 13 23 ··· 23 33 let cache = StoryStatusCache() 24 34 cache.update(from: [makeAuthor(did: "did:plc:alice", storyCount: 3)]) 25 35 cache.update(from: [makeAuthor(did: "did:plc:bob", storyCount: 1)]) 26 - // After second update, alice should be gone 27 36 XCTAssertNil(cache.author(for: "did:plc:alice")) 28 37 XCTAssertNotNil(cache.author(for: "did:plc:bob")) 29 38 } ··· 67 76 func testDidsWithStoriesEmptyByDefault() { 68 77 let cache = StoryStatusCache() 69 78 XCTAssertTrue(cache.didsWithStories.isEmpty) 79 + } 80 + 81 + // MARK: - Expiry 82 + 83 + func testExpiredEntryNotVisibleViaHasStory() { 84 + let cache = StoryStatusCache() 85 + // latestAt was 25 hours ago — story expired 1 hour ago 86 + cache.update(from: [makeAuthor(did: "did:plc:alice", latestAtOffset: -90000)]) 87 + XCTAssertFalse(cache.hasStory(for: "did:plc:alice")) 88 + } 89 + 90 + func testExpiredEntryNotVisibleViaAuthor() { 91 + let cache = StoryStatusCache() 92 + cache.update(from: [makeAuthor(did: "did:plc:alice", latestAtOffset: -90000)]) 93 + XCTAssertNil(cache.author(for: "did:plc:alice")) 94 + } 95 + 96 + func testExpiredEntryExcludedFromAuthorsByDid() { 97 + let cache = StoryStatusCache() 98 + cache.update(from: [ 99 + makeAuthor(did: "did:plc:fresh", latestAtOffset: -3600), // expires in 23h 100 + makeAuthor(did: "did:plc:stale", latestAtOffset: -90000), // expired 1h ago 101 + ]) 102 + XCTAssertEqual(cache.authorsByDid.count, 1) 103 + XCTAssertNotNil(cache.authorsByDid["did:plc:fresh"]) 104 + } 105 + 106 + func testExpiredEntryExcludedFromDidsWithStories() { 107 + let cache = StoryStatusCache() 108 + cache.update(from: [ 109 + makeAuthor(did: "did:plc:fresh", latestAtOffset: -3600), 110 + makeAuthor(did: "did:plc:stale", latestAtOffset: -90000), 111 + ]) 112 + XCTAssertEqual(cache.didsWithStories, Set(["did:plc:fresh"])) 113 + } 114 + 115 + func testPurgeExpiredRemovesStalePurgesEntries() { 116 + let cache = StoryStatusCache() 117 + cache.update(from: [ 118 + makeAuthor(did: "did:plc:fresh", latestAtOffset: -3600), 119 + makeAuthor(did: "did:plc:stale", latestAtOffset: -90000), 120 + ]) 121 + cache.purgeExpired() 122 + XCTAssertTrue(cache.hasStory(for: "did:plc:fresh")) 123 + XCTAssertFalse(cache.hasStory(for: "did:plc:stale")) 124 + } 125 + 126 + func testPurgeExpiredKeepsFreshEntries() { 127 + let cache = StoryStatusCache() 128 + cache.update(from: [makeAuthor(did: "did:plc:alice", latestAtOffset: -3600)]) 129 + cache.purgeExpired() 130 + XCTAssertTrue(cache.hasStory(for: "did:plc:alice")) 131 + XCTAssertNotNil(cache.author(for: "did:plc:alice")) 70 132 } 71 133 }
+4
GrainTests/ViewedStoryStorageTests.swift
··· 7 7 8 8 override func setUp() { 9 9 super.setUp() 10 + UserDefaults.standard.removeObject(forKey: "viewedStoryUris") 11 + UserDefaults.standard.removeObject(forKey: "viewedStoryAuthors") 10 12 storage = ViewedStoryStorage() 11 13 } 12 14 13 15 override func tearDown() { 16 + UserDefaults.standard.removeObject(forKey: "viewedStoryUris") 17 + UserDefaults.standard.removeObject(forKey: "viewedStoryAuthors") 14 18 storage = nil 15 19 super.tearDown() 16 20 }