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 comment buttons + like persistence

Sheet collapse bug: tap zones overlapped with the comment input bar,
letting taps leak through to goToNext() which called advanceStory and
restarted the timer. Added an 80pt bottom inset to the tap zones so
they don't cover the comment bar, and scoped allowsHitTesting correctly
back onto the tap zones VStack (not the hearts ForEach). Added
.buttonStyle(.plain) + explicit contentShape on each input bar button
so Swift reliably routes taps to them.

Favorite persistence bug: story viewer state wasn't surviving a close +
reopen because the client re-fetches stories and the server doesn't
yet return viewer state for stories. Added StoryFavoriteCache —
session-scoped storage of storyUri → favUri that toggleStoryFavorite
writes to and presentStories overlays onto fetched stories.

+55 -4
+2
Grain/GrainApp.swift
··· 18 18 @State private var storyStatusCache = StoryStatusCache() 19 19 @State private var viewedStoryStorage = ViewedStoryStorage() 20 20 @State private var labelDefsCache = LabelDefinitionsCache() 21 + @State private var storyFavoriteCache = StoryFavoriteCache() 21 22 @State private var pendingDeepLink: DeepLink? 22 23 23 24 var body: some Scene { ··· 30 31 .environment(storyStatusCache) 31 32 .environment(viewedStoryStorage) 32 33 .environment(labelDefsCache) 34 + .environment(storyFavoriteCache) 33 35 .tint(Color("AccentColor")) 34 36 .onAppear { 35 37 viewedStoryStorage.cleanup()
+32
Grain/ViewModels/StoryFavoriteCache.swift
··· 1 + import Foundation 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. 6 + @Observable 7 + @MainActor 8 + final class StoryFavoriteCache { 9 + /// Maps story URI → favorite record URI. 10 + private(set) var favoritesByUri: [String: String] = [:] 11 + 12 + func favUri(for storyUri: String) -> String? { 13 + favoritesByUri[storyUri] 14 + } 15 + 16 + func setFavorite(storyUri: String, favUri: String?) { 17 + if let favUri { 18 + favoritesByUri[storyUri] = favUri 19 + } else { 20 + favoritesByUri.removeValue(forKey: storyUri) 21 + } 22 + } 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 + } 31 + } 32 + }
+21 -4
Grain/Views/Stories/StoryViewer.swift
··· 62 62 @Environment(LabelDefinitionsCache.self) private var labelDefsCache 63 63 @Environment(ViewedStoryStorage.self) private var viewedStories 64 64 @Environment(StoryStatusCache.self) private var storyStatusCache 65 + @Environment(StoryFavoriteCache.self) private var storyFavoriteCache 65 66 let authors: [GrainStoryAuthor] 66 67 let client: XRPCClient 67 68 var onProfileTap: ((String) -> Void)? ··· 447 448 } 448 449 .id(story.uri) 449 450 450 - // Tap zones 451 + // Tap zones — with a bottom inset so they don't cover the comment input bar 451 452 VStack(spacing: 0) { 452 453 Color.clear 453 454 .frame(height: 80) ··· 466 467 .frame(maxWidth: .infinity) 467 468 } 468 469 } 470 + Color.clear 471 + .frame(height: 80) 472 + .allowsHitTesting(false) 469 473 } 474 + .allowsHitTesting(reportTarget == nil && !showDeleteConfirm && !showCommentSheet && (labelRevealed || storyLabelResult.action == .none || storyLabelResult.action == .badge)) 470 475 471 476 // Double-tap heart animations 472 477 ForEach(hearts) { heart in ··· 475 480 hearts.removeAll { $0.isComplete } 476 481 } 477 482 } 478 - .allowsHitTesting(reportTarget == nil && !showDeleteConfirm && !showCommentSheet && (labelRevealed || storyLabelResult.action == .none || storyLabelResult.action == .badge)) 479 483 } else { 480 484 ProgressView() 481 485 .tint(.white) ··· 596 600 .buttonStyle(.plain) 597 601 } 598 602 599 - // TODO(human): Implement the story comment input bar 600 603 storyInputBar 601 604 } 602 605 } ··· 876 879 let targetStory = fetched.indices.contains(targetIndex) ? fetched[targetIndex] : nil 877 880 if !isFullsizeCached(targetStory) { imageLoaded = false } 878 881 showLocationCopied = false 879 - stories = fetched 882 + var fetchedWithCache = fetched 883 + storyFavoriteCache.apply(to: &fetchedWithCache) 884 + stories = fetchedWithCache 880 885 currentStoryIndex = targetIndex 881 886 labelRevealed = false 882 887 isLoadingStories = false ··· 999 1004 Image(systemName: "bubble.left") 1000 1005 .font(.body) 1001 1006 .foregroundStyle(.white) 1007 + .frame(width: 36, height: 36) 1008 + .contentShape(Rectangle()) 1002 1009 } 1010 + .buttonStyle(.plain) 1003 1011 1004 1012 // "Add a comment..." — opens sheet with keyboard 1005 1013 Button { ··· 1014 1022 .padding(.horizontal, 14) 1015 1023 .padding(.vertical, 10) 1016 1024 .background(.white.opacity(0.15), in: Capsule()) 1025 + .contentShape(Capsule()) 1017 1026 } 1027 + .buttonStyle(.plain) 1018 1028 1019 1029 // Heart — like/unlike 1020 1030 Button { ··· 1025 1035 .font(.title3) 1026 1036 .foregroundStyle(isFavorited ? Color("AccentColor") : .white) 1027 1037 .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isFavorited) 1038 + .frame(width: 36, height: 36) 1039 + .contentShape(Rectangle()) 1028 1040 } 1041 + .buttonStyle(.plain) 1029 1042 .overlay { 1030 1043 ForEach(likeParticleBursts, id: \.self) { _ in 1031 1044 ForEach(0 ..< 5) { i in ··· 1069 1082 if let favUri = story.viewer?.fav { 1070 1083 // Unfavorite — optimistic 1071 1084 stories[currentStoryIndex].viewer?.fav = nil 1085 + storyFavoriteCache.setFavorite(storyUri: story.uri, favUri: nil) 1072 1086 do { 1073 1087 try await FavoriteService.delete(favoriteUri: favUri, client: client, auth: authContext) 1074 1088 } catch { 1075 1089 stories[currentStoryIndex].viewer?.fav = favUri 1090 + storyFavoriteCache.setFavorite(storyUri: story.uri, favUri: favUri) 1076 1091 } 1077 1092 } else { 1078 1093 // Favorite — optimistic ··· 1081 1096 do { 1082 1097 let response = try await FavoriteService.create(subject: story.uri, client: client, auth: authContext) 1083 1098 stories[currentStoryIndex].viewer = StoryViewerState(fav: response.uri) 1099 + storyFavoriteCache.setFavorite(storyUri: story.uri, favUri: response.uri) 1084 1100 } catch { 1085 1101 stories[currentStoryIndex].viewer = prevViewer 1086 1102 } ··· 1133 1149 .environment(LabelDefinitionsCache()) 1134 1150 .environment(ViewedStoryStorage()) 1135 1151 .environment(StoryStatusCache()) 1152 + .environment(StoryFavoriteCache()) 1136 1153 }