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: timer behavior, fav persistence, comment prefetch, align

- Remove the auto-restart onChange handler so the timer stays paused
after the comment sheet dismisses (user manually navigates)
- Persist StoryFavoriteCache to UserDefaults so favorites survive app
restarts, not just the current session
- Prefetch comment previews for all stories in the current author's
set, and for the next author on swipe (likes stay local per user's
request)
- Left-align the comment preview pill with a trailing Spacer
- Clean up toggleStoryFavorite rollback paths: restore prevViewer on
error, handle nil response.uri gracefully

+59 -22
+7
Grain/ViewModels/StoryCommentsViewModel.swift
··· 43 43 44 44 // MARK: - Preview Loading 45 45 46 + /// Fires background preview loads for the given story URIs. Already-cached URIs are skipped. 47 + func prefetchPreviews(for storyUris: [String], auth: AuthContext? = nil) { 48 + for uri in storyUris where previewCache[uri] == nil { 49 + Task { await loadPreview(storyUri: uri, auth: auth) } 50 + } 51 + } 52 + 46 53 func loadPreview(storyUri: String, auth: AuthContext? = nil) async { 47 54 if let cached = previewCache[storyUri] { 48 55 if activeStoryUri == nil || activeStoryUri == storyUri {
+21 -4
Grain/ViewModels/StoryFavoriteCache.swift
··· 1 1 import Foundation 2 2 3 - /// Session-scoped cache of story favorites. Bridges the gap until the grain 4 - /// appview returns `viewer.fav` on story responses — once it does, this 5 - /// becomes redundant and can be removed. 3 + /// Persistent cache of story favorites, keyed by story URI. Bridges the gap 4 + /// until the grain appview returns `viewer.fav` on story responses — once it 5 + /// does, this becomes redundant and can be removed. 6 6 @Observable 7 7 @MainActor 8 8 final class StoryFavoriteCache { 9 9 /// storyUri → favorite record URI. Presence means liked. 10 - var favorites: [String: String] = [:] 10 + private(set) var favorites: [String: String] = [:] 11 + 12 + private static let key = "storyFavorites" 13 + 14 + init() { 15 + load() 16 + } 11 17 12 18 func isLiked(_ storyUri: String) -> Bool { 13 19 favorites[storyUri] != nil ··· 19 25 20 26 func like(_ storyUri: String, favUri: String) { 21 27 favorites[storyUri] = favUri 28 + save() 22 29 } 23 30 24 31 func unlike(_ storyUri: String) { 25 32 favorites.removeValue(forKey: storyUri) 33 + save() 34 + } 35 + 36 + private func load() { 37 + guard let data = UserDefaults.standard.dictionary(forKey: Self.key) as? [String: String] else { return } 38 + favorites = data 39 + } 40 + 41 + private func save() { 42 + UserDefaults.standard.set(favorites, forKey: Self.key) 26 43 } 27 44 }
+31 -18
Grain/Views/Stories/StoryViewer.swift
··· 203 203 .environment(viewedStories) 204 204 } 205 205 } 206 - .onChange(of: showCommentSheet) { _, isShowing in 207 - if !isShowing { startTimerIfSafe() } 208 - } 209 206 .task { 210 207 // In preview, only continue if we have prefetched stories to show (no network) 211 208 if isPreview, prefetchedStories.isEmpty { return } ··· 596 593 .font(.caption) 597 594 .foregroundStyle(.white) 598 595 .lineLimit(1) 596 + Spacer(minLength: 0) 599 597 } 600 598 .padding(.horizontal) 601 599 .padding(.bottom, 4) ··· 890 888 prefetchAdjacentAuthors() 891 889 prefetchStoryImages() 892 890 if let uri = targetStory?.uri { 893 - Task { await commentsViewModel.switchToStory(uri: uri, auth: auth.authContext()) } 891 + Task { 892 + let ctx = await auth.authContext() 893 + commentsViewModel.switchToStory(uri: uri, auth: ctx) 894 + commentsViewModel.prefetchPreviews(for: fetched.map(\.uri), auth: ctx) 895 + } 894 896 } 895 897 } 896 898 ··· 899 901 let did = authors[nextIndex].profile.did 900 902 guard prefetchedStories[did] == nil else { return } 901 903 Task { 902 - if let response = try? await client.getStories(actor: did, auth: auth.authContext()) { 904 + let ctx = await auth.authContext() 905 + if let response = try? await client.getStories(actor: did, auth: ctx) { 903 906 prefetchedStories[did] = response.stories 907 + commentsViewModel.prefetchPreviews(for: response.stories.map(\.uri), auth: ctx) 904 908 } 905 909 } 906 910 } ··· 1003 1007 commentSheetFocusInput = false 1004 1008 showCommentSheet = true 1005 1009 } label: { 1006 - Image(systemName: "bubble.left") 1007 - .font(.body) 1008 - .foregroundStyle(.white) 1009 - .frame(width: 36, height: 36) 1010 - .contentShape(Rectangle()) 1010 + VStack(spacing: 2) { 1011 + Image(systemName: "bubble.left") 1012 + .font(.body) 1013 + if commentsViewModel.totalCount > 0 { 1014 + Text("\(commentsViewModel.totalCount)") 1015 + .font(.caption2) 1016 + .monospacedDigit() 1017 + } 1018 + } 1019 + .foregroundStyle(.white) 1020 + .frame(width: 36, minHeight: 36) 1021 + .contentShape(Rectangle()) 1011 1022 } 1012 1023 .buttonStyle(.plain) 1013 1024 ··· 1086 1097 1087 1098 if let favUri = existingFavUri { 1088 1099 // Unfavorite — optimistic 1089 - stories[currentStoryIndex].viewer?.fav = nil 1100 + let prevViewer = stories[currentStoryIndex].viewer 1101 + stories[currentStoryIndex].viewer = nil 1090 1102 storyFavoriteCache.unlike(story.uri) 1091 1103 do { 1092 1104 try await FavoriteService.delete(favoriteUri: favUri, client: client, auth: authContext) 1093 1105 } catch { 1094 - stories[currentStoryIndex].viewer?.fav = favUri 1106 + stories[currentStoryIndex].viewer = prevViewer 1095 1107 storyFavoriteCache.like(story.uri, favUri: favUri) 1096 1108 } 1097 1109 } else { 1098 1110 // Favorite — optimistic 1111 + let prevViewer = stories[currentStoryIndex].viewer 1099 1112 stories[currentStoryIndex].viewer = StoryViewerState(fav: "pending") 1100 1113 do { 1101 1114 let response = try await FavoriteService.create(subject: story.uri, client: client, auth: authContext) 1102 - guard let newFavUri = response.uri else { 1103 - stories[currentStoryIndex].viewer = nil 1104 - return 1115 + if let newFavUri = response.uri { 1116 + stories[currentStoryIndex].viewer = StoryViewerState(fav: newFavUri) 1117 + storyFavoriteCache.like(story.uri, favUri: newFavUri) 1118 + } else { 1119 + stories[currentStoryIndex].viewer = prevViewer 1105 1120 } 1106 - stories[currentStoryIndex].viewer = StoryViewerState(fav: newFavUri) 1107 - storyFavoriteCache.like(story.uri, favUri: newFavUri) 1108 1121 } catch { 1109 - stories[currentStoryIndex].viewer = nil 1122 + stories[currentStoryIndex].viewer = prevViewer 1110 1123 } 1111 1124 } 1112 1125 }