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.

feat: reply-to-reply threading and comment icon opens comment sheet

- Reply button on child comments, prefills @username with shared parent
- Comment bubble in engagement row opens comment sheet in detail view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+51 -4
+51 -4
Grain/Views/Gallery/GalleryDetailView.swift
··· 12 12 @State private var showDeleteConfirmation = false 13 13 @State private var showReportSheet = false 14 14 @State private var showCommentSheet = false 15 + @State private var zoomState = ImageZoomState() 16 + @State private var cardStoryAuthor: GrainStoryAuthor? 15 17 @FocusState private var commentFocused: Bool 16 18 @Environment(\.dismiss) private var dismiss 17 19 ··· 46 48 set: { viewModel.gallery = $0 } 47 49 ), 48 50 client: client, 51 + onNavigate: { 52 + replyingTo = nil 53 + showCommentSheet = true 54 + }, 49 55 onProfileTap: { did in 50 56 selectedProfileDid = did 51 57 }, 52 58 onHashtagTap: { tag in 53 59 selectedHashtag = tag 60 + }, 61 + onStoryTap: { author in 62 + cardStoryAuthor = author 54 63 } 55 64 ) 56 65 ··· 80 89 isReply: false, 81 90 onProfileTap: { did in selectedProfileDid = did }, 82 91 onHashtagTap: { tag in selectedHashtag = tag }, 92 + onStoryTap: { author in cardStoryAuthor = author }, 83 93 onReply: { startReply(to: thread.root) }, 84 94 onDelete: { Task { await deleteComment(thread.root) } } 85 95 ) ··· 91 101 isReply: true, 92 102 onProfileTap: { did in selectedProfileDid = did }, 93 103 onHashtagTap: { tag in selectedHashtag = tag }, 104 + onStoryTap: { author in cardStoryAuthor = author }, 105 + onReply: { startReplyToReply(reply, root: thread.root) }, 94 106 onDelete: { Task { await deleteComment(reply) } } 95 107 ) 96 108 } ··· 103 115 .padding(.top, 100) 104 116 } 105 117 } 118 + .environment(zoomState) 119 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 106 120 .navigationBarTitleDisplayMode(.inline) 107 121 .navigationDestination(item: $selectedProfileDid) { did in 108 122 ProfileView(client: client, did: did) ··· 130 144 } 131 145 } label: { 132 146 Image(systemName: "ellipsis") 133 - .foregroundStyle(.primary) 134 147 } 148 + .tint(.primary) 135 149 .disabled(viewModel.gallery == nil) 136 150 } 137 151 } ··· 147 161 if let gallery = viewModel.gallery { 148 162 ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid) 149 163 } 164 + } 165 + .fullScreenCover(item: $cardStoryAuthor) { author in 166 + StoryViewer( 167 + authors: [author], 168 + client: client, 169 + onProfileTap: { did in 170 + cardStoryAuthor = nil 171 + selectedProfileDid = did 172 + }, 173 + onDismiss: { cardStoryAuthor = nil } 174 + ) 175 + .environment(auth) 150 176 } 151 177 .sheet(isPresented: $showCommentSheet) { 152 178 NavigationStack { ··· 210 236 211 237 private func startReply(to comment: GrainComment) { 212 238 replyingTo = comment 239 + commentText = "" 240 + showCommentSheet = true 241 + } 242 + 243 + private func startReplyToReply(_ reply: GrainComment, root: GrainComment) { 244 + replyingTo = root 245 + commentText = "@\(reply.author.handle) " 213 246 showCommentSheet = true 214 247 } 215 248 ··· 265 298 } 266 299 267 300 struct CommentRow: View { 301 + @Environment(StoryStatusCache.self) private var storyStatusCache 268 302 let comment: GrainComment 269 303 var isOwn: Bool = false 270 304 var isReply: Bool = false 271 305 var onProfileTap: ((String) -> Void)? 272 306 var onHashtagTap: ((String) -> Void)? 307 + var onStoryTap: ((GrainStoryAuthor) -> Void)? 273 308 var onReply: (() -> Void)? 274 309 var onDelete: (() -> Void)? 275 310 276 311 var body: some View { 277 312 HStack(alignment: .top, spacing: 8) { 278 - AvatarView(url: comment.author.avatar, size: isReply ? 24 : 28) 279 - .onTapGesture { onProfileTap?(comment.author.did) } 313 + let avatarSize: CGFloat = isReply ? 24 : 28 314 + StoryRingView(hasStory: storyStatusCache.hasStory(for: comment.author.did), size: avatarSize) { 315 + AvatarView(url: comment.author.avatar, size: avatarSize) 316 + } 317 + .onTapGesture { 318 + if let author = storyStatusCache.author(for: comment.author.did) { 319 + onStoryTap?(author) 320 + } else { 321 + onProfileTap?(comment.author.did) 322 + } 323 + } 324 + .onLongPressGesture { 325 + onProfileTap?(comment.author.did) 326 + } 280 327 281 328 VStack(alignment: .leading, spacing: 2) { 282 329 HStack(spacing: 4) { ··· 295 342 296 343 // Actions 297 344 HStack(spacing: 16) { 298 - if !isReply, onReply != nil { 345 + if onReply != nil { 299 346 Button { 300 347 onReply?() 301 348 } label: {