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.

Merge pull request #13 from grainsocial/feat/comment-sheet

Instagram-style comment sheet with iMessage glass input

authored by

Hima and committed by
GitHub
40d8facf 4c0c7314

+435 -166
+2 -1
Grain/Views/Components/GalleryCardView.swift
··· 139 139 @Binding var gallery: GrainGallery 140 140 let client: XRPCClient 141 141 var onNavigate: () -> Void = {} 142 + var onCommentTap: (() -> Void)? 142 143 var onProfileTap: ((String) -> Void)? 143 144 var onHashtagTap: ((String) -> Void)? 144 145 var onLocationTap: ((String, String) -> Void)? ··· 424 425 } 425 426 426 427 Button { 427 - onNavigate() 428 + (onCommentTap ?? onNavigate)() 428 429 } label: { 429 430 HStack(spacing: 5) { 430 431 Image(systemName: "bubble.right")
+32
Grain/Views/Feed/CameraFeedView.swift
··· 12 12 @State private var selectedLocation: LocationDestination? 13 13 @State private var zoomState = ImageZoomState() 14 14 @State private var cardStoryAuthor: GrainStoryAuthor? 15 + @State private var commentSheetUri: String? 15 16 16 17 let client: XRPCClient 17 18 let camera: String ··· 26 27 ForEach($galleries) { $gallery in 27 28 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 28 29 selectedUri = gallery.uri 30 + }, onCommentTap: { 31 + commentSheetUri = gallery.uri 29 32 }, onProfileTap: { did in 30 33 selectedProfileDid = did 31 34 }, onHashtagTap: { tag in ··· 95 98 onDismiss: { cardStoryAuthor = nil } 96 99 ) 97 100 .environment(auth) 101 + } 102 + .sheet(isPresented: Binding( 103 + get: { commentSheetUri != nil }, 104 + set: { if !$0 { commentSheetUri = nil } } 105 + )) { 106 + if let uri = commentSheetUri { 107 + CommentSheetView( 108 + client: client, 109 + galleryUri: uri, 110 + onDismiss: { commentSheetUri = nil }, 111 + onProfileTap: { did in 112 + commentSheetUri = nil 113 + selectedProfileDid = did 114 + }, 115 + onHashtagTap: { tag in 116 + commentSheetUri = nil 117 + selectedHashtag = tag 118 + }, 119 + onStoryTap: { author in 120 + commentSheetUri = nil 121 + cardStoryAuthor = author 122 + }, 123 + onCommentCountChanged: { count in 124 + if let idx = galleries.firstIndex(where: { $0.uri == uri }) { 125 + galleries[idx].commentCount = count 126 + } 127 + } 128 + ) 129 + } 98 130 } 99 131 .task { 100 132 guard !isPreview else {
+32
Grain/Views/Feed/FeedView.swift
··· 239 239 @State private var deletedGalleryUri: String? 240 240 @State private var zoomState = ImageZoomState() 241 241 @State private var cardStoryAuthor: GrainStoryAuthor? 242 + @State private var commentSheetUri: String? 242 243 @AppStorage("privacy.showSuggestedUsers") private var showSuggestedUsers = true 243 244 @State private var suggestedFollows: [SuggestedItem] = [] 244 245 @State private var suggestedLoaded = false ··· 292 293 ForEach(Array($viewModel.galleries.enumerated()), id: \.element.id) { index, $gallery in 293 294 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 294 295 selectedUri = gallery.uri 296 + }, onCommentTap: { 297 + commentSheetUri = gallery.uri 295 298 }, onProfileTap: { did in 296 299 selectedProfileDid = did 297 300 }, onHashtagTap: { tag in ··· 361 364 onDismiss: { cardStoryAuthor = nil } 362 365 ) 363 366 .environment(auth) 367 + } 368 + .sheet(isPresented: Binding( 369 + get: { commentSheetUri != nil }, 370 + set: { if !$0 { commentSheetUri = nil } } 371 + )) { 372 + if let uri = commentSheetUri { 373 + CommentSheetView( 374 + client: client, 375 + galleryUri: uri, 376 + onDismiss: { commentSheetUri = nil }, 377 + onProfileTap: { did in 378 + commentSheetUri = nil 379 + selectedProfileDid = did 380 + }, 381 + onHashtagTap: { tag in 382 + commentSheetUri = nil 383 + selectedHashtag = tag 384 + }, 385 + onStoryTap: { author in 386 + commentSheetUri = nil 387 + cardStoryAuthor = author 388 + }, 389 + onCommentCountChanged: { count in 390 + if let idx = viewModel.galleries.firstIndex(where: { $0.uri == uri }) { 391 + viewModel.galleries[idx].commentCount = count 392 + } 393 + } 394 + ) 395 + } 364 396 } 365 397 .task { 366 398 guard !isPreview else {
+32
Grain/Views/Feed/HashtagFeedView.swift
··· 12 12 @State private var selectedLocation: LocationDestination? 13 13 @State private var zoomState = ImageZoomState() 14 14 @State private var cardStoryAuthor: GrainStoryAuthor? 15 + @State private var commentSheetUri: String? 15 16 16 17 let client: XRPCClient 17 18 let tag: String ··· 26 27 ForEach($galleries) { $gallery in 27 28 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 28 29 selectedUri = gallery.uri 30 + }, onCommentTap: { 31 + commentSheetUri = gallery.uri 29 32 }, onProfileTap: { did in 30 33 selectedProfileDid = did 31 34 }, onHashtagTap: { tag in ··· 95 98 onDismiss: { cardStoryAuthor = nil } 96 99 ) 97 100 .environment(auth) 101 + } 102 + .sheet(isPresented: Binding( 103 + get: { commentSheetUri != nil }, 104 + set: { if !$0 { commentSheetUri = nil } } 105 + )) { 106 + if let uri = commentSheetUri { 107 + CommentSheetView( 108 + client: client, 109 + galleryUri: uri, 110 + onDismiss: { commentSheetUri = nil }, 111 + onProfileTap: { did in 112 + commentSheetUri = nil 113 + selectedProfileDid = did 114 + }, 115 + onHashtagTap: { tag in 116 + commentSheetUri = nil 117 + selectedHashtag = tag 118 + }, 119 + onStoryTap: { author in 120 + commentSheetUri = nil 121 + cardStoryAuthor = author 122 + }, 123 + onCommentCountChanged: { count in 124 + if let idx = galleries.firstIndex(where: { $0.uri == uri }) { 125 + galleries[idx].commentCount = count 126 + } 127 + } 128 + ) 129 + } 98 130 } 99 131 .task { 100 132 guard !isPreview else {
+32
Grain/Views/Feed/LocationFeedView.swift
··· 12 12 @State private var selectedHashtag: String? 13 13 @State private var zoomState = ImageZoomState() 14 14 @State private var cardStoryAuthor: GrainStoryAuthor? 15 + @State private var commentSheetUri: String? 15 16 @State private var mapInteractive = false 16 17 17 18 let client: XRPCClient ··· 59 60 ForEach($galleries) { $gallery in 60 61 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 61 62 selectedUri = gallery.uri 63 + }, onCommentTap: { 64 + commentSheetUri = gallery.uri 62 65 }, onProfileTap: { did in 63 66 selectedProfileDid = did 64 67 }, onHashtagTap: { tag in ··· 123 126 onDismiss: { cardStoryAuthor = nil } 124 127 ) 125 128 .environment(auth) 129 + } 130 + .sheet(isPresented: Binding( 131 + get: { commentSheetUri != nil }, 132 + set: { if !$0 { commentSheetUri = nil } } 133 + )) { 134 + if let uri = commentSheetUri { 135 + CommentSheetView( 136 + client: client, 137 + galleryUri: uri, 138 + onDismiss: { commentSheetUri = nil }, 139 + onProfileTap: { did in 140 + commentSheetUri = nil 141 + selectedProfileDid = did 142 + }, 143 + onHashtagTap: { tag in 144 + commentSheetUri = nil 145 + selectedHashtag = tag 146 + }, 147 + onStoryTap: { author in 148 + commentSheetUri = nil 149 + cardStoryAuthor = author 150 + }, 151 + onCommentCountChanged: { count in 152 + if let idx = galleries.firstIndex(where: { $0.uri == uri }) { 153 + galleries[idx].commentCount = count 154 + } 155 + } 156 + ) 157 + } 126 158 } 127 159 .task { 128 160 guard !isPreview else {
+241
Grain/Views/Gallery/CommentSheetView.swift
··· 1 + import SwiftUI 2 + 3 + struct CommentSheetView: View { 4 + @Environment(AuthManager.self) private var auth 5 + @State private var viewModel: GalleryDetailViewModel 6 + @State private var commentText = "" 7 + @State private var isPostingComment = false 8 + @State private var replyingTo: GrainComment? 9 + @State private var mentionState = MentionAutocompleteState() 10 + @FocusState private var commentFocused: Bool 11 + 12 + let client: XRPCClient 13 + let galleryUri: String 14 + var onDismiss: () -> Void = {} 15 + var onProfileTap: ((String) -> Void)? 16 + var onHashtagTap: ((String) -> Void)? 17 + var onStoryTap: ((GrainStoryAuthor) -> Void)? 18 + var onCommentCountChanged: ((Int) -> Void)? 19 + 20 + init( 21 + client: XRPCClient, 22 + galleryUri: String, 23 + onDismiss: @escaping () -> Void = {}, 24 + onProfileTap: ((String) -> Void)? = nil, 25 + onHashtagTap: ((String) -> Void)? = nil, 26 + onStoryTap: ((GrainStoryAuthor) -> Void)? = nil, 27 + onCommentCountChanged: ((Int) -> Void)? = nil 28 + ) { 29 + self.client = client 30 + self.galleryUri = galleryUri 31 + self.onDismiss = onDismiss 32 + self.onProfileTap = onProfileTap 33 + self.onHashtagTap = onHashtagTap 34 + self.onStoryTap = onStoryTap 35 + self.onCommentCountChanged = onCommentCountChanged 36 + _viewModel = State(initialValue: GalleryDetailViewModel(client: client)) 37 + } 38 + 39 + private var threadedComments: [(root: GrainComment, replies: [GrainComment])] { 40 + let roots = viewModel.comments.filter { $0.replyTo == nil } 41 + let replyMap = Dictionary(grouping: viewModel.comments.filter { $0.replyTo != nil }, by: { $0.replyTo! }) 42 + return roots.map { root in 43 + (root: root, replies: replyMap[root.uri] ?? []) 44 + } 45 + } 46 + 47 + var body: some View { 48 + NavigationStack { 49 + VStack(spacing: 0) { 50 + ScrollView { 51 + if viewModel.comments.isEmpty, !viewModel.isLoading { 52 + Text("No comments yet") 53 + .font(.subheadline) 54 + .foregroundStyle(.tertiary) 55 + .frame(maxWidth: .infinity) 56 + .padding(.top, 60) 57 + } else if viewModel.isLoading, viewModel.comments.isEmpty { 58 + ProgressView() 59 + .frame(maxWidth: .infinity) 60 + .padding(.top, 60) 61 + } else { 62 + LazyVStack(alignment: .leading, spacing: 0) { 63 + ForEach(threadedComments, id: \.root.id) { thread in 64 + CommentRow( 65 + comment: thread.root, 66 + userDID: auth.userDID, 67 + isOwn: thread.root.author.did == auth.userDID, 68 + isReply: false, 69 + onProfileTap: { did in 70 + onDismiss() 71 + onProfileTap?(did) 72 + }, 73 + onHashtagTap: { tag in 74 + onDismiss() 75 + onHashtagTap?(tag) 76 + }, 77 + onStoryTap: { author in 78 + onDismiss() 79 + onStoryTap?(author) 80 + }, 81 + onReply: { startReply(to: thread.root) }, 82 + onDelete: { Task { await deleteComment(thread.root) } } 83 + ) 84 + 85 + ForEach(thread.replies) { reply in 86 + CommentRow( 87 + comment: reply, 88 + userDID: auth.userDID, 89 + isOwn: reply.author.did == auth.userDID, 90 + isReply: true, 91 + onProfileTap: { did in 92 + onDismiss() 93 + onProfileTap?(did) 94 + }, 95 + onHashtagTap: { tag in 96 + onDismiss() 97 + onHashtagTap?(tag) 98 + }, 99 + onStoryTap: { author in 100 + onDismiss() 101 + onStoryTap?(author) 102 + }, 103 + onReply: { startReplyToReply(reply, root: thread.root) }, 104 + onDelete: { Task { await deleteComment(reply) } } 105 + ) 106 + } 107 + } 108 + } 109 + } 110 + } 111 + .safeAreaInset(edge: .bottom) { 112 + VStack(spacing: 0) { 113 + MentionSuggestionOverlay(state: mentionState) { suggestion in 114 + mentionState.complete(handle: suggestion.handle, in: &commentText) 115 + } 116 + 117 + if let replyTarget = replyingTo { 118 + HStack { 119 + Text("Replying to @\(replyTarget.author.handle)") 120 + .font(.caption) 121 + .foregroundStyle(.secondary) 122 + Spacer() 123 + Button { 124 + replyingTo = nil 125 + } label: { 126 + Image(systemName: "xmark.circle.fill") 127 + .font(.caption) 128 + .foregroundStyle(.secondary) 129 + } 130 + } 131 + .padding(.horizontal, 16) 132 + .padding(.top, 4) 133 + } 134 + 135 + GlassEffectContainer(spacing: 8) { 136 + HStack(alignment: .bottom, spacing: 10) { 137 + TextField( 138 + replyingTo != nil ? "Reply..." : "Add a comment...", 139 + text: $commentText, 140 + axis: .vertical 141 + ) 142 + .textFieldStyle(.plain) 143 + .font(.body) 144 + .focused($commentFocused) 145 + .lineLimit(1 ... 6) 146 + .onChange(of: commentText) { mentionState.update(text: commentText) } 147 + .padding(.horizontal, 18) 148 + .padding(.vertical, 14) 149 + .glassEffect(.regular, in: .capsule) 150 + 151 + let isEmpty = commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 152 + if !isEmpty { 153 + Button { 154 + Task { await postComment() } 155 + } label: { 156 + Image(systemName: "arrow.up.circle.fill") 157 + .font(.system(size: 28)) 158 + .foregroundStyle(Color("AccentColor")) 159 + .frame(width: 44, height: 44) 160 + } 161 + .glassEffect(.regular.interactive(), in: .circle) 162 + .disabled(isPostingComment) 163 + .transition(.scale.combined(with: .opacity)) 164 + } 165 + } 166 + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: commentText.isEmpty) 167 + .padding(.horizontal, 12) 168 + .padding(.vertical, 10) 169 + } 170 + } 171 + } 172 + } 173 + .navigationTitle("Comments") 174 + .navigationBarTitleDisplayMode(.inline) 175 + .toolbar { 176 + ToolbarItem(placement: .topBarLeading) { 177 + Button { 178 + onDismiss() 179 + } label: { 180 + Image(systemName: "xmark") 181 + .font(.subheadline.weight(.semibold)) 182 + .foregroundStyle(.primary) 183 + } 184 + } 185 + } 186 + } 187 + .presentationDetents([.medium, .large]) 188 + .presentationDragIndicator(.visible) 189 + .task { 190 + await viewModel.load(uri: galleryUri, auth: auth.authContext()) 191 + } 192 + } 193 + 194 + private func startReply(to comment: GrainComment) { 195 + replyingTo = comment 196 + commentText = "" 197 + commentFocused = true 198 + } 199 + 200 + private func startReplyToReply(_ reply: GrainComment, root: GrainComment) { 201 + replyingTo = root 202 + commentText = "@\(reply.author.handle) " 203 + commentFocused = true 204 + } 205 + 206 + private func postComment() async { 207 + let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) 208 + guard !text.isEmpty, let authContext = await auth.authContext() else { return } 209 + 210 + isPostingComment = true 211 + var recordDict: [String: String] = [ 212 + "text": text, 213 + "subject": galleryUri, 214 + "createdAt": DateFormatting.nowISO(), 215 + ] 216 + if let replyTarget = replyingTo { 217 + recordDict["replyTo"] = replyTarget.uri 218 + } 219 + let record = AnyCodable(recordDict) 220 + let repo = TokenStorage.userDID ?? "" 221 + do { 222 + _ = try await client.createRecord(collection: "social.grain.comment", repo: repo, record: record, auth: authContext) 223 + commentText = "" 224 + replyingTo = nil 225 + commentFocused = false 226 + await viewModel.load(uri: galleryUri, auth: authContext) 227 + onCommentCountChanged?(viewModel.comments.count) 228 + } catch {} 229 + isPostingComment = false 230 + } 231 + 232 + private func deleteComment(_ comment: GrainComment) async { 233 + guard let authContext = await auth.authContext() else { return } 234 + let rkey = comment.uri.split(separator: "/").last.map(String.init) ?? "" 235 + do { 236 + try await client.deleteRecord(collection: "social.grain.comment", rkey: rkey, auth: authContext) 237 + viewModel.comments.removeAll { $0.uri == comment.uri } 238 + onCommentCountChanged?(viewModel.comments.count) 239 + } catch {} 240 + } 241 + }
+32 -165
Grain/Views/Gallery/GalleryDetailView.swift
··· 7 7 @State private var selectedProfileDid: String? 8 8 @State private var selectedHashtag: String? 9 9 @State private var selectedLocation: LocationDestination? 10 - @State private var commentText = "" 11 - @State private var isPostingComment = false 12 - @State private var replyingTo: GrainComment? 13 10 @State private var showDeleteConfirmation = false 14 11 @State private var showReportSheet = false 15 12 @State private var showCommentSheet = false 16 13 @State private var zoomState = ImageZoomState() 17 14 @State private var cardStoryAuthor: GrainStoryAuthor? 18 - @State private var mentionState = MentionAutocompleteState() 19 - @FocusState private var commentFocused: Bool 20 15 @Environment(\.dismiss) private var dismiss 21 16 22 17 let client: XRPCClient ··· 30 25 _deletedGalleryUri = deletedGalleryUri 31 26 } 32 27 33 - /// Group comments into roots with their replies underneath. 34 - private var threadedComments: [(root: GrainComment, replies: [GrainComment])] { 35 - let roots = viewModel.comments.filter { $0.replyTo == nil } 36 - let replyMap = Dictionary(grouping: viewModel.comments.filter { $0.replyTo != nil }, by: { $0.replyTo! }) 37 - return roots.map { root in 38 - (root: root, replies: replyMap[root.uri] ?? []) 39 - } 40 - } 41 - 42 28 var body: some View { 43 29 ScrollView { 44 30 if viewModel.gallery != nil { 45 31 VStack(spacing: 0) { 46 - // Reuse the feed card 47 32 GalleryCardView( 48 33 gallery: Binding( 49 34 get: { viewModel.gallery! }, ··· 51 36 ), 52 37 client: client, 53 38 onNavigate: { 54 - replyingTo = nil 39 + showCommentSheet = true 40 + }, 41 + onCommentTap: { 55 42 showCommentSheet = true 56 43 }, 57 44 onProfileTap: { did in ··· 68 55 } 69 56 ) 70 57 71 - // Add comment button 58 + // View comments button 72 59 Button { 73 60 showCommentSheet = true 74 61 } label: { 75 62 HStack { 76 - Image(systemName: "bubble.left") 77 - Text("Add a comment...") 78 - .foregroundStyle(.secondary) 63 + let count = viewModel.gallery?.commentCount ?? 0 64 + if count > 0 { 65 + Text("View all \(count) comments") 66 + } else { 67 + Text("Add a comment...") 68 + } 79 69 Spacer() 80 70 } 81 71 .font(.subheadline) 72 + .foregroundStyle(.secondary) 82 73 .padding(.horizontal, 12) 83 74 .padding(.vertical, 12) 84 75 } 85 76 .buttonStyle(.plain) 86 - 87 - // Threaded comments 88 - if !viewModel.comments.isEmpty { 89 - LazyVStack(alignment: .leading, spacing: 0) { 90 - ForEach(threadedComments, id: \.root.id) { thread in 91 - CommentRow( 92 - comment: thread.root, 93 - userDID: auth.userDID, 94 - isOwn: thread.root.author.did == auth.userDID, 95 - isReply: false, 96 - onProfileTap: { did in selectedProfileDid = did }, 97 - onHashtagTap: { tag in selectedHashtag = tag }, 98 - onStoryTap: { author in cardStoryAuthor = author }, 99 - onReply: { startReply(to: thread.root) }, 100 - onDelete: { Task { await deleteComment(thread.root) } } 101 - ) 102 - 103 - ForEach(thread.replies) { reply in 104 - CommentRow( 105 - comment: reply, 106 - userDID: auth.userDID, 107 - isOwn: reply.author.did == auth.userDID, 108 - isReply: true, 109 - onProfileTap: { did in selectedProfileDid = did }, 110 - onHashtagTap: { tag in selectedHashtag = tag }, 111 - onStoryTap: { author in cardStoryAuthor = author }, 112 - onReply: { startReplyToReply(reply, root: thread.root) }, 113 - onDelete: { Task { await deleteComment(reply) } } 114 - ) 115 - } 116 - } 117 - } 118 - } 119 77 } 120 78 } else { 121 79 ProgressView() ··· 185 143 .environment(auth) 186 144 } 187 145 .sheet(isPresented: $showCommentSheet) { 188 - NavigationStack { 189 - VStack(spacing: 0) { 190 - if let replyTarget = replyingTo { 191 - HStack { 192 - Text("Replying to @\(replyTarget.author.handle)") 193 - .font(.caption) 194 - .foregroundStyle(.secondary) 195 - Spacer() 196 - Button { 197 - replyingTo = nil 198 - } label: { 199 - Image(systemName: "xmark.circle.fill") 200 - .foregroundStyle(.secondary) 201 - } 202 - } 203 - .padding(.horizontal) 204 - .padding(.top, 8) 205 - } 206 - 207 - TextField(replyingTo != nil ? "Reply..." : "Write a comment...", text: $commentText, axis: .vertical) 208 - .textFieldStyle(.plain) 209 - .font(.body) 210 - .focused($commentFocused) 211 - .padding() 212 - .lineLimit(5 ... 10) 213 - .onChange(of: commentText) { mentionState.update(text: commentText) } 214 - 215 - Spacer() 146 + CommentSheetView( 147 + client: client, 148 + galleryUri: galleryUri, 149 + onDismiss: { showCommentSheet = false }, 150 + onProfileTap: { did in 151 + showCommentSheet = false 152 + selectedProfileDid = did 153 + }, 154 + onHashtagTap: { tag in 155 + showCommentSheet = false 156 + selectedHashtag = tag 157 + }, 158 + onStoryTap: { author in 159 + showCommentSheet = false 160 + cardStoryAuthor = author 161 + }, 162 + onCommentCountChanged: { count in 163 + viewModel.gallery?.commentCount = count 216 164 } 217 - .safeAreaInset(edge: .bottom) { 218 - MentionSuggestionOverlay(state: mentionState) { suggestion in 219 - mentionState.complete(handle: suggestion.handle, in: &commentText) 220 - } 221 - } 222 - .navigationTitle(replyingTo != nil ? "Reply" : "Comment") 223 - .navigationBarTitleDisplayMode(.inline) 224 - .toolbar { 225 - ToolbarItem(placement: .cancellationAction) { 226 - Button("Cancel") { 227 - showCommentSheet = false 228 - commentText = "" 229 - replyingTo = nil 230 - } 231 - } 232 - ToolbarItem(placement: .confirmationAction) { 233 - Button("Post") { 234 - Task { 235 - await postComment() 236 - showCommentSheet = false 237 - } 238 - } 239 - .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment) 240 - } 241 - } 242 - } 243 - .presentationDetents([.medium]) 244 - .onAppear { 245 - commentFocused = true 246 - } 165 + ) 247 166 } 248 167 .task { 249 168 guard !isPreview else { ··· 257 176 } 258 177 } 259 178 260 - private func startReply(to comment: GrainComment) { 261 - replyingTo = comment 262 - commentText = "" 263 - showCommentSheet = true 264 - } 265 - 266 - private func startReplyToReply(_ reply: GrainComment, root: GrainComment) { 267 - replyingTo = root 268 - commentText = "@\(reply.author.handle) " 269 - showCommentSheet = true 270 - } 271 - 272 - private func postComment() async { 273 - let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) 274 - guard !text.isEmpty, let authContext = await auth.authContext() else { return } 275 - 276 - isPostingComment = true 277 - var recordDict: [String: String] = [ 278 - "text": text, 279 - "subject": galleryUri, 280 - "createdAt": DateFormatting.nowISO(), 281 - ] 282 - if let replyTarget = replyingTo { 283 - recordDict["replyTo"] = replyTarget.uri 284 - } 285 - let record = AnyCodable(recordDict) 286 - let repo = TokenStorage.userDID ?? "" 287 - do { 288 - _ = try await client.createRecord(collection: "social.grain.comment", repo: repo, record: record, auth: authContext) 289 - commentText = "" 290 - replyingTo = nil 291 - commentFocused = false 292 - await viewModel.load(uri: galleryUri, auth: authContext) 293 - } catch { 294 - // Silently fail for now 295 - } 296 - isPostingComment = false 297 - } 298 - 299 179 private func deleteGallery() async { 300 180 guard let authContext = await auth.authContext() else { return } 301 181 let rkey = galleryUri.split(separator: "/").last.map(String.init) ?? "" ··· 303 183 try await client.deleteGallery(rkey: rkey, auth: authContext) 304 184 deletedGalleryUri = galleryUri 305 185 dismiss() 306 - } catch { 307 - // Silently fail for now 308 - } 309 - } 310 - 311 - private func deleteComment(_ comment: GrainComment) async { 312 - guard let authContext = await auth.authContext() else { return } 313 - let rkey = comment.uri.split(separator: "/").last.map(String.init) ?? "" 314 - do { 315 - try await client.deleteRecord(collection: "social.grain.comment", rkey: rkey, auth: authContext) 316 - viewModel.comments.removeAll { $0.uri == comment.uri } 317 - } catch { 318 - // Silently fail for now 319 - } 186 + } catch {} 320 187 } 321 188 } 322 189 190 + /// CommentRow stays here since it's shared 323 191 struct CommentRow: View { 324 192 @Environment(StoryStatusCache.self) private var storyStatusCache 325 193 @Environment(ViewedStoryStorage.self) private var viewedStories ··· 387 255 onHashtagTap: onHashtagTap 388 256 ) 389 257 390 - // Actions 391 258 HStack(spacing: 16) { 392 259 if onReply != nil { 393 260 Button {
+32
Grain/Views/Search/SearchView.swift
··· 12 12 @State private var selectedLocation: LocationDestination? 13 13 @State private var zoomState = ImageZoomState() 14 14 @State private var cardStoryAuthor: GrainStoryAuthor? 15 + @State private var commentSheetUri: String? 15 16 @State private var recentSearches = RecentSearchStorage() 16 17 @State private var searchIsPresented = false 17 18 let client: XRPCClient ··· 38 39 ForEach($viewModel.galleryResults) { $gallery in 39 40 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 40 41 searchNavigationUri = gallery.uri 42 + }, onCommentTap: { 43 + commentSheetUri = gallery.uri 41 44 }, onProfileTap: { did in 42 45 selectedProfileDid = did 43 46 }, onHashtagTap: { tag in ··· 127 130 onDismiss: { cardStoryAuthor = nil } 128 131 ) 129 132 .environment(auth) 133 + } 134 + .sheet(isPresented: Binding( 135 + get: { commentSheetUri != nil }, 136 + set: { if !$0 { commentSheetUri = nil } } 137 + )) { 138 + if let uri = commentSheetUri { 139 + CommentSheetView( 140 + client: client, 141 + galleryUri: uri, 142 + onDismiss: { commentSheetUri = nil }, 143 + onProfileTap: { did in 144 + commentSheetUri = nil 145 + selectedProfileDid = did 146 + }, 147 + onHashtagTap: { tag in 148 + commentSheetUri = nil 149 + selectedHashtag = tag 150 + }, 151 + onStoryTap: { author in 152 + commentSheetUri = nil 153 + cardStoryAuthor = author 154 + }, 155 + onCommentCountChanged: { count in 156 + if let idx = viewModel.galleryResults.firstIndex(where: { $0.uri == uri }) { 157 + viewModel.galleryResults[idx].commentCount = count 158 + } 159 + } 160 + ) 161 + } 130 162 } 131 163 } 132 164 }