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 My Feeds menu, create-gallery sheet, comment/report/delete actions

A prior 'many changes' commit dropped showFeedsManagement, the Create
Gallery sheet, the deepLinkStory cover, and the comment/report/delete
modifiers from FeedView. Wires them all back so the My Feeds menu, +
button, comment sheet, report flow, and delete confirmation work again.

authored by

Hima Aramona and committed by
Chad Miller
51cd7446 a3a17707

+130 -19
+130 -19
Grain/Views/Feed/FeedView.swift
··· 1 1 import Nuke 2 + import os 2 3 import SwiftUI 4 + 5 + private let launchSignposter = OSSignposter(subsystem: "social.grain.grain", category: "AppLaunch") 3 6 4 7 struct FeedView: View { 5 8 @Environment(AuthManager.self) private var auth ··· 12 15 @State private var deepLinkProfileDid: String? 13 16 @State private var deepLinkGalleryUri: String? 14 17 @State private var deepLinkStoryAuthor: GrainStoryAuthor? 18 + @State private var deepLinkStory: GrainStory? 19 + @State private var showFeedsManagement = false 20 + @State private var feedRefreshID = UUID() 15 21 16 22 let client: XRPCClient 17 23 @Binding var pendingDeepLink: DeepLink? ··· 46 52 }, 47 53 prefsViewModel: prefsViewModel 48 54 ) 55 + .id(feedRefreshID) 49 56 } 50 57 } 51 58 .navigationBarTitleDisplayMode(.inline) ··· 58 65 trailingToolbarContent 59 66 } 60 67 .sharedBackgroundVisibility(.hidden) 68 + } 69 + .navigationDestination(isPresented: $showFeedsManagement) { 70 + FeedsManagementView(prefsViewModel: prefsViewModel, client: client) 61 71 } 62 72 .task { 63 73 guard !isPreview else { return } 64 74 await prefsViewModel.loadIfNeeded(auth: auth.authContext()) 75 + launchSignposter.emitEvent("FeedPrefsReady") 65 76 await storyViewModel.load(auth: auth.authContext(), storyStatusCache: storyStatusCache) 66 77 } 67 78 .onAppear { ··· 104 115 .navigationDestination(item: $deepLinkGalleryUri) { uri in 105 116 GalleryDetailView(client: client, galleryUri: uri) 106 117 } 118 + .sheet(isPresented: $showCreate) { 119 + NavigationStack { 120 + CreateGalleryView(client: client) { 121 + showCreate = false 122 + feedRefreshID = UUID() 123 + } 124 + } 125 + .tint(Color("AccentColor")) 126 + } 107 127 .fullScreenCover(item: $deepLinkStoryAuthor) { author in 108 128 StoryViewer( 109 129 authors: [author], ··· 116 136 deepLinkStoryAuthor = nil 117 137 storyViewModel.invalidate() 118 138 } 139 + ) 140 + .environment(auth) 141 + } 142 + .fullScreenCover(item: $deepLinkStory) { story in 143 + StoryViewer( 144 + authors: [GrainStoryAuthor( 145 + profile: story.creator, 146 + storyCount: 1, 147 + latestAt: story.createdAt 148 + )], 149 + initialStories: [story], 150 + client: client, 151 + onProfileTap: { did in 152 + deepLinkStory = nil 153 + deepLinkProfileDid = did 154 + }, 155 + onDismiss: { deepLinkStory = nil } 119 156 ) 120 157 .environment(auth) 121 158 } ··· 152 189 Label("Unpin", systemImage: "pin.slash") 153 190 } 154 191 } 192 + 193 + Divider() 194 + Button { 195 + showFeedsManagement = true 196 + } label: { 197 + Label("My Feeds", systemImage: "list.bullet") 198 + } 155 199 } label: { 156 200 HStack(spacing: 4) { 157 201 Text(prefsViewModel.selectedFeedLabel) ··· 191 235 deepLinkProfileDid = did 192 236 case .gallery: 193 237 deepLinkGalleryUri = link.galleryUri 194 - case let .story(did, _): 195 - Task { await openStoryDeepLink(did: did) } 238 + case let .story(did, rkey): 239 + Task { await openStoryDeepLink(did: did, rkey: rkey) } 196 240 } 197 241 } 198 242 199 - private func openStoryDeepLink(did: String) async { 243 + private func openStoryDeepLink(did: String, rkey: String) async { 200 244 do { 201 245 let response = try await client.getStories(actor: did, auth: auth.authContext()) 202 246 let count = response.stories.count ··· 207 251 latestAt: response.stories.last?.createdAt ?? "" 208 252 ) 209 253 } else { 210 - // Story expired — fall back to profile 211 - deepLinkProfileDid = did 254 + // Story expired — fetch the specific story 255 + let storyUri = "at://\(did)/social.grain.story/\(rkey)" 256 + if let story = try await client.getStory(uri: storyUri, auth: auth.authContext()).story { 257 + deepLinkStory = story 258 + } else { 259 + deepLinkProfileDid = did 260 + } 212 261 } 213 262 } catch { 214 - // Fall back to profile on error 215 263 deepLinkProfileDid = did 216 264 } 217 265 } ··· 228 276 @State private var deletedGalleryUri: String? 229 277 @State private var zoomState = ImageZoomState() 230 278 @State private var cardStoryAuthor: GrainStoryAuthor? 279 + @State private var commentSheetUri: String? 280 + @State private var reportGallery: GrainGallery? 281 + @State private var deleteGalleryUri: String? 282 + @State private var showDeleteConfirmation = false 231 283 @AppStorage("privacy.showSuggestedUsers") private var showSuggestedUsers = true 232 284 @State private var suggestedFollows: [SuggestedItem] = [] 233 285 @State private var suggestedLoaded = false ··· 279 331 ) 280 332 281 333 ForEach(Array($viewModel.galleries.enumerated()), id: \.element.id) { index, $gallery in 334 + let isOwner = gallery.creator.did == auth.userDID 335 + let reportAction: (() -> Void)? = !isOwner ? { 336 + reportGallery = gallery 337 + } : nil 338 + let deleteAction: (() -> Void)? = isOwner ? { 339 + showDeleteConfirmation = true 340 + deleteGalleryUri = gallery.uri 341 + } : nil 282 342 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 283 343 selectedUri = gallery.uri 344 + }, onCommentTap: { 345 + commentSheetUri = gallery.uri 284 346 }, onProfileTap: { did in 285 347 selectedProfileDid = did 286 348 }, onHashtagTap: { tag in ··· 289 351 selectedLocation = LocationDestination(h3Index: h3, name: name) 290 352 }, onStoryTap: { author in 291 353 cardStoryAuthor = author 292 - }) 293 - .onAppear { 294 - // Trigger loadMore when 5 items from the end 295 - let remaining = viewModel.galleries.count - index 296 - if remaining <= 5 { 297 - Task { await viewModel.loadMore(auth: auth.authContext()) } 354 + }, onReport: reportAction, onDelete: deleteAction) 355 + .onAppear { 356 + // Trigger loadMore when 5 items from the end 357 + let remaining = viewModel.galleries.count - index 358 + if remaining <= 5 { 359 + Task { await viewModel.loadMore(auth: auth.authContext()) } 360 + } 361 + // Prefetch first image of next 3 galleries 362 + let input = viewModel.galleries.map { g in 363 + (firstThumb: g.items?.first?.thumb, firstFullsize: g.items?.first?.fullsize) 364 + } 365 + let plan = ImagePrefetchPlanning.feedPrefetchRequests(galleries: input, currentIndex: index) 366 + feedPrefetcher.startPrefetching(with: plan.all) 298 367 } 299 - // Prefetch first image of next 3 galleries 300 - let input = viewModel.galleries.map { g in 301 - (firstThumb: g.items?.first?.thumb, firstFullsize: g.items?.first?.fullsize) 302 - } 303 - let plan = ImagePrefetchPlanning.feedPrefetchRequests(galleries: input, currentIndex: index) 304 - feedPrefetcher.startPrefetching(with: plan.all) 305 - } 306 368 307 369 if index == 4, showSuggestedUsers { 308 370 SuggestedFollowsView(client: client, suggestions: $suggestedFollows, onProfileTap: { did in ··· 351 413 ) 352 414 .environment(auth) 353 415 } 416 + .sheet(isPresented: Binding( 417 + get: { commentSheetUri != nil }, 418 + set: { if !$0 { commentSheetUri = nil } } 419 + )) { 420 + if let uri = commentSheetUri { 421 + CommentSheetView( 422 + client: client, 423 + galleryUri: uri, 424 + onDismiss: { commentSheetUri = nil }, 425 + onProfileTap: { did in 426 + commentSheetUri = nil 427 + selectedProfileDid = did 428 + }, 429 + onHashtagTap: { tag in 430 + commentSheetUri = nil 431 + selectedHashtag = tag 432 + }, 433 + onStoryTap: { author in 434 + commentSheetUri = nil 435 + cardStoryAuthor = author 436 + }, 437 + onCommentCountChanged: { count in 438 + if let idx = viewModel.galleries.firstIndex(where: { $0.uri == uri }) { 439 + viewModel.galleries[idx].commentCount = count 440 + } 441 + } 442 + ) 443 + } 444 + } 445 + .sheet(item: $reportGallery) { gallery in 446 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 447 + } 448 + .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 449 + Button("Delete", role: .destructive) { 450 + if let uri = deleteGalleryUri { 451 + Task { 452 + guard let authContext = await auth.authContext() else { return } 453 + let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 454 + try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 455 + viewModel.galleries.removeAll { $0.uri == uri } 456 + } 457 + deleteGalleryUri = nil 458 + } 459 + } 460 + Button("Cancel", role: .cancel) { deleteGalleryUri = nil } 461 + } message: { 462 + Text("This will permanently delete this gallery and all its photos.") 463 + } 354 464 .task { 355 465 guard !isPreview else { 356 466 #if DEBUG ··· 360 470 } 361 471 if !viewModel.hasFetchedInitial { 362 472 await viewModel.loadInitial(auth: auth.authContext()) 473 + launchSignposter.emitEvent("FeedFirstContent") 363 474 lastLoadTime = .now 364 475 } 365 476 if showSuggestedUsers, !suggestedLoaded, let did = auth.userDID {