iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: favorites source and polish for ProfileGalleryFeedView

- Add ProfileGalleryFeedSource (.galleries/.favorites) and route
loadMore/delete/comment-count through the active source
- Expose hasMoreGalleries/hasMoreFavorites on the viewmodel and gate
the tail spinner so it disappears once the server confirms no more
- Rightward swipe outside the carousel dismisses the view
- Add favorites error UI with retry on the profile favorites grid

authored by

Hima Aramona and committed by
Chad Miller
1d5e569a 89b1a11c

+128 -14
+13 -3
Grain/ViewModels/ProfileDetailViewModel.swift
··· 1 1 import Foundation 2 + import OSLog 2 3 import UIKit 4 + 5 + private let profileLogger = Logger(subsystem: "social.grain.grain", category: "Profile") 3 6 4 7 @Observable 5 8 @MainActor ··· 19 22 /// not the same as a confirmed empty list. 20 23 var favoritesLoaded = false 21 24 var isLoadingFavorites = false 25 + var favoritesError: Error? 22 26 23 27 private var galleryCursor: String? 24 - private var hasMoreGalleries = true 28 + private(set) var hasMoreGalleries = true 25 29 private var archiveCursor: String? 26 30 private var hasMoreArchive = true 27 31 private var archiveLoaded = false 28 32 private var favoritesCursor: String? 29 - private var hasMoreFavorites = true 33 + private(set) var hasMoreFavorites = true 30 34 private let client: XRPCClient 31 35 32 36 /// Max favorites persisted to disk. Enough for an instant top-of-list on ··· 121 125 func loadFavorites(did: String, auth: AuthContext? = nil) async { 122 126 guard !favoritesLoaded, !isLoadingFavorites else { return } 123 127 isLoadingFavorites = true 128 + favoritesError = nil 129 + profileLogger.info("loadFavorites start did=\(did, privacy: .public) hasAuth=\(auth != nil, privacy: .public)") 124 130 do { 125 131 let response = try await client.getActorFavorites(actor: did, auth: auth) 126 132 favoriteGalleries = response.items ?? [] 127 133 favoritesCursor = response.cursor 128 134 hasMoreFavorites = response.cursor != nil 135 + profileLogger.info("loadFavorites ok count=\(favoriteGalleries.count, privacy: .public)") 129 136 let toCache = Array(favoriteGalleries.prefix(Self.favoritesDiskCacheLimit)) 130 137 let key = Self.favoritesCacheKey(did: did) 131 138 Task.detached(priority: .utility) { 132 139 FeedCache.shared.save(toCache, key: key) 133 140 } 134 - } catch {} 141 + } catch { 142 + favoritesError = error 143 + profileLogger.error("loadFavorites failed: \(error, privacy: .public)") 144 + } 135 145 favoritesLoaded = true 136 146 isLoadingFavorites = false 137 147 }
+96 -11
Grain/Views/Profile/ProfileGalleryFeedView.swift
··· 1 1 import SwiftUI 2 2 3 + enum ProfileGalleryFeedSource: Hashable { 4 + case galleries 5 + case favorites 6 + } 7 + 8 + struct ProfileGallerySelection: Hashable { 9 + let uri: String 10 + let source: ProfileGalleryFeedSource 11 + } 12 + 3 13 struct ProfileGalleryFeedView: View { 4 14 @Environment(AuthManager.self) private var auth 15 + @Environment(\.dismiss) private var dismiss 5 16 @Bindable var viewModel: ProfileDetailViewModel 6 17 let client: XRPCClient 7 18 let did: String 8 19 let initialUri: String 20 + let source: ProfileGalleryFeedSource 9 21 10 22 @State private var didExpand = false 11 23 @State private var scrollAnchor: String? ··· 19 31 @State private var deleteGalleryUri: String? 20 32 @State private var showDeleteConfirmation = false 21 33 22 - init(viewModel: ProfileDetailViewModel, client: XRPCClient, did: String, initialUri: String) { 34 + init( 35 + viewModel: ProfileDetailViewModel, 36 + client: XRPCClient, 37 + did: String, 38 + initialUri: String, 39 + source: ProfileGalleryFeedSource = .galleries 40 + ) { 23 41 self.viewModel = viewModel 24 42 self.client = client 25 43 self.did = did 26 44 self.initialUri = initialUri 45 + self.source = source 27 46 _scrollAnchor = State(initialValue: initialUri) 28 47 } 29 48 49 + private var items: [GrainGallery] { 50 + switch source { 51 + case .galleries: viewModel.galleries 52 + case .favorites: viewModel.favoriteGalleries 53 + } 54 + } 55 + 56 + private var itemsBinding: Binding<[GrainGallery]> { 57 + switch source { 58 + case .galleries: $viewModel.galleries 59 + case .favorites: $viewModel.favoriteGalleries 60 + } 61 + } 62 + 63 + private var hasMore: Bool { 64 + switch source { 65 + case .galleries: viewModel.hasMoreGalleries 66 + case .favorites: viewModel.hasMoreFavorites 67 + } 68 + } 69 + 30 70 private var tappedIndex: Int { 31 - viewModel.galleries.firstIndex(where: { $0.uri == initialUri }) ?? 0 71 + items.firstIndex(where: { $0.uri == initialUri }) ?? 0 32 72 } 33 73 34 74 private var renderStartIndex: Int { 35 75 didExpand ? 0 : tappedIndex 36 76 } 37 77 78 + private func loadMore() async { 79 + switch source { 80 + case .galleries: 81 + await viewModel.loadMoreGalleries(did: did, auth: auth.authContext()) 82 + case .favorites: 83 + await viewModel.loadMoreFavorites(did: did, auth: auth.authContext()) 84 + } 85 + } 86 + 87 + private func updateCommentCount(uri: String, count: Int) { 88 + switch source { 89 + case .galleries: 90 + if let idx = viewModel.galleries.firstIndex(where: { $0.uri == uri }) { 91 + viewModel.galleries[idx].commentCount = count 92 + } 93 + case .favorites: 94 + if let idx = viewModel.favoriteGalleries.firstIndex(where: { $0.uri == uri }) { 95 + viewModel.favoriteGalleries[idx].commentCount = count 96 + } 97 + } 98 + } 99 + 100 + private func removeAfterDelete(uri: String) { 101 + switch source { 102 + case .galleries: 103 + viewModel.galleries.removeAll { $0.uri == uri } 104 + case .favorites: 105 + viewModel.favoriteGalleries.removeAll { $0.uri == uri } 106 + } 107 + } 108 + 38 109 var body: some View { 39 110 ScrollView { 40 111 LazyVStack(spacing: 0) { 41 - ForEach(Array($viewModel.galleries.enumerated()), id: \.element.id) { index, $gallery in 112 + ForEach(Array(itemsBinding.enumerated()), id: \.element.id) { index, $gallery in 42 113 if index >= renderStartIndex { 43 114 galleryCard(gallery: $gallery, index: index) 44 115 .id(gallery.uri) 45 116 } 46 117 } 47 118 48 - if viewModel.isLoading { 119 + if viewModel.isLoading, hasMore { 49 120 ProgressView() 50 121 .padding() 51 122 } ··· 53 124 .scrollTargetLayout() 54 125 } 55 126 .scrollPosition(id: $scrollAnchor, anchor: .top) 56 - .contentMargins(.top, 16, for: .scrollContent) 127 + .scrollTargetBehavior(.viewAligned) 128 + .contentMargins(.top, 24, for: .scrollContent) 129 + .gesture( 130 + // Rightward swipe anywhere outside the carousel pops the nav. 131 + // Using exclusive .gesture (not simultaneous) so the child TabView 132 + // in GalleryCardView claims swipes inside the image area first; 133 + // ours only fires for touches that miss the carousel. 134 + DragGesture(minimumDistance: 30) 135 + .onEnded { value in 136 + let dx = value.translation.width 137 + let dy = value.translation.height 138 + let predicted = value.predictedEndTranslation.width 139 + if dx > 80, abs(dy) < 60, predicted > 120 { 140 + dismiss() 141 + } 142 + } 143 + ) 57 144 .environment(zoomState) 58 145 .modifier(ImageZoomOverlay(zoomState: zoomState)) 59 146 .navigationBarTitleDisplayMode(.inline) ··· 100 187 cardStoryAuthor = author 101 188 }, 102 189 onCommentCountChanged: { count in 103 - if let idx = viewModel.galleries.firstIndex(where: { $0.uri == uri }) { 104 - viewModel.galleries[idx].commentCount = count 105 - } 190 + updateCommentCount(uri: uri, count: count) 106 191 } 107 192 ) 108 193 } ··· 117 202 guard let authContext = await auth.authContext() else { return } 118 203 let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 119 204 try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 120 - viewModel.galleries.removeAll { $0.uri == uri } 205 + removeAfterDelete(uri: uri) 121 206 } 122 207 deleteGalleryUri = nil 123 208 } ··· 147 232 if g.uri == initialUri, !didExpand { 148 233 didExpand = true 149 234 } 150 - if index == viewModel.galleries.count - 1 { 151 - Task { await viewModel.loadMoreGalleries(did: did, auth: auth.authContext()) } 235 + if index == items.count - 1 { 236 + Task { await loadMore() } 152 237 } 153 238 } 154 239 }
+19
Grain/Views/Profile/ProfileView.swift
··· 803 803 ProgressView() 804 804 .frame(maxWidth: .infinity) 805 805 .padding(.top, 60) 806 + } else if viewModel.favoriteGalleries.isEmpty, let err = viewModel.favoritesError { 807 + VStack(spacing: 8) { 808 + Text("Couldn't load favorites") 809 + .font(.subheadline) 810 + .foregroundStyle(.secondary) 811 + Text(String(describing: err)) 812 + .font(.caption2) 813 + .foregroundStyle(.tertiary) 814 + .multilineTextAlignment(.center) 815 + .padding(.horizontal, 24) 816 + Button("Retry") { 817 + viewModel.favoritesLoaded = false 818 + Task { await viewModel.loadFavorites(did: did, auth: auth.authContext()) } 819 + } 820 + .font(.subheadline) 821 + .buttonStyle(.bordered) 822 + } 823 + .frame(maxWidth: .infinity) 824 + .padding(.top, 60) 806 825 } else if viewModel.favoriteGalleries.isEmpty { 807 826 Text("No favorites yet") 808 827 .font(.subheadline)