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: use getGalleryThread for story comments + simpler fav cache

The server doesn't implement social.grain.unspecced.getStoryThread —
verified via curl. But getGalleryThread already queries by subject URI
regardless of whether the subject is a gallery or a story, so the
client can use it for both. Switched StoryCommentsViewModel to call
getGalleryThread(gallery: storyUri) and deleted the orphaned
getStoryThread endpoint.

Simplified StoryFavoriteCache to a cleaner bool-oriented API
(isLiked/like/unlike). Key fix: isFavorited in StoryViewer now reads
from BOTH the story's viewer state AND the cache, so reopened stories
still show as liked even when the server returns no viewer state.
Also guards against nil response.uri on create so we don't accidentally
clear the cache entry on a successful request.

+69 -48
-11
Grain/API/Endpoints/StoryEndpoints.swift
··· 35 35 func getStoryAuthors(auth: AuthContext? = nil) async throws -> GetStoryAuthorsResponse { 36 36 try await query("social.grain.unspecced.getStoryAuthors", auth: auth, as: GetStoryAuthorsResponse.self) 37 37 } 38 - 39 - func getStoryThread( 40 - story: String, 41 - limit: Int = 20, 42 - cursor: String? = nil, 43 - auth: AuthContext? = nil 44 - ) async throws -> GetGalleryThreadResponse { 45 - var params = ["story": story, "limit": String(limit)] 46 - if let cursor { params["cursor"] = cursor } 47 - return try await query("social.grain.unspecced.getStoryThread", params: params, auth: auth, as: GetGalleryThreadResponse.self) 48 - } 49 38 }
+3 -3
Grain/ViewModels/StoryCommentsViewModel.swift
··· 53 53 } 54 54 55 55 do { 56 - let response = try await client.getStoryThread(story: storyUri, limit: 1, auth: auth) 56 + let response = try await client.getGalleryThread(gallery: storyUri, limit: 1, auth: auth) 57 57 let preview = CachedPreview( 58 58 comment: response.comments.first, 59 59 count: response.totalCount ?? response.comments.count ··· 77 77 hasMoreComments = true 78 78 79 79 do { 80 - let response = try await client.getStoryThread(story: storyUri, auth: auth) 80 + let response = try await client.getGalleryThread(gallery: storyUri, auth: auth) 81 81 comments = response.comments 82 82 commentCursor = response.cursor 83 83 hasMoreComments = response.cursor != nil ··· 100 100 isLoading = true 101 101 102 102 do { 103 - let response = try await client.getStoryThread(story: storyUri, cursor: cursor, auth: auth) 103 + let response = try await client.getGalleryThread(gallery: storyUri, cursor: cursor, auth: auth) 104 104 comments.append(contentsOf: response.comments) 105 105 commentCursor = response.cursor 106 106 hasMoreComments = response.cursor != nil
+14 -19
Grain/ViewModels/StoryFavoriteCache.swift
··· 1 1 import Foundation 2 2 3 - /// Session-scoped cache of story favorites. Ensures that favoriting a story 4 - /// sticks across navigation and story re-fetches, even when the server does 5 - /// not yet return viewer state for stories. 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. 6 6 @Observable 7 7 @MainActor 8 8 final class StoryFavoriteCache { 9 - /// Maps story URI → favorite record URI. 10 - private(set) var favoritesByUri: [String: String] = [:] 9 + /// storyUri → favorite record URI. Presence means liked. 10 + var favorites: [String: String] = [:] 11 + 12 + func isLiked(_ storyUri: String) -> Bool { 13 + favorites[storyUri] != nil 14 + } 11 15 12 16 func favUri(for storyUri: String) -> String? { 13 - favoritesByUri[storyUri] 17 + favorites[storyUri] 14 18 } 15 19 16 - func setFavorite(storyUri: String, favUri: String?) { 17 - if let favUri { 18 - favoritesByUri[storyUri] = favUri 19 - } else { 20 - favoritesByUri.removeValue(forKey: storyUri) 21 - } 20 + func like(_ storyUri: String, favUri: String) { 21 + favorites[storyUri] = favUri 22 22 } 23 23 24 - /// Overlays cached favorites onto a story array so the UI reflects session state. 25 - func apply(to stories: inout [GrainStory]) { 26 - for i in stories.indices { 27 - if let favUri = favoritesByUri[stories[i].uri] { 28 - stories[i].viewer = StoryViewerState(fav: favUri) 29 - } 30 - } 24 + func unlike(_ storyUri: String) { 25 + favorites.removeValue(forKey: storyUri) 31 26 } 32 27 }
+52 -15
Grain/Views/Stories/StoryViewer.swift
··· 207 207 if !isShowing { startTimerIfSafe() } 208 208 } 209 209 .task { 210 - guard !isPreview else { return } 210 + // In preview, only continue if we have prefetched stories to show (no network) 211 + if isPreview, prefetchedStories.isEmpty { return } 211 212 let startAuthor = authors[currentAuthorIndex] 212 213 let isOwn = startAuthor.profile.did == auth.userDID 213 214 let hasUnreads = !viewedStories.hasViewedAll(authorDid: startAuthor.profile.did, latestAt: startAuthor.latestAt) 214 215 unreadOnly = isOwn || hasUnreads 215 - timer.onComplete = { [self] in goToNext() } 216 - timer.onQuarter = { [self] in markCurrentStoryViewed() } 216 + if !isPreview { 217 + timer.onComplete = { [self] in goToNext() } 218 + timer.onQuarter = { [self] in markCurrentStoryViewed() } 219 + } 217 220 await loadStoriesForCurrentAuthor() 218 221 } 219 222 } ··· 879 882 let targetStory = fetched.indices.contains(targetIndex) ? fetched[targetIndex] : nil 880 883 if !isFullsizeCached(targetStory) { imageLoaded = false } 881 884 showLocationCopied = false 882 - var fetchedWithCache = fetched 883 - storyFavoriteCache.apply(to: &fetchedWithCache) 884 - stories = fetchedWithCache 885 + stories = fetched 885 886 currentStoryIndex = targetIndex 886 887 labelRevealed = false 887 888 isLoadingStories = false ··· 990 991 // MARK: - Comments & Likes 991 992 992 993 private var isFavorited: Bool { 993 - currentStory?.viewer?.fav != nil 994 + guard let story = currentStory else { return false } 995 + return story.viewer?.fav != nil || storyFavoriteCache.isLiked(story.uri) 994 996 } 995 997 996 998 private var storyInputBar: some View { ··· 1079 1081 currentStoryIndex < stories.count else { return } 1080 1082 1081 1083 let story = stories[currentStoryIndex] 1082 - if let favUri = story.viewer?.fav { 1084 + // Resolve the favUri from either server state or session cache 1085 + let existingFavUri = story.viewer?.fav ?? storyFavoriteCache.favUri(for: story.uri) 1086 + 1087 + if let favUri = existingFavUri { 1083 1088 // Unfavorite — optimistic 1084 1089 stories[currentStoryIndex].viewer?.fav = nil 1085 - storyFavoriteCache.setFavorite(storyUri: story.uri, favUri: nil) 1090 + storyFavoriteCache.unlike(story.uri) 1086 1091 do { 1087 1092 try await FavoriteService.delete(favoriteUri: favUri, client: client, auth: authContext) 1088 1093 } catch { 1089 1094 stories[currentStoryIndex].viewer?.fav = favUri 1090 - storyFavoriteCache.setFavorite(storyUri: story.uri, favUri: favUri) 1095 + storyFavoriteCache.like(story.uri, favUri: favUri) 1091 1096 } 1092 1097 } else { 1093 1098 // Favorite — optimistic 1094 - let prevViewer = stories[currentStoryIndex].viewer 1095 1099 stories[currentStoryIndex].viewer = StoryViewerState(fav: "pending") 1096 1100 do { 1097 1101 let response = try await FavoriteService.create(subject: story.uri, client: client, auth: authContext) 1098 - stories[currentStoryIndex].viewer = StoryViewerState(fav: response.uri) 1099 - storyFavoriteCache.setFavorite(storyUri: story.uri, favUri: response.uri) 1102 + guard let newFavUri = response.uri else { 1103 + stories[currentStoryIndex].viewer = nil 1104 + return 1105 + } 1106 + stories[currentStoryIndex].viewer = StoryViewerState(fav: newFavUri) 1107 + storyFavoriteCache.like(story.uri, favUri: newFavUri) 1100 1108 } catch { 1101 - stories[currentStoryIndex].viewer = prevViewer 1109 + stories[currentStoryIndex].viewer = nil 1102 1110 } 1103 1111 } 1104 1112 } ··· 1143 1151 } 1144 1152 } 1145 1153 1146 - #Preview { 1154 + #Preview("Story Viewer") { 1147 1155 StoryViewer( 1148 1156 authors: PreviewData.storyAuthors, 1149 1157 startAuthorDid: "did:plc:prevuser1", ··· 1156 1164 .environment(StoryStatusCache()) 1157 1165 .environment(StoryFavoriteCache()) 1158 1166 } 1167 + 1168 + #Preview("Story Viewer + Comment Sheet") { 1169 + let vm = StoryCommentsViewModel(client: XRPCClient(baseURL: AuthManager.serverURL)) 1170 + vm.comments = PreviewData.storyComments 1171 + vm.latestComment = PreviewData.storyComments.first 1172 + vm.totalCount = PreviewData.storyComments.count 1173 + 1174 + return StoryViewer( 1175 + authors: PreviewData.storyAuthors, 1176 + startAuthorDid: "did:plc:prevuser1", 1177 + initialStories: PreviewData.stories, 1178 + client: XRPCClient(baseURL: AuthManager.serverURL) 1179 + ) 1180 + .environment(AuthManager()) 1181 + .environment(LabelDefinitionsCache()) 1182 + .environment(ViewedStoryStorage()) 1183 + .environment(StoryStatusCache()) 1184 + .environment(StoryFavoriteCache()) 1185 + .sheet(isPresented: .constant(true)) { 1186 + StoryCommentSheet( 1187 + viewModel: vm, 1188 + storyUri: PreviewData.stories[0].uri, 1189 + client: XRPCClient(baseURL: AuthManager.serverURL) 1190 + ) 1191 + .environment(AuthManager()) 1192 + .environment(StoryStatusCache()) 1193 + .environment(ViewedStoryStorage()) 1194 + } 1195 + }