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: restore gallery card kebab menu and fix build errors

- Add kebab (ellipsis) button and action sheet to GalleryCardView
- Wire onReport/onDelete in all feed views
- Remove redundant toolbar menu from GalleryDetailView
- Fix ReportView parameter names (uri/cid → subjectUri/subjectCid)
- Fix HashtagFeedView type-checker timeout by extracting card helper
- Fix StoryViewer missing self. in closure

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

+259 -82
+47
Grain/Views/Components/GalleryCardView.swift
··· 200 200 var onHashtagTap: ((String) -> Void)? 201 201 var onLocationTap: ((String, String) -> Void)? 202 202 var onStoryTap: ((GrainStoryAuthor) -> Void)? 203 + var onReport: (() -> Void)? 204 + var onDelete: (() -> Void)? 203 205 @State private var isFavoriting = false 206 + @State private var showCardActions = false 204 207 @State private var likeParticleBursts: [UUID] = [] 205 208 @State private var currentPage = 0 206 209 @State private var showingAlt = false ··· 292 295 } 293 296 294 297 Spacer() 298 + 299 + if onReport != nil || onDelete != nil { 300 + Button { showCardActions = true } label: { 301 + Image(systemName: "ellipsis") 302 + .font(.system(size: 14, weight: .medium)) 303 + .foregroundStyle(.primary) 304 + .frame(width: 32, height: 32) 305 + } 306 + .buttonStyle(.plain) 307 + } 295 308 } 296 309 .padding(.horizontal, 12) 297 310 .padding(.vertical, 8) 298 311 .contentShape(Rectangle()) 299 312 .onTapGesture { onProfileTap?(gallery.creator.did) } 313 + .sheet(isPresented: $showCardActions) { 314 + GalleryActionsSheet(onReport: onReport, onDelete: onDelete) 315 + .presentationDetents([.height(200)]) 316 + } 300 317 } 301 318 302 319 @ViewBuilder ··· 621 638 .tint(Color("AccentColor")) 622 639 .frame(maxHeight: .infinity, alignment: .top) 623 640 } 641 + 642 + private struct GalleryActionsSheet: View { 643 + @Environment(\.dismiss) private var dismiss 644 + var onReport: (() -> Void)? 645 + var onDelete: (() -> Void)? 646 + 647 + var body: some View { 648 + List { 649 + if let onReport { 650 + Button { 651 + dismiss() 652 + onReport() 653 + } label: { 654 + Label("Report", systemImage: "flag") 655 + .foregroundStyle(.primary) 656 + } 657 + } 658 + if let onDelete { 659 + Button { 660 + dismiss() 661 + onDelete() 662 + } label: { 663 + Label("Delete Gallery", systemImage: "trash") 664 + .foregroundStyle(.primary) 665 + } 666 + } 667 + } 668 + .tint(.primary) 669 + } 670 + }
+53 -18
Grain/Views/Feed/CameraFeedView.swift
··· 13 13 @State private var zoomState = ImageZoomState() 14 14 @State private var cardStoryAuthor: GrainStoryAuthor? 15 15 @State private var commentSheetUri: String? 16 + @State private var reportGallery: GrainGallery? 17 + @State private var deleteGalleryUri: String? 18 + @State private var showDeleteConfirmation = false 16 19 17 20 let client: XRPCClient 18 21 let camera: String ··· 25 28 ScrollView { 26 29 LazyVStack(spacing: 0) { 27 30 ForEach($galleries) { $gallery in 28 - GalleryCardView(gallery: $gallery, client: client, onNavigate: { 29 - selectedUri = gallery.uri 30 - }, onCommentTap: { 31 - commentSheetUri = gallery.uri 32 - }, onProfileTap: { did in 33 - selectedProfileDid = did 34 - }, onHashtagTap: { tag in 35 - selectedHashtag = tag 36 - }, onLocationTap: { h3, name in 37 - selectedLocation = LocationDestination(h3Index: h3, name: name) 38 - }, onStoryTap: { author in 39 - cardStoryAuthor = author 40 - }) 41 - .onAppear { 42 - if gallery.id == galleries.last?.id { 43 - Task { await loadMore() } 44 - } 45 - } 31 + galleryCard(gallery: $gallery) 46 32 } 47 33 48 34 if isLoading { ··· 128 114 ) 129 115 } 130 116 } 117 + .sheet(item: $reportGallery) { gallery in 118 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 119 + } 120 + .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 121 + Button("Delete", role: .destructive) { 122 + if let uri = deleteGalleryUri { 123 + Task { 124 + guard let authContext = await auth.authContext() else { return } 125 + let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 126 + try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 127 + galleries.removeAll { $0.uri == uri } 128 + } 129 + deleteGalleryUri = nil 130 + } 131 + } 132 + Button("Cancel", role: .cancel) { deleteGalleryUri = nil } 133 + } message: { 134 + Text("This will permanently delete this gallery and all its photos.") 135 + } 131 136 .task { 132 137 guard !isPreview else { 133 138 #if DEBUG ··· 137 142 } 138 143 if galleries.isEmpty { 139 144 await loadInitial() 145 + } 146 + } 147 + } 148 + 149 + @ViewBuilder 150 + private func galleryCard(gallery: Binding<GrainGallery>) -> some View { 151 + let g = gallery.wrappedValue 152 + let isOwner = g.creator.did == auth.userDID 153 + let reportAction: (() -> Void)? = !isOwner ? { 154 + reportGallery = g 155 + } : nil 156 + let deleteAction: (() -> Void)? = isOwner ? { 157 + showDeleteConfirmation = true 158 + deleteGalleryUri = g.uri 159 + } : nil 160 + GalleryCardView( 161 + gallery: gallery, 162 + client: client, 163 + onNavigate: { selectedUri = g.uri }, 164 + onCommentTap: { commentSheetUri = g.uri }, 165 + onProfileTap: { did in selectedProfileDid = did }, 166 + onHashtagTap: { tag in selectedHashtag = tag }, 167 + onLocationTap: { h3, name in selectedLocation = LocationDestination(h3Index: h3, name: name) }, 168 + onStoryTap: { author in cardStoryAuthor = author }, 169 + onReport: reportAction, 170 + onDelete: deleteAction 171 + ) 172 + .onAppear { 173 + if g.id == galleries.last?.id { 174 + Task { await loadMore() } 140 175 } 141 176 } 142 177 }
+43 -13
Grain/Views/Feed/FeedView.swift
··· 255 255 @State private var zoomState = ImageZoomState() 256 256 @State private var cardStoryAuthor: GrainStoryAuthor? 257 257 @State private var commentSheetUri: String? 258 + @State private var reportGallery: GrainGallery? 259 + @State private var deleteGalleryUri: String? 260 + @State private var showDeleteConfirmation = false 258 261 @AppStorage("privacy.showSuggestedUsers") private var showSuggestedUsers = true 259 262 @State private var suggestedFollows: [SuggestedItem] = [] 260 263 @State private var suggestedLoaded = false ··· 306 309 ) 307 310 308 311 ForEach(Array($viewModel.galleries.enumerated()), id: \.element.id) { index, $gallery in 312 + let isOwner = gallery.creator.did == auth.userDID 313 + let reportAction: (() -> Void)? = !isOwner ? { 314 + reportGallery = gallery 315 + } : nil 316 + let deleteAction: (() -> Void)? = isOwner ? { 317 + showDeleteConfirmation = true 318 + deleteGalleryUri = gallery.uri 319 + } : nil 309 320 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 310 321 selectedUri = gallery.uri 311 322 }, onCommentTap: { ··· 318 329 selectedLocation = LocationDestination(h3Index: h3, name: name) 319 330 }, onStoryTap: { author in 320 331 cardStoryAuthor = author 321 - }) 322 - .onAppear { 323 - // Trigger loadMore when 5 items from the end 324 - let remaining = viewModel.galleries.count - index 325 - if remaining <= 5 { 326 - Task { await viewModel.loadMore(auth: auth.authContext()) } 327 - } 328 - // Prefetch first image of next 3 galleries 329 - let input = viewModel.galleries.map { g in 330 - (firstThumb: g.items?.first?.thumb, firstFullsize: g.items?.first?.fullsize) 332 + }, onReport: reportAction, onDelete: deleteAction) 333 + .onAppear { 334 + // Trigger loadMore when 5 items from the end 335 + let remaining = viewModel.galleries.count - index 336 + if remaining <= 5 { 337 + Task { await viewModel.loadMore(auth: auth.authContext()) } 338 + } 339 + // Prefetch first image of next 3 galleries 340 + let input = viewModel.galleries.map { g in 341 + (firstThumb: g.items?.first?.thumb, firstFullsize: g.items?.first?.fullsize) 342 + } 343 + let plan = ImagePrefetchPlanning.feedPrefetchRequests(galleries: input, currentIndex: index) 344 + feedPrefetcher.startPrefetching(with: plan.all) 331 345 } 332 - let plan = ImagePrefetchPlanning.feedPrefetchRequests(galleries: input, currentIndex: index) 333 - feedPrefetcher.startPrefetching(with: plan.all) 334 - } 335 346 336 347 if index == 4, showSuggestedUsers { 337 348 SuggestedFollowsView(client: client, suggestions: $suggestedFollows, onProfileTap: { did in ··· 408 419 } 409 420 ) 410 421 } 422 + } 423 + .sheet(item: $reportGallery) { gallery in 424 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 425 + } 426 + .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 427 + Button("Delete", role: .destructive) { 428 + if let uri = deleteGalleryUri { 429 + Task { 430 + guard let authContext = await auth.authContext() else { return } 431 + let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 432 + try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 433 + viewModel.galleries.removeAll { $0.uri == uri } 434 + } 435 + deleteGalleryUri = nil 436 + } 437 + } 438 + Button("Cancel", role: .cancel) { deleteGalleryUri = nil } 439 + } message: { 440 + Text("This will permanently delete this gallery and all its photos.") 411 441 } 412 442 .task { 413 443 guard !isPreview else {
+46 -19
Grain/Views/Feed/HashtagFeedView.swift
··· 13 13 @State private var zoomState = ImageZoomState() 14 14 @State private var cardStoryAuthor: GrainStoryAuthor? 15 15 @State private var commentSheetUri: String? 16 + @State private var reportGallery: GrainGallery? 17 + @State private var deleteGalleryUri: String? 18 + @State private var showDeleteConfirmation = false 16 19 17 20 let client: XRPCClient 18 21 let tag: String ··· 24 27 var body: some View { 25 28 ScrollView { 26 29 LazyVStack(spacing: 0) { 27 - ForEach($galleries) { $gallery in 28 - GalleryCardView(gallery: $gallery, client: client, onNavigate: { 29 - selectedUri = gallery.uri 30 - }, onCommentTap: { 31 - commentSheetUri = gallery.uri 32 - }, onProfileTap: { did in 33 - selectedProfileDid = did 34 - }, onHashtagTap: { tag in 35 - selectedHashtag = tag 36 - }, onLocationTap: { h3, name in 37 - selectedLocation = LocationDestination(h3Index: h3, name: name) 38 - }, onStoryTap: { author in 39 - cardStoryAuthor = author 40 - }) 41 - .onAppear { 42 - if gallery.id == galleries.last?.id { 43 - Task { await loadMore() } 44 - } 45 - } 30 + ForEach(Array($galleries.enumerated()), id: \.element.id) { index, $gallery in 31 + galleryCard(gallery: $gallery, index: index) 46 32 } 47 33 48 34 if isLoading { ··· 128 114 ) 129 115 } 130 116 } 117 + .sheet(item: $reportGallery) { gallery in 118 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 119 + } 120 + .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 121 + Button("Delete", role: .destructive) { 122 + if let uri = deleteGalleryUri { 123 + Task { 124 + guard let authContext = await auth.authContext() else { return } 125 + let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 126 + try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 127 + galleries.removeAll { $0.uri == uri } 128 + } 129 + deleteGalleryUri = nil 130 + } 131 + } 132 + Button("Cancel", role: .cancel) { deleteGalleryUri = nil } 133 + } message: { 134 + Text("This will permanently delete this gallery and all its photos.") 135 + } 131 136 .task { 132 137 guard !isPreview else { 133 138 #if DEBUG ··· 149 154 cursor = response.cursor 150 155 } catch {} 151 156 isLoading = false 157 + } 158 + 159 + @ViewBuilder 160 + private func galleryCard(gallery: Binding<GrainGallery>, index: Int) -> some View { 161 + let g = gallery.wrappedValue 162 + let isOwner = g.creator.did == auth.userDID 163 + GalleryCardView( 164 + gallery: gallery, client: client, 165 + onNavigate: { selectedUri = g.uri }, 166 + onCommentTap: { commentSheetUri = g.uri }, 167 + onProfileTap: { did in selectedProfileDid = did }, 168 + onHashtagTap: { tag in selectedHashtag = tag }, 169 + onLocationTap: { h3, name in selectedLocation = LocationDestination(h3Index: h3, name: name) }, 170 + onStoryTap: { author in cardStoryAuthor = author }, 171 + onReport: !isOwner ? { reportGallery = g } : nil, 172 + onDelete: isOwner ? { showDeleteConfirmation = true; deleteGalleryUri = g.uri } : nil 173 + ) 174 + .onAppear { 175 + if index == galleries.count - 1 { 176 + Task { await loadMore() } 177 + } 178 + } 152 179 } 153 180 154 181 private func loadMore() async {
+35 -5
Grain/Views/Feed/LocationFeedView.swift
··· 13 13 @State private var zoomState = ImageZoomState() 14 14 @State private var cardStoryAuthor: GrainStoryAuthor? 15 15 @State private var commentSheetUri: String? 16 + @State private var reportGallery: GrainGallery? 17 + @State private var deleteGalleryUri: String? 18 + @State private var showDeleteConfirmation = false 16 19 @State private var mapInteractive = false 17 20 18 21 let client: XRPCClient ··· 58 61 } 59 62 60 63 ForEach($galleries) { $gallery in 64 + let isOwner = gallery.creator.did == auth.userDID 65 + let reportAction: (() -> Void)? = !isOwner ? { 66 + reportGallery = gallery 67 + } : nil 68 + let deleteAction: (() -> Void)? = isOwner ? { 69 + showDeleteConfirmation = true 70 + deleteGalleryUri = gallery.uri 71 + } : nil 61 72 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 62 73 selectedUri = gallery.uri 63 74 }, onCommentTap: { ··· 68 79 selectedHashtag = tag 69 80 }, onStoryTap: { author in 70 81 cardStoryAuthor = author 71 - }) 72 - .onAppear { 73 - if gallery.id == galleries.last?.id { 74 - Task { await loadMore() } 82 + }, onReport: reportAction, onDelete: deleteAction) 83 + .onAppear { 84 + if gallery.id == galleries.last?.id { 85 + Task { await loadMore() } 86 + } 75 87 } 76 - } 77 88 } 78 89 79 90 if isLoading { ··· 155 166 } 156 167 ) 157 168 } 169 + } 170 + .sheet(item: $reportGallery) { gallery in 171 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 172 + } 173 + .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 174 + Button("Delete", role: .destructive) { 175 + if let uri = deleteGalleryUri { 176 + Task { 177 + guard let authContext = await auth.authContext() else { return } 178 + let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 179 + try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 180 + galleries.removeAll { $0.uri == uri } 181 + } 182 + deleteGalleryUri = nil 183 + } 184 + } 185 + Button("Cancel", role: .cancel) { deleteGalleryUri = nil } 186 + } message: { 187 + Text("This will permanently delete this gallery and all its photos.") 158 188 } 159 189 .task { 160 190 guard !isPreview else {
+4 -26
Grain/Views/Gallery/GalleryDetailView.swift
··· 52 52 }, 53 53 onStoryTap: { author in 54 54 cardStoryAuthor = author 55 - } 55 + }, 56 + onReport: viewModel.gallery?.creator.did != auth.userDID ? { showReportSheet = true } : nil, 57 + onDelete: viewModel.gallery?.creator.did == auth.userDID ? { showDeleteConfirmation = true } : nil 56 58 ) 57 59 58 60 // View comments button ··· 92 94 .navigationDestination(item: $selectedLocation) { loc in 93 95 LocationFeedView(client: client, h3Index: loc.h3Index, locationName: loc.name) 94 96 } 95 - .toolbar { 96 - ToolbarItem(placement: .topBarTrailing) { 97 - Menu { 98 - if let gallery = viewModel.gallery { 99 - if gallery.creator.did == auth.userDID { 100 - Button(role: .destructive) { 101 - showDeleteConfirmation = true 102 - } label: { 103 - Label("Delete Gallery", systemImage: "trash") 104 - } 105 - } else { 106 - Button { 107 - showReportSheet = true 108 - } label: { 109 - Label("Report", systemImage: "flag") 110 - } 111 - } 112 - } 113 - } label: { 114 - Image(systemName: "ellipsis") 115 - } 116 - .tint(.primary) 117 - .disabled(viewModel.gallery == nil) 118 - } 119 - } 97 + .toolbarTitleDisplayMode(.inline) 120 98 .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 121 99 Button("Delete", role: .destructive) { 122 100 Task { await deleteGallery() }
+31 -1
Grain/Views/Search/SearchView.swift
··· 13 13 @State private var zoomState = ImageZoomState() 14 14 @State private var cardStoryAuthor: GrainStoryAuthor? 15 15 @State private var commentSheetUri: String? 16 + @State private var reportGallery: GrainGallery? 17 + @State private var deleteGalleryUri: String? 18 + @State private var showDeleteConfirmation = false 16 19 @State private var recentSearches = RecentSearchStorage() 17 20 @State private var searchIsPresented = false 18 21 let client: XRPCClient ··· 37 40 switch viewModel.selectedTab { 38 41 case .galleries: 39 42 ForEach($viewModel.galleryResults) { $gallery in 43 + let isOwner = gallery.creator.did == auth.userDID 44 + let reportAction: (() -> Void)? = !isOwner ? { 45 + reportGallery = gallery 46 + } : nil 47 + let deleteAction: (() -> Void)? = isOwner ? { 48 + showDeleteConfirmation = true 49 + deleteGalleryUri = gallery.uri 50 + } : nil 40 51 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 41 52 searchNavigationUri = gallery.uri 42 53 }, onCommentTap: { ··· 49 60 selectedLocation = LocationDestination(h3Index: h3, name: name) 50 61 }, onStoryTap: { author in 51 62 cardStoryAuthor = author 52 - }) 63 + }, onReport: reportAction, onDelete: deleteAction) 53 64 } 54 65 case .profiles: 55 66 ForEach(viewModel.profileResults) { profile in ··· 159 170 } 160 171 ) 161 172 } 173 + } 174 + .sheet(item: $reportGallery) { gallery in 175 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 176 + } 177 + .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 178 + Button("Delete", role: .destructive) { 179 + if let uri = deleteGalleryUri { 180 + Task { 181 + guard let authContext = await auth.authContext() else { return } 182 + let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 183 + try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 184 + viewModel.galleryResults.removeAll { $0.uri == uri } 185 + } 186 + deleteGalleryUri = nil 187 + } 188 + } 189 + Button("Cancel", role: .cancel) { deleteGalleryUri = nil } 190 + } message: { 191 + Text("This will permanently delete this gallery and all its photos.") 162 192 } 163 193 } 164 194 }