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: story heart fav race + double-tap finger tracking + rename

- toggleStoryFavorite now captures storyUri and re-resolves the index by
URI after every await, so timer auto-advance or a user tap mid-request
no longer lands the optimistic/success/rollback writes on the wrong
story. Guard per-URI via favoritingStoryUris so overlapping requests
on different stories don't clobber each other. Signpost intervals +
logger lines wrap the toggle flow for Instruments.
- Rename StoryCommentsViewModel.latestComment → firstComment (it's
chronologically first, not latest); update tests and StoryViewer
callers.
- Double-tap heart now follows the finger: tap zones report location
via .onTapGesture(count: 2, coordinateSpace: .named("storyHearts")),
with the matching .coordinateSpace declared on the storyContent
ZStack where the hearts overlay renders.
- Fix self.progress autoclosure warning in StoryTimer.resume logger.

+113 -46
+13 -12
Grain/ViewModels/StoryCommentsViewModel.swift
··· 8 8 @MainActor 9 9 final class StoryCommentsViewModel { 10 10 var comments: [GrainComment] = [] 11 - // latestComment is set to response.comments.first (oldest/chronological). 12 - // Ideally this would prefer comments from followed users, then fall back to most-liked, 13 - // but the getGalleryThread endpoint doesn't return viewer state on authors or likeCount 14 - // on comments. Bumping the preview fetch from limit=1 to support client-side selection 15 - // also adds ~100ms per request. Revisit when the backend hydrates those fields. 16 - var latestComment: GrainComment? 11 + // `firstComment` is `response.comments.first`, which is the oldest/chronological 12 + // comment on the story. Ideally this would prefer comments from followed users, 13 + // then fall back to most-liked, but the getGalleryThread endpoint doesn't return 14 + // viewer state on authors or likeCount on comments. Bumping the preview fetch 15 + // from limit=1 to support client-side selection also adds ~100ms per request. 16 + // Revisit when the backend hydrates those fields. 17 + var firstComment: GrainComment? 17 18 var totalCount: Int = 0 18 19 var isLoading = false 19 20 var isPostingComment = false ··· 38 39 hasMoreComments = true 39 40 40 41 if let cached = previewCache[uri] { 41 - latestComment = cached.comment 42 + firstComment = cached.comment 42 43 totalCount = cached.count 43 44 } else { 44 - latestComment = nil 45 + firstComment = nil 45 46 totalCount = 0 46 47 Task { await loadPreview(storyUri: uri, auth: auth) } 47 48 } ··· 59 60 func loadPreview(storyUri: String, auth: AuthContext? = nil) async { 60 61 if let cached = previewCache[storyUri] { 61 62 if activeStoryUri == nil || activeStoryUri == storyUri { 62 - latestComment = cached.comment 63 + firstComment = cached.comment 63 64 totalCount = cached.count 64 65 } 65 66 return ··· 73 74 ) 74 75 previewCache[storyUri] = preview 75 76 if activeStoryUri == nil || activeStoryUri == storyUri { 76 - latestComment = preview.comment 77 + firstComment = preview.comment 77 78 totalCount = preview.count 78 79 } 79 80 } catch { ··· 104 105 let latest = response.comments.first 105 106 previewCache[storyUri] = CachedPreview(comment: latest, count: totalCount) 106 107 if storyUri == activeStoryUri { 107 - latestComment = latest 108 + firstComment = latest 108 109 } 109 110 let count = response.comments.count 110 111 scvmSignposter.endInterval("loadComments", state, "count=\(count)") ··· 163 164 let latest = comments.first 164 165 previewCache[storyUri] = CachedPreview(comment: latest, count: totalCount) 165 166 if storyUri == activeStoryUri { 166 - latestComment = latest 167 + firstComment = latest 167 168 } 168 169 } catch { 169 170 logger.error("Failed to delete comment: \(error)")
+95 -29
Grain/Views/Stories/StoryViewer.swift
··· 131 131 @State private var isCommentSheetOpen = false 132 132 @State private var hasLoadedInitialStories = false 133 133 @State private var hearts: [HeartAnimationState] = [] 134 - @State private var isFavoriting = false 134 + @State private var favoritingStoryUris: Set<String> = [] 135 135 @State private var heartBeatTrigger = 0 136 136 @State private var instanceID: Int = 0 137 137 ··· 468 468 } 469 469 .id(story.uri) 470 470 471 - // Tap zones — with a bottom inset so they don't cover the comment input bar 471 + // Tap zones — with a bottom inset so they don't cover the comment input bar. 472 + // Double-tap reports its location in the "storyHearts" coordinate space 473 + // (declared on the outer ZStack below) so the heart lands under the finger. 472 474 VStack(spacing: 0) { 473 475 Color.clear 474 476 .frame(height: 80) ··· 477 479 HStack(spacing: 0) { 478 480 Color.clear 479 481 .contentShape(Rectangle()) 480 - .onTapGesture(count: 2) { doubleTapLike(at: CGPoint(x: geo.size.width / 6, y: geo.size.height / 2)) } 482 + .onTapGesture(count: 2, coordinateSpace: .named("storyHearts")) { location in 483 + doubleTapLike(at: location) 484 + } 481 485 .onTapGesture { goToPrevious() } 482 486 .frame(width: geo.size.width / 3) 483 487 Color.clear 484 488 .contentShape(Rectangle()) 485 - .onTapGesture(count: 2) { doubleTapLike(at: CGPoint(x: geo.size.width * 2 / 3, y: geo.size.height / 2)) } 489 + .onTapGesture(count: 2, coordinateSpace: .named("storyHearts")) { location in 490 + doubleTapLike(at: location) 491 + } 486 492 .onTapGesture { goToNext() } 487 493 .frame(maxWidth: .infinity) 488 494 } ··· 581 587 582 588 if let currentStory { 583 589 Group { 584 - if let latest = commentsViewModel.latestComment, 590 + if let latest = commentsViewModel.firstComment, 585 591 commentsViewModel.activeStoryUri == currentStory.uri 586 592 { 587 593 Button { ··· 604 610 .transition(.opacity) 605 611 } 606 612 } 607 - .animation(.easeInOut(duration: 0.2), value: commentsViewModel.latestComment?.uri) 613 + .animation(.easeInOut(duration: 0.2), value: commentsViewModel.firstComment?.uri) 608 614 609 615 bottomInputBar(interactive: true, story: currentStory) 610 616 } 611 617 } 612 618 } 619 + .coordinateSpace(.named("storyHearts")) 613 620 } 614 621 615 622 // MARK: - Navigation ··· 1137 1144 private func doubleTapLike(at point: CGPoint) { 1138 1145 hearts.append(HeartAnimationState(position: point)) 1139 1146 heartBeatTrigger &+= 1 1140 - guard !isFavorited, !isFavoriting else { return } 1147 + guard !isFavorited else { return } 1141 1148 triggerFavoriteToggle() 1142 1149 } 1143 1150 1144 1151 private func triggerFavoriteToggle() { 1145 - isFavoriting = true 1152 + guard let storyUri = currentStory?.uri else { return } 1153 + guard !favoritingStoryUris.contains(storyUri) else { 1154 + svLogger.info("[triggerFavoriteToggle] SKIPPED — toggle already in flight uri=\(storyUri)") 1155 + svSignposter.emitEvent("toggleStoryFavorite.skipped", "reason=inFlight") 1156 + return 1157 + } 1158 + favoritingStoryUris.insert(storyUri) 1146 1159 Task { 1147 - await toggleStoryFavorite() 1148 - isFavoriting = false 1160 + await toggleStoryFavorite(storyUri: storyUri) 1161 + favoritingStoryUris.remove(storyUri) 1149 1162 } 1150 1163 } 1151 1164 1152 - private func toggleStoryFavorite() async { 1153 - guard let authContext = await auth.authContext(), 1154 - currentStoryIndex < stories.count else { return } 1165 + /// Toggle the favorite state for the *story that was visible when the user tapped*, 1166 + /// not whichever story happens to be current when the network request returns. 1167 + /// The story timer or a tap can advance `currentStoryIndex` across the `await`, 1168 + /// so every mutation must look up the story by its captured URI. 1169 + private func toggleStoryFavorite(storyUri: String) async { 1170 + guard let authContext = await auth.authContext() else { 1171 + svLogger.info("[toggleStoryFavorite] BAIL — no authContext") 1172 + return 1173 + } 1174 + 1175 + let capturedViewer = stories.first(where: { $0.uri == storyUri })?.viewer 1176 + let existingFavUri = capturedViewer?.fav ?? storyFavoriteCache.favUri(for: storyUri) 1177 + let op = existingFavUri == nil ? "like" : "unlike" 1155 1178 1156 - let story = stories[currentStoryIndex] 1157 - // Resolve the favUri from either server state or session cache 1158 - let existingFavUri = story.viewer?.fav ?? storyFavoriteCache.favUri(for: story.uri) 1179 + let toggleState = svSignposter.beginInterval( 1180 + "toggleStoryFavorite", 1181 + id: svSignposter.makeSignpostID(), 1182 + "op=\(op) uri=\(storyUri)" 1183 + ) 1184 + defer { svSignposter.endInterval("toggleStoryFavorite", toggleState) } 1185 + svLogger.info("[toggleStoryFavorite] enter op=\(op) uri=\(storyUri)") 1186 + 1187 + func indexOfCapturedStory() -> Int? { 1188 + stories.firstIndex { $0.uri == storyUri } 1189 + } 1159 1190 1160 1191 if let favUri = existingFavUri { 1161 1192 // Unfavorite — optimistic 1162 - let prevViewer = stories[currentStoryIndex].viewer 1163 - stories[currentStoryIndex].viewer = nil 1164 - storyFavoriteCache.unlike(story.uri) 1193 + let prevViewer: StoryViewerState? 1194 + if let idx = indexOfCapturedStory() { 1195 + prevViewer = stories[idx].viewer 1196 + stories[idx].viewer = nil 1197 + } else { 1198 + prevViewer = nil 1199 + } 1200 + storyFavoriteCache.unlike(storyUri) 1201 + svSignposter.emitEvent("toggleStoryFavorite.optimistic", "op=unlike uri=\(storyUri)") 1165 1202 do { 1166 1203 try await FavoriteService.delete(favoriteUri: favUri, client: client, auth: authContext) 1204 + svSignposter.emitEvent("toggleStoryFavorite.success", "op=unlike uri=\(storyUri)") 1205 + svLogger.info("[toggleStoryFavorite] success op=unlike uri=\(storyUri)") 1167 1206 } catch { 1168 - stories[currentStoryIndex].viewer = prevViewer 1169 - storyFavoriteCache.like(story.uri, favUri: favUri) 1207 + svSignposter.emitEvent("toggleStoryFavorite.error", "op=unlike uri=\(storyUri)") 1208 + svLogger.error("[toggleStoryFavorite] unlike error: \(error); rolling back uri=\(storyUri)") 1209 + if let idx = indexOfCapturedStory() { 1210 + stories[idx].viewer = prevViewer 1211 + } 1212 + storyFavoriteCache.like(storyUri, favUri: favUri) 1170 1213 } 1171 1214 } else { 1172 - // Favorite — optimistic 1173 - let prevViewer = stories[currentStoryIndex].viewer 1174 - stories[currentStoryIndex].viewer = StoryViewerState(fav: "pending") 1215 + // Favorite — optimistic on the in-memory story only. We intentionally do 1216 + // NOT write a "pending" favUri into storyFavoriteCache because the cache 1217 + // persists to UserDefaults; a backgrounded/killed app would leave the 1218 + // bogus "pending" URI behind and break the next unfavorite attempt. 1219 + let prevViewer: StoryViewerState? 1220 + if let idx = indexOfCapturedStory() { 1221 + prevViewer = stories[idx].viewer 1222 + stories[idx].viewer = StoryViewerState(fav: "pending") 1223 + } else { 1224 + prevViewer = nil 1225 + } 1226 + svSignposter.emitEvent("toggleStoryFavorite.optimistic", "op=like uri=\(storyUri)") 1175 1227 do { 1176 - let response = try await FavoriteService.create(subject: story.uri, client: client, auth: authContext) 1228 + let response = try await FavoriteService.create(subject: storyUri, client: client, auth: authContext) 1177 1229 if let newFavUri = response.uri { 1178 - stories[currentStoryIndex].viewer = StoryViewerState(fav: newFavUri) 1179 - storyFavoriteCache.like(story.uri, favUri: newFavUri) 1230 + if let idx = indexOfCapturedStory() { 1231 + stories[idx].viewer = StoryViewerState(fav: newFavUri) 1232 + } 1233 + storyFavoriteCache.like(storyUri, favUri: newFavUri) 1234 + svSignposter.emitEvent("toggleStoryFavorite.success", "op=like uri=\(storyUri)") 1235 + svLogger.info("[toggleStoryFavorite] success op=like uri=\(storyUri)") 1180 1236 } else { 1181 - stories[currentStoryIndex].viewer = prevViewer 1237 + svSignposter.emitEvent("toggleStoryFavorite.error", "op=like reason=nilUri uri=\(storyUri)") 1238 + svLogger.error("[toggleStoryFavorite] create returned nil uri; rolling back uri=\(storyUri)") 1239 + if let idx = indexOfCapturedStory() { 1240 + stories[idx].viewer = prevViewer 1241 + } 1242 + storyFavoriteCache.unlike(storyUri) 1182 1243 } 1183 1244 } catch { 1184 - stories[currentStoryIndex].viewer = prevViewer 1245 + svSignposter.emitEvent("toggleStoryFavorite.error", "op=like uri=\(storyUri)") 1246 + svLogger.error("[toggleStoryFavorite] like error: \(error); rolling back uri=\(storyUri)") 1247 + if let idx = indexOfCapturedStory() { 1248 + stories[idx].viewer = prevViewer 1249 + } 1250 + storyFavoriteCache.unlike(storyUri) 1185 1251 } 1186 1252 } 1187 1253 }
+5 -5
GrainTests/StoryCommentsViewModelTests.swift
··· 46 46 """) 47 47 48 48 await vm.loadPreview(storyUri: storyA) 49 - XCTAssertEqual(vm.latestComment?.text, "Great shot!") 49 + XCTAssertEqual(vm.firstComment?.text, "Great shot!") 50 50 XCTAssertEqual(vm.totalCount, 5) 51 51 } 52 52 ··· 132 132 {"comments": [{"uri": "at://did:plc:a/social.grain.comment/old", "cid": "cold", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Old", "createdAt": "2024-06-15T12:00:00Z"}], "totalCount": 1} 133 133 """) 134 134 await vm.loadPreview(storyUri: storyA) 135 - XCTAssertEqual(vm.latestComment?.text, "Old") 135 + XCTAssertEqual(vm.firstComment?.text, "Old") 136 136 137 137 // Post triggers createRecord then loadComments refresh 138 138 var requestPaths: [String] = [] ··· 153 153 let auth = makeDummyAuth() 154 154 vm.switchToStory(uri: storyA) 155 155 await vm.postComment(text: "Hello", storyUri: storyA, auth: auth) 156 - XCTAssertEqual(vm.latestComment?.text, "Fresh") 156 + XCTAssertEqual(vm.firstComment?.text, "Fresh") 157 157 XCTAssertEqual(vm.totalCount, 2) 158 158 } 159 159 ··· 198 198 // Wait for the background task to complete 199 199 try? await Task.sleep(for: .milliseconds(100)) 200 200 XCTAssertEqual(requestCount, 1) 201 - XCTAssertEqual(vm.latestComment?.text, "Story A comment") 201 + XCTAssertEqual(vm.firstComment?.text, "Story A comment") 202 202 203 203 // Switch to story B 204 204 vm.switchToStory(uri: storyB) ··· 209 209 vm.switchToStory(uri: storyA) 210 210 // No sleep needed — cache is synchronous 211 211 XCTAssertEqual(requestCount, 2, "Switching back to A should use cache") 212 - XCTAssertEqual(vm.latestComment?.text, "Story A comment") 212 + XCTAssertEqual(vm.firstComment?.text, "Story A comment") 213 213 } 214 214 }