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: stop task re-runs and pin sheet story URI

Two tightly-correlated bugs had the same root cause: SwiftUI was
re-firing the StoryViewer .task when the comment sheet presented,
triggering loadStoriesForCurrentAuthor → presentStories again. That
reset imageLoaded and restarted the timer, which caused:
- blur/spinner flash on the story image (thumb cache bug was back)
- timer resetting to 0 and auto-advancing while the sheet was open
- the sheet collapsing because currentStory briefly became nil during
a presentStories cycle, and the .sheet content reads `if let story =
currentStory` — empty content auto-dismisses

Fixes:
- hasLoadedInitialStories guard on .task so it only loads once per
StoryViewer instance, even if SwiftUI re-fires it
- sheetStoryUri @State pinned when a comment button is tapped, so the
sheet content uses a stable URI instead of re-reading currentStory
on every render
- openCommentSheet(focusInput:) helper consolidating the three button
call sites

+138 -107
+1 -1
Grain/Utilities/PreviewData.swift
··· 220 220 createdAt: "2025-01-10T18:00:00Z", 221 221 labels: nil, 222 222 crossPost: nil, 223 - viewer: nil 223 + viewer: StoryViewerState(fav: "at://did:plc:prevuser1/social.grain.fav/f1") 224 224 ), 225 225 GrainStory( 226 226 uri: "at://did:plc:prevuser1/social.grain.story/s2",
+112 -92
Grain/Views/Stories/StoryCommentSheet.swift
··· 26 26 27 27 var body: some View { 28 28 NavigationStack { 29 - VStack(spacing: 0) { 30 - if viewModel.isLoading, viewModel.comments.isEmpty { 31 - Spacer() 32 - ProgressView() 33 - Spacer() 34 - } else if viewModel.comments.isEmpty { 35 - Spacer() 36 - Text("No comments yet") 37 - .foregroundStyle(.secondary) 38 - .font(.subheadline) 39 - Spacer() 40 - } else { 41 - ScrollView { 42 - LazyVStack(alignment: .leading, spacing: 0) { 43 - ForEach(threadedComments, id: \.root.id) { thread in 44 - CommentRow( 45 - comment: thread.root, 46 - userDID: auth.userDID, 47 - isOwn: thread.root.author.did == auth.userDID, 48 - isReply: false, 49 - onProfileTap: onProfileTap, 50 - onHashtagTap: nil, 51 - onStoryTap: nil, 52 - onReply: { startReply(to: thread.root) }, 53 - onDelete: { Task { await deleteComment(thread.root) } } 54 - ) 55 - 56 - ForEach(thread.replies) { reply in 57 - CommentRow( 58 - comment: reply, 59 - userDID: auth.userDID, 60 - isOwn: reply.author.did == auth.userDID, 61 - isReply: true, 62 - onProfileTap: onProfileTap, 63 - onHashtagTap: nil, 64 - onStoryTap: nil, 65 - onReply: { startReplyToReply(reply, root: thread.root) }, 66 - onDelete: { Task { await deleteComment(reply) } } 67 - ) 68 - } 69 - } 29 + commentList 30 + .safeAreaInset(edge: .bottom) { 31 + VStack(spacing: 0) { 32 + MentionSuggestionOverlay(state: mentionState) { suggestion in 33 + mentionState.complete(handle: suggestion.handle, in: &commentText) 70 34 } 35 + glassInputPill 71 36 } 72 37 } 73 - 74 - Divider() 75 - 76 - // Input area 77 - VStack(spacing: 0) { 78 - if let replyTarget = replyingTo { 79 - HStack { 80 - Text("Replying to @\(replyTarget.author.handle)") 81 - .font(.caption) 82 - .foregroundStyle(.secondary) 83 - Spacer() 84 - Button { 85 - replyingTo = nil 86 - } label: { 87 - Image(systemName: "xmark.circle.fill") 88 - .foregroundStyle(.secondary) 89 - } 38 + .navigationTitle("Comments") 39 + .navigationBarTitleDisplayMode(.inline) 40 + .toolbar { 41 + ToolbarItem(placement: .cancellationAction) { 42 + Button("Done") { 43 + onDismiss?() 90 44 } 91 - .padding(.horizontal) 92 - .padding(.top, 8) 93 45 } 46 + } 47 + } 48 + .presentationDetents([.medium, .large]) 49 + .task { 50 + await viewModel.loadComments(storyUri: storyUri, auth: auth.authContext()) 51 + if focusInput { 52 + commentFocused = true 53 + } 54 + } 55 + } 94 56 95 - HStack(alignment: .bottom, spacing: 8) { 96 - TextField(replyingTo != nil ? "Reply..." : "Add a comment...", text: $commentText, axis: .vertical) 97 - .textFieldStyle(.plain) 98 - .font(.body) 99 - .focused($commentFocused) 100 - .lineLimit(1 ... 5) 101 - .onChange(of: commentText) { mentionState.update(text: commentText) } 57 + // MARK: - Comment list 102 58 103 - Button { 104 - Task { await postComment() } 105 - } label: { 106 - Image(systemName: "arrow.up.circle.fill") 107 - .font(.title2) 108 - .foregroundStyle(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : Color("AccentColor")) 59 + @ViewBuilder 60 + private var commentList: some View { 61 + if viewModel.isLoading, viewModel.comments.isEmpty { 62 + Spacer() 63 + ProgressView() 64 + Spacer() 65 + } else if viewModel.comments.isEmpty { 66 + Spacer() 67 + Text("No comments yet") 68 + .foregroundStyle(.secondary) 69 + .font(.subheadline) 70 + Spacer() 71 + } else { 72 + ScrollView { 73 + LazyVStack(alignment: .leading, spacing: 0) { 74 + ForEach(threadedComments, id: \.root.id) { thread in 75 + CommentRow( 76 + comment: thread.root, 77 + userDID: auth.userDID, 78 + isOwn: thread.root.author.did == auth.userDID, 79 + isReply: false, 80 + onProfileTap: onProfileTap, 81 + onHashtagTap: nil, 82 + onStoryTap: nil, 83 + onReply: { startReply(to: thread.root) }, 84 + onDelete: { Task { await deleteComment(thread.root) } } 85 + ) 86 + 87 + ForEach(thread.replies) { reply in 88 + CommentRow( 89 + comment: reply, 90 + userDID: auth.userDID, 91 + isOwn: reply.author.did == auth.userDID, 92 + isReply: true, 93 + onProfileTap: onProfileTap, 94 + onHashtagTap: nil, 95 + onStoryTap: nil, 96 + onReply: { startReplyToReply(reply, root: thread.root) }, 97 + onDelete: { Task { await deleteComment(reply) } } 98 + ) 109 99 } 110 - .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isPostingComment) 111 100 } 112 - .padding(.horizontal) 113 - .padding(.vertical, 10) 114 101 } 115 102 } 116 - .safeAreaInset(edge: .bottom) { 117 - MentionSuggestionOverlay(state: mentionState) { suggestion in 118 - mentionState.complete(handle: suggestion.handle, in: &commentText) 103 + } 104 + } 105 + 106 + // MARK: - Glass input pill 107 + 108 + private var glassInputPill: some View { 109 + VStack(spacing: 0) { 110 + if let replyTarget = replyingTo { 111 + HStack { 112 + Text("Replying to @\(replyTarget.author.handle)") 113 + .font(.caption) 114 + .foregroundStyle(.secondary) 115 + Spacer() 116 + Button { 117 + replyingTo = nil 118 + } label: { 119 + Image(systemName: "xmark.circle.fill") 120 + .foregroundStyle(.secondary) 121 + } 119 122 } 123 + .padding(.horizontal, 14) 124 + .padding(.top, 10) 125 + .padding(.bottom, 4) 120 126 } 121 - .navigationTitle("Comments") 122 - .navigationBarTitleDisplayMode(.inline) 123 - .toolbar { 124 - ToolbarItem(placement: .cancellationAction) { 125 - Button("Done") { 126 - onDismiss?() 127 - } 127 + 128 + HStack(alignment: .bottom, spacing: 8) { 129 + TextField(replyingTo != nil ? "Reply..." : "Add a comment...", text: $commentText, axis: .vertical) 130 + .textFieldStyle(.plain) 131 + .font(.subheadline) 132 + .focused($commentFocused) 133 + .lineLimit(1 ... 5) 134 + .onChange(of: commentText) { mentionState.update(text: commentText) } 135 + 136 + Button { 137 + Task { await postComment() } 138 + } label: { 139 + Image(systemName: "arrow.up.circle.fill") 140 + .font(.title2) 141 + .foregroundStyle(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : Color("AccentColor")) 128 142 } 143 + .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isPostingComment) 129 144 } 145 + .padding(.horizontal, 14) 146 + .padding(.vertical, 10) 130 147 } 131 - .presentationDetents([.medium, .large]) 132 - .task { 133 - await viewModel.loadComments(storyUri: storyUri, auth: auth.authContext()) 134 - if focusInput { 135 - commentFocused = true 136 - } 148 + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) 149 + .overlay { 150 + RoundedRectangle(cornerRadius: 22, style: .continuous) 151 + .strokeBorder(.white.opacity(0.15), lineWidth: 0.5) 137 152 } 153 + .shadow(color: .black.opacity(0.12), radius: 8, y: 2) 154 + .padding(.horizontal, 16) 155 + .padding(.bottom, 8) 138 156 } 157 + 158 + // MARK: - Actions 139 159 140 160 private func startReply(to comment: GrainComment) { 141 161 replyingTo = comment
+25 -14
Grain/Views/Stories/StoryViewer.swift
··· 94 94 @State private var commentsViewModel: StoryCommentsViewModel 95 95 @State private var showCommentSheet = false 96 96 @State private var commentSheetFocusInput = false 97 + @State private var sheetStoryUri: String? 98 + @State private var hasLoadedInitialStories = false 97 99 @State private var hearts: [HeartAnimationState] = [] 98 100 @State private var isFavoriting = false 99 101 @State private var likeParticleBursts: [UUID] = [] ··· 185 187 if reportTarget == nil { timer.start() } 186 188 } 187 189 .sheet(isPresented: $showCommentSheet) { 188 - if let story = currentStory { 190 + if let uri = sheetStoryUri { 189 191 StoryCommentSheet( 190 192 viewModel: commentsViewModel, 191 - storyUri: story.uri, 193 + storyUri: uri, 192 194 client: client, 193 195 focusInput: commentSheetFocusInput, 194 196 onProfileTap: { did in ··· 204 206 } 205 207 } 206 208 .task { 207 - // In preview, only continue if we have prefetched stories to show (no network) 209 + // Guard against re-runs: .task can re-fire when the view re-enters the 210 + // hierarchy (e.g. after sheet presentation cycles), and we only want to 211 + // load stories once per StoryViewer instance. 212 + guard !hasLoadedInitialStories else { return } 213 + hasLoadedInitialStories = true 208 214 if isPreview, prefetchedStories.isEmpty { return } 209 215 let startAuthor = authors[currentAuthorIndex] 210 216 let isOwn = startAuthor.profile.did == auth.userDID ··· 583 589 // Latest comment preview 584 590 if let latest = commentsViewModel.latestComment { 585 591 Button { 586 - timer.stop() 587 - commentSheetFocusInput = false 588 - showCommentSheet = true 592 + openCommentSheet(focusInput: false) 589 593 } label: { 590 594 HStack(spacing: 6) { 591 595 AvatarView(url: latest.author.avatar, size: 20, animated: false) ··· 994 998 995 999 // MARK: - Comments & Likes 996 1000 1001 + /// Open the comment sheet, pinning the URI at open time so the sheet 1002 + /// content doesn't depend on a live reading of `currentStory` (which can 1003 + /// flicker to nil during view re-renders). 1004 + private func openCommentSheet(focusInput: Bool) { 1005 + guard let uri = currentStory?.uri else { return } 1006 + timer.stop() 1007 + sheetStoryUri = uri 1008 + commentSheetFocusInput = focusInput 1009 + showCommentSheet = true 1010 + } 1011 + 997 1012 private var isFavorited: Bool { 998 1013 guard let story = currentStory else { return false } 999 1014 return story.viewer?.fav != nil || storyFavoriteCache.isLiked(story.uri) ··· 1003 1018 HStack(spacing: 12) { 1004 1019 // Comment bubble — opens comment list 1005 1020 Button { 1006 - timer.stop() 1007 - commentSheetFocusInput = false 1008 - showCommentSheet = true 1021 + openCommentSheet(focusInput: false) 1009 1022 } label: { 1010 1023 VStack(spacing: 2) { 1011 1024 Image(systemName: "bubble.left") ··· 1017 1030 } 1018 1031 } 1019 1032 .foregroundStyle(.white) 1020 - .frame(width: 36, minHeight: 36) 1033 + .frame(width: 36) 1021 1034 .contentShape(Rectangle()) 1022 1035 } 1023 1036 .buttonStyle(.plain) 1024 1037 1025 1038 // "Add a comment..." — opens sheet with keyboard 1026 1039 Button { 1027 - timer.stop() 1028 - commentSheetFocusInput = true 1029 - showCommentSheet = true 1040 + openCommentSheet(focusInput: true) 1030 1041 } label: { 1031 1042 Text("Add a comment...") 1032 1043 .font(.subheadline) ··· 1034 1045 .frame(maxWidth: .infinity, alignment: .leading) 1035 1046 .padding(.horizontal, 14) 1036 1047 .padding(.vertical, 10) 1037 - .background(.white.opacity(0.15), in: Capsule()) 1048 + .background(.ultraThinMaterial, in: Capsule()) 1038 1049 .contentShape(Capsule()) 1039 1050 } 1040 1051 .buttonStyle(.plain)