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.

many changes

authored by

Hima Aramona and committed by
Chad Miller
76a0e282 6c6936e1

+715 -557
+1 -1
Grain/Views/Feed/CameraFeedView.swift
··· 115 115 } 116 116 } 117 117 .sheet(item: $reportGallery) { gallery in 118 - ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 118 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid) 119 119 } 120 120 .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 121 121 Button("Delete", role: .destructive) {
+27 -160
Grain/Views/Feed/FeedView.swift
··· 1 1 import Nuke 2 - import os 3 2 import SwiftUI 4 - 5 - private let fvLogger = Logger(subsystem: "social.grain.grain", category: "FeedView") 6 - private let feedLaunchSignposter = OSSignposter(subsystem: "social.grain.grain", category: "AppLaunch") 7 3 8 4 struct FeedView: View { 9 5 @Environment(AuthManager.self) private var auth ··· 16 12 @State private var deepLinkProfileDid: String? 17 13 @State private var deepLinkGalleryUri: String? 18 14 @State private var deepLinkStoryAuthor: GrainStoryAuthor? 19 - @State private var deepLinkStory: GrainStory? 20 - @State private var showFeedsManagement = false 21 - @State private var feedRefreshID = UUID() 22 15 23 16 let client: XRPCClient 24 17 @Binding var pendingDeepLink: DeepLink? ··· 28 21 self.client = client 29 22 _pendingDeepLink = pendingDeepLink 30 23 _showCreate = showCreate 31 - let _spid = feedLaunchSignposter.makeSignpostID() 32 - let _state = feedLaunchSignposter.beginInterval("FeedViewModelInit", id: _spid) 33 24 _prefsViewModel = State(initialValue: FeedPreferencesViewModel(client: client)) 34 25 _storyViewModel = State(initialValue: StoryStripViewModel(client: client)) 35 - feedLaunchSignposter.endInterval("FeedViewModelInit", _state) 36 26 } 37 27 38 28 var body: some View { 39 - let _ = feedLaunchSignposter.emitEvent("FeedViewBodyBegin") 40 29 let storySortVersion = storyViewModel.version 41 - let _ = fvLogger.info("[body] eval storyViewerDid=\(storyViewerDid ?? "nil") authors.count=\(storyViewModel.authors.count) version=\(storySortVersion)") 42 30 NavigationStack { 43 31 ForEach(prefsViewModel.pinnedFeeds) { feed in 44 32 if feed.id == prefsViewModel.selectedFeedId { ··· 58 46 }, 59 47 prefsViewModel: prefsViewModel 60 48 ) 61 - .id(feedRefreshID) 62 49 } 63 50 } 64 51 .navigationBarTitleDisplayMode(.inline) ··· 72 59 } 73 60 .sharedBackgroundVisibility(.hidden) 74 61 } 75 - .navigationDestination(isPresented: $showFeedsManagement) { 76 - FeedsManagementView(prefsViewModel: prefsViewModel, client: client) 77 - } 78 62 .task { 79 63 guard !isPreview else { return } 80 - let _spid = feedLaunchSignposter.makeSignpostID() 81 - let _state = feedLaunchSignposter.beginInterval("FeedPrefsLoad", id: _spid) 82 64 await prefsViewModel.loadIfNeeded(auth: auth.authContext()) 83 - feedLaunchSignposter.endInterval("FeedPrefsLoad", _state) 84 65 await storyViewModel.load(auth: auth.authContext(), storyStatusCache: storyStatusCache) 85 66 } 86 67 .onAppear { ··· 95 76 get: { storyViewerDid != nil }, 96 77 set: { if !$0 { storyViewerDid = nil } } 97 78 )) { 98 - let _ = fvLogger.info("[cover.content] closure invoked storyViewerDid=\(storyViewerDid ?? "nil")") 99 79 if let did = storyViewerDid { 100 80 StoryViewer( 101 81 authors: storyViewModel.authors, ··· 124 104 .navigationDestination(item: $deepLinkGalleryUri) { uri in 125 105 GalleryDetailView(client: client, galleryUri: uri) 126 106 } 127 - .sheet(isPresented: $showCreate) { 128 - NavigationStack { 129 - CreateGalleryView(client: client) { 130 - showCreate = false 131 - feedRefreshID = UUID() 132 - } 133 - } 134 - .tint(Color("AccentColor")) 135 - } 136 107 .fullScreenCover(item: $deepLinkStoryAuthor) { author in 137 108 StoryViewer( 138 109 authors: [author], ··· 145 116 deepLinkStoryAuthor = nil 146 117 storyViewModel.invalidate() 147 118 } 148 - ) 149 - .environment(auth) 150 - } 151 - .fullScreenCover(item: $deepLinkStory) { story in 152 - StoryViewer( 153 - authors: [GrainStoryAuthor( 154 - profile: story.creator, 155 - storyCount: 1, 156 - latestAt: story.createdAt 157 - )], 158 - initialStories: [story], 159 - client: client, 160 - onProfileTap: { did in 161 - deepLinkStory = nil 162 - deepLinkProfileDid = did 163 - }, 164 - onDismiss: { deepLinkStory = nil } 165 119 ) 166 120 .environment(auth) 167 121 } ··· 198 152 Label("Unpin", systemImage: "pin.slash") 199 153 } 200 154 } 201 - 202 - Divider() 203 - Button { 204 - showFeedsManagement = true 205 - } label: { 206 - Label("My Feeds", systemImage: "list.bullet") 207 - } 208 155 } label: { 209 156 HStack(spacing: 4) { 210 157 Text(prefsViewModel.selectedFeedLabel) ··· 244 191 deepLinkProfileDid = did 245 192 case .gallery: 246 193 deepLinkGalleryUri = link.galleryUri 247 - case let .story(did, rkey): 248 - Task { await openStoryDeepLink(did: did, rkey: rkey) } 194 + case let .story(did, _): 195 + Task { await openStoryDeepLink(did: did) } 249 196 } 250 197 } 251 198 252 - private func openStoryDeepLink(did: String, rkey: String) async { 199 + private func openStoryDeepLink(did: String) async { 253 200 do { 254 201 let response = try await client.getStories(actor: did, auth: auth.authContext()) 255 202 let count = response.stories.count ··· 260 207 latestAt: response.stories.last?.createdAt ?? "" 261 208 ) 262 209 } else { 263 - // Story expired — fetch the specific story 264 - let storyUri = "at://\(did)/social.grain.story/\(rkey)" 265 - if let story = try await client.getStory(uri: storyUri, auth: auth.authContext()).story { 266 - deepLinkStory = story 267 - } else { 268 - deepLinkProfileDid = did 269 - } 210 + // Story expired — fall back to profile 211 + deepLinkProfileDid = did 270 212 } 271 213 } catch { 214 + // Fall back to profile on error 272 215 deepLinkProfileDid = did 273 216 } 274 217 } ··· 283 226 @State private var selectedHashtag: String? 284 227 @State private var selectedLocation: LocationDestination? 285 228 @State private var deletedGalleryUri: String? 286 - @State private var zoomState = ImageZoomState() 229 + @State private var zoomState = ImageZoaomState() 287 230 @State private var cardStoryAuthor: GrainStoryAuthor? 288 - @State private var avatarOverlayURL: String? 289 - @State private var commentSheetUri: String? 290 - @State private var reportGallery: GrainGallery? 291 - @State private var deleteGalleryUri: String? 292 - @State private var showDeleteConfirmation = false 293 231 @AppStorage("privacy.showSuggestedUsers") private var showSuggestedUsers = true 294 232 @State private var suggestedFollows: [SuggestedItem] = [] 295 233 @State private var suggestedLoaded = false ··· 337 275 sortVersion: storySortVersion, 338 276 onAuthorTap: onStoryAuthorTap, 339 277 onAuthorLongPress: { did in selectedProfileDid = did }, 340 - onViewPhoto: { url in avatarOverlayURL = url }, 341 278 onCreateTap: onStoryCreateTap 342 279 ) 343 280 344 281 ForEach(Array($viewModel.galleries.enumerated()), id: \.element.id) { index, $gallery in 345 - let isOwner = gallery.creator.did == auth.userDID 346 - let reportAction: (() -> Void)? = !isOwner ? { 347 - reportGallery = gallery 348 - } : nil 349 - let deleteAction: (() -> Void)? = isOwner ? { 350 - showDeleteConfirmation = true 351 - deleteGalleryUri = gallery.uri 352 - } : nil 353 282 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 354 283 selectedUri = gallery.uri 355 - }, onCommentTap: { 356 - commentSheetUri = gallery.uri 357 284 }, onProfileTap: { did in 358 285 selectedProfileDid = did 359 286 }, onHashtagTap: { tag in ··· 362 289 selectedLocation = LocationDestination(h3Index: h3, name: name) 363 290 }, onStoryTap: { author in 364 291 cardStoryAuthor = author 365 - }, onReport: reportAction, onDelete: deleteAction) 366 - .onAppear { 367 - // Trigger loadMore when 5 items from the end 368 - let remaining = viewModel.galleries.count - index 369 - if remaining <= 5 { 370 - Task { await viewModel.loadMore(auth: auth.authContext()) } 371 - } 372 - // Prefetch first image of next 3 galleries 373 - let input = viewModel.galleries.map { g in 374 - (firstThumb: g.items?.first?.thumb, firstFullsize: g.items?.first?.fullsize) 375 - } 376 - let plan = ImagePrefetchPlanning.feedPrefetchRequests(galleries: input, currentIndex: index) 377 - feedPrefetcher.startPrefetching(with: plan.all) 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()) } 298 + } 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) 378 302 } 303 + let plan = ImagePrefetchPlanning.feedPrefetchRequests(galleries: input, currentIndex: index) 304 + feedPrefetcher.startPrefetching(with: plan.all) 305 + } 379 306 380 307 if index == 4, showSuggestedUsers { 381 308 SuggestedFollowsView(client: client, suggestions: $suggestedFollows, onProfileTap: { did in ··· 424 351 ) 425 352 .environment(auth) 426 353 } 427 - .fullScreenCover(isPresented: Binding( 428 - get: { avatarOverlayURL != nil }, 429 - set: { if !$0 { avatarOverlayURL = nil } } 430 - )) { 431 - if let url = avatarOverlayURL { 432 - AvatarOverlay(url: url) { avatarOverlayURL = nil } 433 - } 434 - } 435 - .sheet(isPresented: Binding( 436 - get: { commentSheetUri != nil }, 437 - set: { if !$0 { commentSheetUri = nil } } 438 - )) { 439 - if let uri = commentSheetUri { 440 - CommentSheetView( 441 - client: client, 442 - galleryUri: uri, 443 - onDismiss: { commentSheetUri = nil }, 444 - onProfileTap: { did in 445 - commentSheetUri = nil 446 - selectedProfileDid = did 447 - }, 448 - onHashtagTap: { tag in 449 - commentSheetUri = nil 450 - selectedHashtag = tag 451 - }, 452 - onStoryTap: { author in 453 - commentSheetUri = nil 454 - cardStoryAuthor = author 455 - }, 456 - onCommentCountChanged: { count in 457 - if let idx = viewModel.galleries.firstIndex(where: { $0.uri == uri }) { 458 - viewModel.galleries[idx].commentCount = count 459 - } 460 - } 461 - ) 462 - } 463 - } 464 - .sheet(item: $reportGallery) { gallery in 465 - ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 466 - } 467 - .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 468 - Button("Delete", role: .destructive) { 469 - if let uri = deleteGalleryUri { 470 - Task { 471 - guard let authContext = await auth.authContext() else { return } 472 - let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 473 - try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 474 - viewModel.galleries.removeAll { $0.uri == uri } 475 - } 476 - deleteGalleryUri = nil 477 - } 478 - } 479 - Button("Cancel", role: .cancel) { deleteGalleryUri = nil } 480 - } message: { 481 - Text("This will permanently delete this gallery and all its photos.") 482 - } 483 354 .task { 484 355 guard !isPreview else { 485 356 #if DEBUG ··· 487 358 #endif 488 359 return 489 360 } 490 - feedLaunchSignposter.emitEvent("FeedTaskBegin") 491 - if !viewModel.hasFetchedInitial { 492 - let _authCtx = await auth.authContext() 493 - feedLaunchSignposter.emitEvent("FeedAuthResolved") 494 - let _spid = feedLaunchSignposter.makeSignpostID() 495 - let _state = feedLaunchSignposter.beginInterval("FeedInitialLoad", id: _spid) 496 - await viewModel.loadInitial(auth: _authCtx) 497 - feedLaunchSignposter.endInterval("FeedInitialLoad", _state) 498 - feedLaunchSignposter.emitEvent("FeedGalleriesReady") 361 + if viewModel.galleries.isEmpty { 362 + await viewModel.loadInitial(auth: auth.authContext()) 499 363 lastLoadTime = .now 500 364 } 501 365 if showSuggestedUsers, !suggestedLoaded, let did = auth.userDID { ··· 524 388 } 525 389 526 390 #Preview { 527 - FeedView(client: .preview) 528 - .previewEnvironments() 391 + FeedView(client: XRPCClient(baseURL: AuthManager.serverURL)) 392 + .environment(AuthManager()) 393 + .environment(StoryStatusCache()) 394 + .environment(ViewedStoryStorage()) 395 + .environment(LabelDefinitionsCache()) 529 396 }
+1 -1
Grain/Views/Feed/HashtagFeedView.swift
··· 115 115 } 116 116 } 117 117 .sheet(item: $reportGallery) { gallery in 118 - ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 118 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid) 119 119 } 120 120 .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 121 121 Button("Delete", role: .destructive) {
+1 -1
Grain/Views/Feed/LocationFeedView.swift
··· 168 168 } 169 169 } 170 170 .sheet(item: $reportGallery) { gallery in 171 - ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 171 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid) 172 172 } 173 173 .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 174 174 Button("Delete", role: .destructive) {
+169
Grain/Views/Profile/ProfileGalleryFeedView.swift
··· 1 + import SwiftUI 2 + 3 + struct ProfileGalleryFeedView: View { 4 + @Environment(AuthManager.self) private var auth 5 + @Bindable var viewModel: ProfileDetailViewModel 6 + let client: XRPCClient 7 + let did: String 8 + let initialUri: String 9 + 10 + @State private var didExpand = false 11 + @State private var scrollAnchor: String? 12 + @State private var selectedProfileDid: String? 13 + @State private var selectedHashtag: String? 14 + @State private var selectedLocation: LocationDestination? 15 + @State private var zoomState = ImageZoomState() 16 + @State private var cardStoryAuthor: GrainStoryAuthor? 17 + @State private var commentSheetUri: String? 18 + @State private var reportGallery: GrainGallery? 19 + @State private var deleteGalleryUri: String? 20 + @State private var showDeleteConfirmation = false 21 + 22 + init(viewModel: ProfileDetailViewModel, client: XRPCClient, did: String, initialUri: String) { 23 + self.viewModel = viewModel 24 + self.client = client 25 + self.did = did 26 + self.initialUri = initialUri 27 + _scrollAnchor = State(initialValue: initialUri) 28 + } 29 + 30 + private var tappedIndex: Int { 31 + viewModel.galleries.firstIndex(where: { $0.uri == initialUri }) ?? 0 32 + } 33 + 34 + private var renderStartIndex: Int { 35 + didExpand ? 0 : tappedIndex 36 + } 37 + 38 + var body: some View { 39 + ScrollView { 40 + LazyVStack(spacing: 0) { 41 + ForEach(Array($viewModel.galleries.enumerated()), id: \.element.id) { index, $gallery in 42 + if index >= renderStartIndex { 43 + galleryCard(gallery: $gallery, index: index) 44 + .id(gallery.uri) 45 + } 46 + } 47 + 48 + if viewModel.isLoading { 49 + ProgressView() 50 + .padding() 51 + } 52 + } 53 + .scrollTargetLayout() 54 + } 55 + .scrollPosition(id: $scrollAnchor, anchor: .top) 56 + .contentMargins(.top, 16, for: .scrollContent) 57 + .environment(zoomState) 58 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 59 + .navigationBarTitleDisplayMode(.inline) 60 + .navigationDestination(item: $selectedProfileDid) { did in 61 + ProfileView(client: client, did: did) 62 + } 63 + .navigationDestination(item: $selectedHashtag) { tag in 64 + HashtagFeedView(client: client, tag: tag) 65 + } 66 + .navigationDestination(item: $selectedLocation) { loc in 67 + LocationFeedView(client: client, h3Index: loc.h3Index, locationName: loc.name) 68 + } 69 + .fullScreenCover(item: $cardStoryAuthor) { author in 70 + StoryViewer( 71 + authors: [author], 72 + client: client, 73 + onProfileTap: { did in 74 + cardStoryAuthor = nil 75 + selectedProfileDid = did 76 + }, 77 + onDismiss: { cardStoryAuthor = nil } 78 + ) 79 + .environment(auth) 80 + } 81 + .sheet(isPresented: Binding( 82 + get: { commentSheetUri != nil }, 83 + set: { if !$0 { commentSheetUri = nil } } 84 + )) { 85 + if let uri = commentSheetUri { 86 + CommentSheetView( 87 + client: client, 88 + galleryUri: uri, 89 + onDismiss: { commentSheetUri = nil }, 90 + onProfileTap: { did in 91 + commentSheetUri = nil 92 + selectedProfileDid = did 93 + }, 94 + onHashtagTap: { tag in 95 + commentSheetUri = nil 96 + selectedHashtag = tag 97 + }, 98 + onStoryTap: { author in 99 + commentSheetUri = nil 100 + cardStoryAuthor = author 101 + }, 102 + onCommentCountChanged: { count in 103 + if let idx = viewModel.galleries.firstIndex(where: { $0.uri == uri }) { 104 + viewModel.galleries[idx].commentCount = count 105 + } 106 + } 107 + ) 108 + } 109 + } 110 + .sheet(item: $reportGallery) { gallery in 111 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid) 112 + } 113 + .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 114 + Button("Delete", role: .destructive) { 115 + if let uri = deleteGalleryUri { 116 + Task { 117 + guard let authContext = await auth.authContext() else { return } 118 + let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 119 + try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 120 + viewModel.galleries.removeAll { $0.uri == uri } 121 + } 122 + deleteGalleryUri = nil 123 + } 124 + } 125 + Button("Cancel", role: .cancel) { deleteGalleryUri = nil } 126 + } message: { 127 + Text("This will permanently delete this gallery and all its photos.") 128 + } 129 + } 130 + 131 + @ViewBuilder 132 + private func galleryCard(gallery: Binding<GrainGallery>, index: Int) -> some View { 133 + let g = gallery.wrappedValue 134 + let isOwner = g.creator.did == auth.userDID 135 + GalleryCardView( 136 + gallery: gallery, client: client, 137 + onNavigate: {}, 138 + onCommentTap: { commentSheetUri = g.uri }, 139 + onProfileTap: { did in selectedProfileDid = did }, 140 + onHashtagTap: { tag in selectedHashtag = tag }, 141 + onLocationTap: { h3, name in selectedLocation = LocationDestination(h3Index: h3, name: name) }, 142 + onStoryTap: { author in cardStoryAuthor = author }, 143 + onReport: !isOwner ? { reportGallery = g } : nil, 144 + onDelete: isOwner ? { showDeleteConfirmation = true; deleteGalleryUri = g.uri } : nil 145 + ) 146 + .onAppear { 147 + if g.uri == initialUri, !didExpand { 148 + didExpand = true 149 + } 150 + if index == viewModel.galleries.count - 1 { 151 + Task { await viewModel.loadMoreGalleries(did: did, auth: auth.authContext()) } 152 + } 153 + } 154 + } 155 + } 156 + 157 + #Preview { 158 + ProfileGalleryFeedView( 159 + viewModel: { 160 + let vm = ProfileDetailViewModel(client: .preview) 161 + vm.galleries = PreviewData.galleries 162 + return vm 163 + }(), 164 + client: .preview, 165 + did: "did:plc:preview", 166 + initialUri: PreviewData.galleries.first?.uri ?? "" 167 + ) 168 + .previewEnvironments() 169 + }
+514 -390
Grain/Views/Profile/ProfileView.swift
··· 3 3 import OSLog 4 4 import SwiftUI 5 5 6 - private let avatarOverlaySignposter = OSSignposter(subsystem: "social.grain.grain", category: "AvatarOverlay") 7 - private let avatarOverlayLogger = Logger(subsystem: "social.grain.grain", category: "AvatarOverlay") 8 6 private let profileLaunchSignposter = OSSignposter(subsystem: "social.grain.grain", category: "AppLaunch") 9 7 10 8 enum ProfileViewMode: String, CaseIterable { ··· 14 12 struct ProfileView: View { 15 13 @Namespace private var viewModeNS 16 14 @Namespace private var galleryZoomNS 15 + @Namespace private var avatarZoomNS 17 16 @Environment(AuthManager.self) private var auth 18 17 @Environment(ViewedStoryStorage.self) private var viewedStories 19 18 @Environment(LabelDefinitionsCache.self) private var labelDefsCache ··· 26 25 @State private var selectedHashtag: String? 27 26 @State private var deletedGalleryUri: String? 28 27 @State private var viewMode: ProfileViewMode = .grid 28 + @State private var tabPageWidth: CGFloat = 0 29 + @State private var tabScrollOffsetX: CGFloat = 0 30 + @State private var tabSectionViewportMinY: CGFloat = .infinity 29 31 @State private var zoomState = ImageZoomState() 32 + // Flipped on during a dismiss so the overlay disappears via a plain opacity 33 + // transition instead of morphing back to the avatar anchor. Reset after the 34 + // overlay is gone so the next open can morph again. 35 + @State private var suppressAvatarMorph = false 30 36 @State private var cardStoryAuthor: GrainStoryAuthor? 31 37 let client: XRPCClient 32 38 @State private var selectedArchivedStory: GrainStory? ··· 51 57 52 58 var body: some View { 53 59 let _ = profileLaunchSignposter.emitEvent("ProfileViewBodyBegin") 54 - if isRoot { 55 - NavigationStack { 60 + ZStack { 61 + if isRoot { 62 + NavigationStack { 63 + profileContent 64 + } 65 + } else { 56 66 profileContent 57 67 } 58 - } else { 59 - profileContent 68 + 69 + if showAvatarOverlay, let avatar = viewModel.profile?.avatar { 70 + AvatarOverlay( 71 + url: avatar, 72 + namespace: suppressAvatarMorph ? nil : avatarZoomNS, 73 + onDismiss: dismissAvatarOverlay 74 + ) 75 + .ignoresSafeArea() 76 + .zIndex(999) 77 + .transition(.asymmetric(insertion: .identity, removal: .opacity)) 78 + } 60 79 } 61 80 } 62 81 ··· 70 89 ) { 71 90 AvatarView(url: profile.avatar, size: 80) 72 91 .liquidGlassCircle() 92 + .matchedGeometryEffect(id: "avatar", in: avatarZoomNS, isSource: !showAvatarOverlay) 73 93 } 74 94 .overlay(alignment: .bottomTrailing) { 75 95 if did == auth.userDID { ··· 86 106 if hasStory { showStoryViewer = true } else { showStoryCreate = true } 87 107 } else { 88 108 if hasStory { showStoryViewer = true } 89 - else if profile.avatar != nil { showAvatarOverlay = true } 109 + else if profile.avatar != nil { openAvatarOverlay() } 90 110 } 91 111 } 92 112 .profileContextMenu( ··· 94 114 hasStory: hasStory, 95 115 onViewStory: hasStory ? { showStoryViewer = true } : nil, 96 116 onAddStory: did == auth.userDID ? { showStoryCreate = true } : nil, 97 - onViewPhoto: profile.avatar != nil ? { showAvatarOverlay = true } : nil, 117 + onViewPhoto: profile.avatar != nil ? { openAvatarOverlay() } : nil, 98 118 showSharingActions: false 99 119 ) { 100 120 StoryRingView(hasStory: hasStory, viewed: false, size: 120) { ··· 104 124 } 105 125 } 106 126 127 + private func handleRow(profile: GrainProfileDetailed) -> some View { 128 + HStack(spacing: 6) { 129 + if !viewModel.isBlockHidden, profile.viewer?.followedBy != nil { 130 + Text("Follows you") 131 + .font(.caption2) 132 + .padding(.horizontal, 6) 133 + .padding(.vertical, 2) 134 + .background(.quaternary) 135 + .clipShape(RoundedRectangle(cornerRadius: 4)) 136 + } 137 + Text("@\(profile.handle)") 138 + .font(.subheadline) 139 + .foregroundStyle(.secondary) 140 + .contextMenu { 141 + if profile.avatar != nil { 142 + Button { openAvatarOverlay() } label: { 143 + Label("View Profile Photo", systemImage: "person.crop.circle") 144 + } 145 + Divider() 146 + } 147 + Button { copyText("@\(profile.handle)") } label: { 148 + Label("Copy Handle", systemImage: "doc.on.doc") 149 + } 150 + Button { copyText(did) } label: { 151 + Label("Copy DID", systemImage: "number") 152 + } 153 + } 154 + } 155 + } 156 + 107 157 private var profileContent: some View { 108 - ScrollView { 109 - if let profile = viewModel.profile { 110 - VStack(spacing: 12) { 111 - // Avatar + stats row 112 - HStack(alignment: .center, spacing: 16) { 113 - avatarButton(profile: profile) 158 + ScrollViewReader { scrollProxy in 159 + ScrollView { 160 + if let profile = viewModel.profile { 161 + VStack(spacing: 12) { 162 + // Avatar + stats row 163 + HStack(alignment: .center, spacing: 16) { 164 + avatarButton(profile: profile) 114 165 115 - if !viewModel.isBlockHidden { 116 - HStack(spacing: 0) { 117 - StatView(count: profile.galleryCount ?? 0, label: "Galleries") 166 + if !viewModel.isBlockHidden { 167 + HStack(spacing: 0) { 168 + StatView(count: profile.galleryCount ?? 0, label: "Galleries") 169 + .frame(maxWidth: .infinity) 170 + NavigationLink { 171 + FollowListView(client: client, did: did, mode: .followers) 172 + } label: { 173 + StatView(count: profile.followersCount ?? 0, label: "Followers") 174 + } 175 + .buttonStyle(.plain) 118 176 .frame(maxWidth: .infinity) 119 - NavigationLink { 120 - FollowListView(client: client, did: did, mode: .followers) 121 - } label: { 122 - StatView(count: profile.followersCount ?? 0, label: "Followers") 123 - } 124 - .buttonStyle(.plain) 125 - .frame(maxWidth: .infinity) 126 - NavigationLink { 127 - FollowListView(client: client, did: did, mode: .following) 128 - } label: { 129 - StatView(count: profile.followsCount ?? 0, label: "Following") 177 + NavigationLink { 178 + FollowListView(client: client, did: did, mode: .following) 179 + } label: { 180 + StatView(count: profile.followsCount ?? 0, label: "Following") 181 + } 182 + .buttonStyle(.plain) 183 + .frame(maxWidth: .infinity) 130 184 } 131 - .buttonStyle(.plain) 132 - .frame(maxWidth: .infinity) 133 185 } 134 186 } 135 - } 136 - .padding(.horizontal) 137 - .padding(.top, 8) 187 + .padding(.horizontal) 188 + .padding(.top, 8) 189 + 190 + // Name + handle + bio 191 + VStack(alignment: .leading, spacing: 4) { 192 + Text(profile.displayName ?? profile.handle) 193 + .font(.subheadline.bold()) 138 194 139 - // Name + handle + bio 140 - VStack(alignment: .leading, spacing: 4) { 141 - Text(profile.displayName ?? profile.handle) 142 - .font(.subheadline.bold()) 195 + handleRow(profile: profile) 143 196 144 - HStack(spacing: 6) { 145 - if !viewModel.isBlockHidden, profile.viewer?.followedBy != nil { 146 - Text("Follows you") 147 - .font(.caption2) 148 - .padding(.horizontal, 6) 149 - .padding(.vertical, 2) 150 - .background(.quaternary) 151 - .clipShape(RoundedRectangle(cornerRadius: 4)) 152 - } 153 - Text("@\(profile.handle)") 197 + if viewModel.isBlockHidden { 198 + // Block alert 199 + HStack(spacing: 6) { 200 + Image(systemName: "nosign") 201 + .font(.caption) 202 + if profile.viewer?.blocking != nil { 203 + Text("Account blocked") 204 + } else { 205 + Text("This user has blocked you") 206 + } 207 + } 154 208 .font(.subheadline) 155 209 .foregroundStyle(.secondary) 156 - .contextMenu { 157 - if profile.avatar != nil { 158 - Button { showAvatarOverlay = true } label: { 159 - Label("View Profile Photo", systemImage: "person.crop.circle") 160 - } 161 - Divider() 162 - } 163 - Button { copyText("@\(profile.handle)") } label: { 164 - Label("Copy Handle", systemImage: "doc.on.doc") 165 - } 166 - Button { copyText(did) } label: { 167 - Label("Copy DID", systemImage: "number") 168 - } 210 + .padding(.horizontal, 12) 211 + .padding(.vertical, 6) 212 + .background(.quaternary) 213 + .clipShape(RoundedRectangle(cornerRadius: 8)) 214 + .padding(.top, 4) 215 + } else { 216 + if let description = profile.description, !description.isEmpty { 217 + RichTextView( 218 + text: description, 219 + font: .subheadline, 220 + onMentionTap: { did in selectedProfileDid = did }, 221 + onHashtagTap: { tag in selectedHashtag = tag } 222 + ) 223 + .padding(.top, 2) 169 224 } 225 + } 170 226 } 227 + .frame(maxWidth: .infinity, alignment: .leading) 228 + .padding(.horizontal) 171 229 172 - if viewModel.isBlockHidden { 173 - // Block alert 174 - HStack(spacing: 6) { 175 - Image(systemName: "nosign") 176 - .font(.caption) 177 - if profile.viewer?.blocking != nil { 178 - Text("Account blocked") 179 - } else { 180 - Text("This user has blocked you") 230 + if !viewModel.isBlockHidden { 231 + // Known followers 232 + if !viewModel.knownFollowers.isEmpty, did != auth.userDID { 233 + NavigationLink { 234 + FollowListView(client: client, did: did, mode: .knownFollowers) 235 + } label: { 236 + knownFollowersRow 181 237 } 182 - } 183 - .font(.subheadline) 184 - .foregroundStyle(.secondary) 185 - .padding(.horizontal, 12) 186 - .padding(.vertical, 6) 187 - .background(.quaternary) 188 - .clipShape(RoundedRectangle(cornerRadius: 8)) 189 - .padding(.top, 4) 190 - } else { 191 - if let description = profile.description, !description.isEmpty { 192 - RichTextView( 193 - text: description, 194 - font: .subheadline, 195 - onMentionTap: { did in selectedProfileDid = did }, 196 - onHashtagTap: { tag in selectedHashtag = tag } 197 - ) 198 - .padding(.top, 2) 238 + .buttonStyle(.plain) 199 239 } 200 - } 201 - } 202 - .frame(maxWidth: .infinity, alignment: .leading) 203 - .padding(.horizontal) 204 240 205 - if !viewModel.isBlockHidden { 206 - // Known followers 207 - if !viewModel.knownFollowers.isEmpty, did != auth.userDID { 208 - NavigationLink { 209 - FollowListView(client: client, did: did, mode: .knownFollowers) 210 - } label: { 211 - knownFollowersRow 212 - } 213 - .buttonStyle(.plain) 214 - } 241 + // Follow + Germ DM buttons 242 + if did != auth.userDID { 243 + HStack(spacing: 8) { 244 + followButton(profile: profile) 215 245 216 - // Follow + Germ DM buttons 217 - if did != auth.userDID { 218 - HStack(spacing: 8) { 219 - followButton(profile: profile) 220 - 221 - if let germUrl = germDMUrl(profile: profile) { 222 - Link(destination: germUrl) { 223 - HStack(spacing: 4) { 224 - Image("germ-logo") 225 - .resizable() 226 - .frame(width: 14, height: 14) 227 - Text("Germ DM") 228 - .font(.subheadline.weight(.semibold)) 246 + if let germUrl = germDMUrl(profile: profile) { 247 + Link(destination: germUrl) { 248 + HStack(spacing: 4) { 249 + Image("germ-logo") 250 + .resizable() 251 + .frame(width: 14, height: 14) 252 + Text("Germ DM") 253 + .font(.subheadline.weight(.semibold)) 254 + } 255 + .frame(maxWidth: .infinity) 229 256 } 230 - .frame(maxWidth: .infinity) 257 + .buttonStyle(.bordered) 258 + .tint(.primary) 259 + } 260 + } 261 + .padding(.horizontal) 262 + } else { 263 + HStack(spacing: 8) { 264 + NavigationLink { 265 + EditProfileView(client: client, onSaved: { 266 + Task { await viewModel.load(did: did) } 267 + }) 268 + } label: { 269 + Text("Edit Profile") 270 + .font(.subheadline.weight(.semibold)) 271 + .frame(maxWidth: .infinity) 231 272 } 232 273 .buttonStyle(.bordered) 233 274 .tint(.primary) 234 - } 235 - } 236 - .padding(.horizontal) 237 - } else { 238 - HStack(spacing: 8) { 239 - NavigationLink { 240 - EditProfileView(client: client, onSaved: { 241 - Task { await viewModel.load(did: did) } 242 - }) 243 - } label: { 244 - Text("Edit Profile") 245 - .font(.subheadline.weight(.semibold)) 246 - .frame(maxWidth: .infinity) 247 - } 248 - .buttonStyle(.bordered) 249 - .tint(.primary) 250 275 251 - if let germUrl = germDMUrl(profile: profile) { 252 - Link(destination: germUrl) { 253 - HStack(spacing: 4) { 254 - Image("germ-logo") 255 - .resizable() 256 - .frame(width: 14, height: 14) 257 - Text("Germ DM") 258 - .font(.subheadline.weight(.semibold)) 276 + if let germUrl = germDMUrl(profile: profile) { 277 + Link(destination: germUrl) { 278 + HStack(spacing: 4) { 279 + Image("germ-logo") 280 + .resizable() 281 + .frame(width: 14, height: 14) 282 + Text("Germ DM") 283 + .font(.subheadline.weight(.semibold)) 284 + } 285 + .frame(maxWidth: .infinity) 259 286 } 260 - .frame(maxWidth: .infinity) 287 + .buttonStyle(.bordered) 288 + .tint(.primary) 261 289 } 262 - .buttonStyle(.bordered) 263 - .tint(.primary) 264 290 } 291 + .padding(.horizontal) 265 292 } 266 - .padding(.horizontal) 267 293 } 268 - } 269 294 270 - // Tabs + grid 271 - if !viewModel.isBlockHidden { 272 - VStack(spacing: 0) { 295 + // Tabs + grid 296 + if !viewModel.isBlockHidden { 273 297 if did == auth.userDID { 274 - HStack(spacing: 0) { 275 - tabButton(icon: "square.grid.3x3", mode: .grid) 276 - tabButton(icon: "heart", mode: .favorites) 277 - tabButton(icon: "clock", mode: .stories) 278 - } 279 - } 280 - 281 - if viewMode == .grid { 298 + ownProfileTabSection 299 + .id("profileTabSection") 300 + .onGeometryChange(for: CGFloat.self) { proxy in 301 + proxy.frame(in: .scrollView).minY 302 + } action: { newValue in 303 + tabSectionViewportMinY = newValue 304 + } 305 + } else { 282 306 galleriesGrid 283 307 } 284 - 285 - if viewMode == .favorites { 286 - favoritesGrid 287 - } 288 - 289 - if viewMode == .stories { 290 - storyArchiveGrid 291 - } 292 308 } 293 - .highPriorityGesture( 294 - did == auth.userDID ? 295 - DragGesture(minimumDistance: 30, coordinateSpace: .local) 296 - .onEnded { value in 297 - let h = value.translation.width 298 - let v = value.translation.height 299 - guard abs(h) > abs(v) else { return } 300 - let modes: [ProfileViewMode] = [.grid, .favorites, .stories] 301 - guard let currentIdx = modes.firstIndex(of: viewMode) else { return } 302 - if h < 0, currentIdx < modes.count - 1 { 303 - let next = modes[currentIdx + 1] 304 - withAnimation(.easeInOut(duration: 0.2)) { viewMode = next } 305 - if next == .stories { 306 - Task { await viewModel.loadStoryArchive(did: did, auth: auth.authContext()) } 307 - } else if next == .favorites { 308 - Task { await viewModel.loadFavorites(did: did, auth: auth.authContext()) } 309 - } 310 - } else if h > 0, currentIdx > 0 { 311 - withAnimation(.easeInOut(duration: 0.2)) { viewMode = modes[currentIdx - 1] } 312 - } 309 + } // end if !isBlockHidden (tabs + grid) 310 + } else if viewModel.error != nil { 311 + VStack(spacing: 16) { 312 + ContentUnavailableView( 313 + "Profile Not Found", 314 + systemImage: "person.slash", 315 + description: Text("This user doesn't have a Grain profile yet.") 316 + ) 317 + if let url = URL(string: "https://bsky.app/profile/\(actor)") { 318 + Link(destination: url) { 319 + HStack(spacing: 6) { 320 + Image(systemName: "arrow.up.right") 321 + Text("View on Bluesky") 313 322 } 314 - : nil 315 - ) 316 - } 317 - } // end if !isBlockHidden (tabs + grid) 318 - } else if viewModel.error != nil { 319 - VStack(spacing: 16) { 320 - ContentUnavailableView( 321 - "Profile Not Found", 322 - systemImage: "person.slash", 323 - description: Text("This user doesn't have a Grain profile yet.") 324 - ) 325 - if let url = URL(string: "https://bsky.app/profile/\(actor)") { 326 - Link(destination: url) { 327 - HStack(spacing: 6) { 328 - Image(systemName: "arrow.up.right") 329 - Text("View on Bluesky") 323 + .font(.subheadline.weight(.medium)) 330 324 } 331 - .font(.subheadline.weight(.medium)) 332 325 } 333 326 } 327 + .padding(.top, 40) 328 + } else { 329 + ProgressView() 330 + .padding(.top, 100) 334 331 } 335 - .padding(.top, 40) 336 - } else { 337 - ProgressView() 338 - .padding(.top, 100) 339 332 } 340 - } 341 - .environment(zoomState) 342 - .modifier(ImageZoomOverlay(zoomState: zoomState)) 343 - .navigationTitle("") 344 - .navigationBarTitleDisplayMode(.inline) 345 - .toolbar { 346 - if did == auth.userDID { 347 - if let handle = viewModel.profile?.handle, 348 - let profileURL = URL(string: "https://grain.social/profile/\(handle)") 349 - { 333 + .environment(zoomState) 334 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 335 + .navigationTitle("") 336 + .navigationBarTitleDisplayMode(.inline) 337 + .toolbar { 338 + if did == auth.userDID { 339 + if let handle = viewModel.profile?.handle, 340 + let profileURL = URL(string: "https://grain.social/profile/\(handle)") 341 + { 342 + ToolbarItem(placement: .topBarTrailing) { 343 + ShareLink(item: profileURL) { 344 + Image(systemName: "square.and.arrow.up") 345 + } 346 + .tint(.primary) 347 + } 348 + } 350 349 ToolbarItem(placement: .topBarTrailing) { 351 - ShareLink(item: profileURL) { 352 - Image(systemName: "square.and.arrow.up") 350 + NavigationLink { 351 + SettingsView(client: client) 352 + } label: { 353 + Image(systemName: "gearshape") 353 354 } 354 355 .tint(.primary) 355 356 } 356 - } 357 - ToolbarItem(placement: .topBarTrailing) { 358 - NavigationLink { 359 - SettingsView(client: client) 360 - } label: { 361 - Image(systemName: "gearshape") 362 - } 363 - .tint(.primary) 364 - } 365 - } else if let profile = viewModel.profile { 366 - ToolbarItem(placement: .topBarTrailing) { 367 - Menu { 368 - if let profileURL = URL(string: "https://grain.social/profile/\(profile.handle)") { 369 - ShareLink(item: profileURL) { 370 - Label("Share Profile", systemImage: "square.and.arrow.up") 357 + } else if let profile = viewModel.profile { 358 + ToolbarItem(placement: .topBarTrailing) { 359 + Menu { 360 + if let profileURL = URL(string: "https://grain.social/profile/\(profile.handle)") { 361 + ShareLink(item: profileURL) { 362 + Label("Share Profile", systemImage: "square.and.arrow.up") 363 + } 364 + Button { 365 + UIPasteboard.general.string = profile.handle 366 + } label: { 367 + Label("Copy Username", systemImage: "at") 368 + } 369 + Divider() 371 370 } 372 - Button { 373 - UIPasteboard.general.string = profile.handle 374 - } label: { 375 - Label("Copy Username", systemImage: "at") 371 + if !viewModel.isBlockHidden { 372 + Button(role: profile.viewer?.muted == true ? nil : .destructive) { 373 + Task { await viewModel.toggleMute(auth: auth.authContext()) } 374 + } label: { 375 + Label( 376 + profile.viewer?.muted == true ? "Unmute" : "Mute", 377 + systemImage: profile.viewer?.muted == true ? "speaker.wave.2" : "speaker.slash" 378 + ) 379 + } 376 380 } 377 - Divider() 378 - } 379 - if !viewModel.isBlockHidden { 380 - Button(role: profile.viewer?.muted == true ? nil : .destructive) { 381 - Task { await viewModel.toggleMute(auth: auth.authContext()) } 381 + Button(role: profile.viewer?.blocking != nil ? nil : .destructive) { 382 + Task { await viewModel.toggleBlock(auth: auth.authContext()) } 382 383 } label: { 383 384 Label( 384 - profile.viewer?.muted == true ? "Unmute" : "Mute", 385 - systemImage: profile.viewer?.muted == true ? "speaker.wave.2" : "speaker.slash" 385 + profile.viewer?.blocking != nil ? "Unblock" : "Block", 386 + systemImage: profile.viewer?.blocking != nil ? "circle" : "nosign" 386 387 ) 387 388 } 388 - } 389 - Button(role: profile.viewer?.blocking != nil ? nil : .destructive) { 390 - Task { await viewModel.toggleBlock(auth: auth.authContext()) } 391 389 } label: { 392 - Label( 393 - profile.viewer?.blocking != nil ? "Unblock" : "Block", 394 - systemImage: profile.viewer?.blocking != nil ? "circle" : "nosign" 395 - ) 390 + Image(systemName: "ellipsis") 396 391 } 397 - } label: { 398 - Image(systemName: "ellipsis") 392 + .tint(.primary) 399 393 } 400 - .tint(.primary) 394 + } 395 + } 396 + .navigationDestination(item: $selectedGalleryUri) { uri in 397 + ProfileGalleryFeedView(viewModel: viewModel, client: client, did: did, initialUri: uri) 398 + .navigationTransition(.zoom(sourceID: uri, in: galleryZoomNS)) 399 + } 400 + .navigationDestination(item: $selectedProfileDid) { did in 401 + ProfileView(client: client, did: did) 402 + } 403 + .navigationDestination(item: $selectedHashtag) { tag in 404 + HashtagFeedView(client: client, tag: tag) 405 + } 406 + .fullScreenCover(isPresented: $showStoryViewer) { 407 + if let profile = viewModel.profile { 408 + StoryViewer( 409 + authors: [GrainStoryAuthor( 410 + profile: GrainProfile(cid: "", did: did, handle: profile.handle, displayName: profile.displayName, avatar: profile.avatar), 411 + storyCount: viewModel.stories.count, 412 + latestAt: viewModel.stories.last?.createdAt ?? "" 413 + )], 414 + client: client, 415 + onProfileTap: { did in 416 + showStoryViewer = false 417 + selectedProfileDid = did 418 + }, 419 + onDismiss: { showStoryViewer = false } 420 + ) 421 + .environment(auth) 401 422 } 402 423 } 403 - } 404 - .navigationDestination(item: $selectedGalleryUri) { uri in 405 - GalleryDetailView(client: client, galleryUri: uri, deletedGalleryUri: $deletedGalleryUri) 406 - .navigationTransition(.zoom(sourceID: uri, in: galleryZoomNS)) 407 - } 408 - .navigationDestination(item: $selectedProfileDid) { did in 409 - ProfileView(client: client, did: did) 410 - } 411 - .navigationDestination(item: $selectedHashtag) { tag in 412 - HashtagFeedView(client: client, tag: tag) 413 - } 414 - .fullScreenCover(isPresented: $showStoryViewer) { 415 - if let profile = viewModel.profile { 424 + .fullScreenCover(item: $cardStoryAuthor) { author in 416 425 StoryViewer( 417 - authors: [GrainStoryAuthor( 418 - profile: GrainProfile(cid: "", did: did, handle: profile.handle, displayName: profile.displayName, avatar: profile.avatar), 419 - storyCount: viewModel.stories.count, 420 - latestAt: viewModel.stories.last?.createdAt ?? "" 421 - )], 426 + authors: [author], 422 427 client: client, 423 428 onProfileTap: { did in 424 - showStoryViewer = false 429 + cardStoryAuthor = nil 425 430 selectedProfileDid = did 426 431 }, 427 - onDismiss: { showStoryViewer = false } 432 + onDismiss: { cardStoryAuthor = nil } 428 433 ) 429 434 .environment(auth) 430 435 } 431 - } 432 - .fullScreenCover(item: $cardStoryAuthor) { author in 433 - StoryViewer( 434 - authors: [author], 435 - client: client, 436 - onProfileTap: { did in 437 - cardStoryAuthor = nil 438 - selectedProfileDid = did 439 - }, 440 - onDismiss: { cardStoryAuthor = nil } 441 - ) 442 - .environment(auth) 443 - } 444 - .fullScreenCover(item: $selectedArchivedStory) { story in 445 - if let profile = viewModel.profile, 446 - viewModel.archivedStories.contains(where: { $0.id == story.id }) 447 - { 448 - StoryViewer( 449 - authors: [GrainStoryAuthor( 450 - profile: GrainProfile(cid: "", did: did, handle: profile.handle, displayName: profile.displayName, avatar: profile.avatar), 451 - storyCount: 1, 452 - latestAt: story.createdAt 453 - )], 454 - initialStories: [story], 455 - client: client, 456 - onProfileTap: { did in 457 - selectedArchivedStory = nil 458 - selectedProfileDid = did 459 - }, 460 - onDismiss: { selectedArchivedStory = nil } 461 - ) 436 + .fullScreenCover(item: $selectedArchivedStory) { story in 437 + if let profile = viewModel.profile, 438 + viewModel.archivedStories.contains(where: { $0.id == story.id }) 439 + { 440 + StoryViewer( 441 + authors: [GrainStoryAuthor( 442 + profile: GrainProfile(cid: "", did: did, handle: profile.handle, displayName: profile.displayName, avatar: profile.avatar), 443 + storyCount: 1, 444 + latestAt: story.createdAt 445 + )], 446 + initialStories: [story], 447 + client: client, 448 + onProfileTap: { did in 449 + selectedArchivedStory = nil 450 + selectedProfileDid = did 451 + }, 452 + onDismiss: { selectedArchivedStory = nil } 453 + ) 454 + .environment(auth) 455 + } 456 + } 457 + .fullScreenCover(isPresented: $showStoryCreate) { 458 + StoryCreateView(client: client, onCreated: { 459 + Task { await viewModel.load(did: did) } 460 + }) 462 461 .environment(auth) 463 462 } 464 - } 465 - .fullScreenCover(isPresented: $showStoryCreate) { 466 - StoryCreateView(client: client, onCreated: { 467 - Task { await viewModel.load(did: did) } 468 - }) 469 - .environment(auth) 470 - } 471 - .fullScreenCover(isPresented: $showAvatarOverlay) { 472 - if let avatar = viewModel.profile?.avatar { 473 - AvatarOverlay(url: avatar) { 474 - showAvatarOverlay = false 463 + .background(Color(.systemBackground)) 464 + .refreshable { 465 + await viewModel.load(did: actor, viewer: auth.userDID, auth: auth.authContext()) 466 + if viewMode == .favorites { 467 + await viewModel.loadFavorites(did: actor, auth: auth.authContext()) 468 + } else if viewMode == .stories { 469 + await viewModel.loadStoryArchive(did: actor, auth: auth.authContext()) 475 470 } 476 471 } 477 - } 478 - .background(Color(.systemBackground)) 479 - .refreshable { 480 - await viewModel.load(did: actor, viewer: auth.userDID, auth: auth.authContext()) 481 - if viewMode == .favorites { 482 - await viewModel.loadFavorites(did: actor, auth: auth.authContext()) 483 - } else if viewMode == .stories { 484 - await viewModel.loadStoryArchive(did: actor, auth: auth.authContext()) 472 + .task { 473 + guard !isPreview else { 474 + #if DEBUG 475 + viewModel.profile = PreviewData.profile 476 + viewModel.galleries = PreviewData.galleries 477 + #endif 478 + return 479 + } 480 + if viewModel.profile == nil { 481 + await viewModel.load(did: actor, viewer: auth.userDID, auth: auth.authContext()) 482 + } 485 483 } 486 - } 487 - .task { 488 - guard !isPreview else { 489 - #if DEBUG 490 - viewModel.profile = PreviewData.profile 491 - viewModel.galleries = PreviewData.galleries 492 - #endif 493 - return 484 + .onChange(of: deletedGalleryUri) { _, uri in 485 + if let uri { 486 + viewModel.galleries.removeAll { $0.uri == uri } 487 + deletedGalleryUri = nil 488 + } 494 489 } 495 - if viewModel.profile == nil { 496 - await viewModel.load(did: actor, viewer: auth.userDID, auth: auth.authContext()) 490 + .alert("Sign in again to block", isPresented: $viewModel.showReauthAlert) { 491 + Button("OK", role: .cancel) {} 492 + } message: { 493 + Text("Please sign out and back in to enable blocking. This is a one-time step after the update.") 497 494 } 498 - } 499 - .onChange(of: deletedGalleryUri) { _, uri in 500 - if let uri { 501 - viewModel.galleries.removeAll { $0.uri == uri } 502 - deletedGalleryUri = nil 495 + .overlay(alignment: .center) { 496 + if showCopiedToast { CopiedCheckmarkToast() } 503 497 } 504 - } 505 - .alert("Sign in again to block", isPresented: $viewModel.showReauthAlert) { 506 - Button("OK", role: .cancel) {} 507 - } message: { 508 - Text("Please sign out and back in to enable blocking. This is a one-time step after the update.") 509 - } 510 - .overlay(alignment: .center) { 511 - if showCopiedToast { CopiedCheckmarkToast() } 512 - } 513 - .animation(.spring(response: 0.4, dampingFraction: 0.7), value: showCopiedToast) 514 - .sensoryFeedback(.impact(weight: .medium), trigger: showCopiedToast) 498 + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: showCopiedToast) 499 + .sensoryFeedback(.impact(weight: .medium), trigger: showCopiedToast) 500 + .coordinateSpace(.named("profileScroll")) 501 + .onChange(of: viewMode) { _, _ in 502 + if tabSectionViewportMinY < 0 { 503 + withAnimation(.smooth(duration: 0.35)) { 504 + scrollProxy.scrollTo("profileTabSection", anchor: .top) 505 + } 506 + } 507 + } 508 + } // close ScrollViewReader 515 509 } 516 510 517 511 private func copyText(_ text: String) { ··· 568 562 } 569 563 570 564 private func tabButton(icon: String, mode: ProfileViewMode) -> some View { 571 - let isActive = viewMode == mode 565 + let modes: [ProfileViewMode] = [.grid, .favorites, .stories] 566 + let activeIdx: Int = { 567 + if tabPageWidth > 0 { 568 + let raw = Int((tabScrollOffsetX / tabPageWidth).rounded()) 569 + return max(0, min(modes.count - 1, raw)) 570 + } 571 + return modes.firstIndex(of: viewMode) ?? 0 572 + }() 573 + let isActive = modes.firstIndex(of: mode) == activeIdx 572 574 let symbolName = isActive ? icon + ".fill" : icon 573 575 return Button { 574 - withAnimation(.easeInOut(duration: 0.2)) { viewMode = mode } 575 - if mode == .stories { 576 - Task { await viewModel.loadStoryArchive(did: did, auth: auth.authContext()) } 577 - } else if mode == .favorites { 578 - Task { await viewModel.loadFavorites(did: did, auth: auth.authContext()) } 579 - } 576 + setViewMode(mode) 580 577 } label: { 581 - VStack(spacing: 4) { 582 - Image(systemName: symbolName) 583 - .font(.system(size: 22)) 584 - .foregroundStyle(isActive ? .primary : .secondary) 585 - Rectangle() 586 - .fill(viewMode == mode ? Color("AccentColor") : .clear) 587 - .frame(width: 32, height: 2.5) 588 - } 589 - .frame(maxWidth: .infinity) 590 - .padding(.vertical, 8) 578 + Image(systemName: symbolName) 579 + .font(.system(size: 22)) 580 + .foregroundStyle(isActive ? .primary : .secondary) 581 + .frame(maxWidth: .infinity) 582 + .padding(.top, 8) 583 + .padding(.bottom, 14) 584 + .contentShape(Rectangle()) 591 585 } 592 586 .buttonStyle(.plain) 593 587 } 594 588 589 + private func setViewMode(_ mode: ProfileViewMode) { 590 + guard mode != viewMode else { return } 591 + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { 592 + viewMode = mode 593 + } 594 + if mode == .stories { 595 + Task { await viewModel.loadStoryArchive(did: did, auth: auth.authContext()) } 596 + } else if mode == .favorites { 597 + Task { await viewModel.loadFavorites(did: did, auth: auth.authContext()) } 598 + } 599 + } 600 + 601 + private var ownProfileTabSection: some View { 602 + let modes: [ProfileViewMode] = [.grid, .favorites, .stories] 603 + let currentIdx = modes.firstIndex(of: viewMode) ?? 0 604 + 605 + let scrollBinding = Binding<ProfileViewMode?>( 606 + get: { viewMode }, 607 + set: { newMode in 608 + guard let newMode, newMode != viewMode else { return } 609 + viewMode = newMode 610 + if newMode == .stories { 611 + Task { await viewModel.loadStoryArchive(did: did, auth: auth.authContext()) } 612 + } else if newMode == .favorites { 613 + Task { await viewModel.loadFavorites(did: did, auth: auth.authContext()) } 614 + } 615 + } 616 + ) 617 + 618 + return VStack(spacing: 0) { 619 + // Tab bar with a single indicator driven by live scroll offset. 620 + HStack(spacing: 0) { 621 + tabButton(icon: "square.grid.3x3", mode: .grid) 622 + tabButton(icon: "heart", mode: .favorites) 623 + tabButton(icon: "clock", mode: .stories) 624 + } 625 + .overlay(alignment: .bottomLeading) { 626 + GeometryReader { tabBarGeo in 627 + let tabWidth = tabBarGeo.size.width / CGFloat(modes.count) 628 + let indicatorWidth: CGFloat = 32 629 + let fraction: CGFloat = tabPageWidth > 0 630 + ? tabScrollOffsetX / tabPageWidth 631 + : CGFloat(currentIdx) 632 + let clamped = max(0, min(fraction, CGFloat(modes.count - 1))) 633 + let xOffset = clamped * tabWidth + (tabWidth - indicatorWidth) / 2 634 + Rectangle() 635 + .fill(Color("AccentColor")) 636 + .frame(width: indicatorWidth, height: 2.5) 637 + .offset(x: xOffset, y: -6) 638 + } 639 + .frame(height: 2.5) 640 + .allowsHitTesting(false) 641 + } 642 + 643 + // Native horizontally-paged grids. SwiftUI handles the physics, snapping, 644 + // and axis disambiguation with the outer vertical ScrollView. 645 + ScrollView(.horizontal) { 646 + HStack(alignment: .top, spacing: 0) { 647 + galleriesGrid 648 + .frame(maxHeight: .infinity, alignment: .top) 649 + .containerRelativeFrame(.horizontal) 650 + .id(ProfileViewMode.grid) 651 + favoritesGrid 652 + .frame(maxHeight: .infinity, alignment: .top) 653 + .containerRelativeFrame(.horizontal) 654 + .id(ProfileViewMode.favorites) 655 + storyArchiveGrid 656 + .frame(maxHeight: .infinity, alignment: .top) 657 + .containerRelativeFrame(.horizontal) 658 + .id(ProfileViewMode.stories) 659 + } 660 + .scrollTargetLayout() 661 + } 662 + .scrollTargetBehavior(.paging) 663 + .scrollPosition(id: scrollBinding) 664 + .scrollIndicators(.hidden) 665 + .onScrollGeometryChange(for: CGFloat.self) { geo in 666 + geo.contentOffset.x 667 + } action: { _, newValue in 668 + tabScrollOffsetX = newValue 669 + } 670 + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { newWidth in 671 + if newWidth > 0 { tabPageWidth = newWidth } 672 + } 673 + .frame(minHeight: 500) 674 + } 675 + } 676 + 595 677 @ViewBuilder 596 678 private var galleriesGrid: some View { 597 679 if viewModel.galleries.isEmpty, !viewModel.isLoading { ··· 827 909 } 828 910 return URL(string: "\(messageMe.messageMeUrl)/web#\(did)+\(viewerDid)") 829 911 } 912 + 913 + private func openAvatarOverlay() { 914 + suppressAvatarMorph = false 915 + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { 916 + showAvatarOverlay = true 917 + } 918 + } 919 + 920 + private func dismissAvatarOverlay() { 921 + // Detach matchedGeometryEffect before the removal transition so the overlay 922 + // fades instead of morphing back into the avatar anchor. The flag resets 923 + // after the transition so the next open still animates from the avatar. 924 + suppressAvatarMorph = true 925 + withAnimation(.easeOut(duration: 0.25)) { 926 + showAvatarOverlay = false 927 + } 928 + Task { @MainActor in 929 + try? await Task.sleep(for: .milliseconds(300)) 930 + suppressAvatarMorph = false 931 + } 932 + } 830 933 } 831 934 832 935 struct AvatarOverlay: View { 833 936 let url: String 937 + var namespace: Namespace.ID? 834 938 let onDismiss: () -> Void 835 939 836 940 @State private var zoomState = ImageZoomState() 941 + @State private var circularImage: UIImage? 837 942 @State private var dragOffset: CGFloat = 0 838 - @State private var circularImage: UIImage? 943 + @GestureState private var dragDelta: CGFloat = 0 944 + 945 + private var liveDrag: CGFloat { 946 + dragOffset + dragDelta 947 + } 839 948 840 949 private var backgroundOpacity: Double { 841 950 guard !zoomState.showOverlay else { return 0.92 } 842 - return max(0, 0.92 - abs(dragOffset) / 250) 951 + return max(0, 0.92 - abs(liveDrag) / 250) 843 952 } 844 953 845 954 var body: some View { ··· 847 956 Color.black 848 957 .opacity(backgroundOpacity) 849 958 .ignoresSafeArea() 959 + .contentShape(Rectangle()) 960 + .onTapGesture { onDismiss() } 850 961 851 - if let image = circularImage { 852 - ZoomableImage(localImage: image, aspectRatio: 1.0, onSingleTap: { 853 - guard !zoomState.showOverlay else { return } 854 - onDismiss() 855 - }) 856 - .padding(32) 857 - .offset(y: dragOffset) 858 - } else { 859 - ProgressView() 962 + GeometryReader { geo in 963 + let side = geo.size.width - 64 964 + Group { 965 + if let image = circularImage { 966 + ZoomableImage( 967 + localImage: image, 968 + aspectRatio: 1.0, 969 + onSingleTap: { 970 + // Ignore taps while actively pinch-zoomed — ZoomableImage 971 + // emits a single tap on release too, and we don't want 972 + // that to dismiss. 973 + if !zoomState.showOverlay { onDismiss() } 974 + } 975 + ) 976 + .frame(width: side, height: side) 977 + } else { 978 + ProgressView() 979 + .tint(.white) 980 + .frame(width: side, height: side) 981 + } 982 + } 983 + .position(x: geo.size.width / 2, y: geo.size.height / 2) 984 + .offset(y: liveDrag) 985 + .if(namespace != nil) { view in 986 + view.matchedGeometryEffect(id: "avatar", in: namespace!, isSource: false) 987 + } 860 988 } 989 + .ignoresSafeArea() 861 990 } 862 - .gesture( 991 + .environment(zoomState) 992 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 993 + .simultaneousGesture( 863 994 DragGesture() 864 - .onChanged { val in 865 - guard !zoomState.showOverlay else { return } 866 - dragOffset = val.translation.height 995 + .updating($dragDelta) { val, state, _ in 996 + // Don't let a 1-finger drag move the image while the user is 997 + // pinch-zooming — ZoomableImage's 2-finger pan handles that. 998 + if !zoomState.showOverlay { state = val.translation.height } 867 999 } 868 1000 .onEnded { val in 869 - let shouldDismiss = !zoomState.showOverlay && ( 870 - abs(val.translation.height) > 80 || 871 - abs(val.predictedEndTranslation.height) > 150 872 - ) 1001 + guard !zoomState.showOverlay else { return } 1002 + let shouldDismiss = abs(val.translation.height) > 80 1003 + || abs(val.predictedEndTranslation.height) > 150 873 1004 if shouldDismiss { 874 1005 onDismiss() 875 1006 } else { ··· 879 1010 } 880 1011 } 881 1012 ) 882 - .environment(zoomState) 883 - .modifier(ImageZoomOverlay(zoomState: zoomState)) 884 1013 .task { 885 1014 guard let imageURL = URL(string: url) else { return } 886 - let spid = avatarOverlaySignposter.makeSignpostID() 887 - let state = avatarOverlaySignposter.beginInterval("LoadCircularAvatar", id: spid, "url=\(url)") 888 - avatarOverlayLogger.debug("[LoadCircularAvatar] begin url=\(url)") 889 1015 let request = ImageRequest(url: imageURL, processors: [ImageProcessors.Circle()]) 890 1016 circularImage = try? await ImagePipeline.shared.image(for: request) 891 - avatarOverlayLogger.debug("[LoadCircularAvatar] end success=\(circularImage != nil)") 892 - avatarOverlaySignposter.endInterval("LoadCircularAvatar", state, "success=\(circularImage != nil)") 893 1017 } 894 1018 } 895 1019 }
+1 -1
Grain/Views/Search/SearchView.swift
··· 193 193 } 194 194 } 195 195 .sheet(item: $reportGallery) { gallery in 196 - ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid ?? "") 196 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid) 197 197 } 198 198 .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 199 199 Button("Delete", role: .destructive) {
+1 -3
Grain/Views/Stories/StoryStripView.swift
··· 8 8 var sortVersion: Int = 0 9 9 let onAuthorTap: (GrainStoryAuthor, Int) -> Void 10 10 var onAuthorLongPress: ((String) -> Void)? 11 - var onViewPhoto: ((String) -> Void)? 12 11 let onCreateTap: () -> Void 13 12 14 13 private let avatarSize: CGFloat = 68 ··· 70 69 handle: author.profile.handle, 71 70 hasStory: true, 72 71 onViewProfile: { onAuthorLongPress?(author.profile.did) }, 73 - onViewStory: { onAuthorTap(author, 0) }, 74 - onViewPhoto: author.profile.avatar.map { url in { onViewPhoto?(url) } } 72 + onViewStory: { onAuthorTap(author, 0) } 75 73 ) { 76 74 StoryRingView(hasStory: true, viewed: isViewed, size: 96) { 77 75 AvatarView(url: author.profile.avatar, size: 96)