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: hide favorites with broken thumbnails

Track uris whose thumb fails to load (dangling blob refs the server
still lists) and filter them out of the favorites grid. Show a
'Deleted' placeholder in-place until the next load drops them, and
eagerly probe the first 9 thumbs so off-screen broken items get
marked before the user scrolls.

authored by

Hima Aramona and committed by
Chad Miller
b0f6fd24 7e2e6fdd

+110 -14
+16 -9
Grain/ViewModels/ProfileDetailViewModel.swift
··· 23 23 var favoritesLoaded = false 24 24 var isLoadingFavorites = false 25 25 var favoritesError: Error? 26 + /// URIs of favorites whose thumbnail failed to load this session — usually 27 + /// dangling refs to since-deleted blobs. Populated by the grid's LazyImage 28 + /// completion handler; applied on the next favorites load so the current 29 + /// view doesn't reflow. 30 + var brokenFavoriteUris: Set<String> = [] 26 31 27 32 private var galleryCursor: String? 28 33 private(set) var hasMoreGalleries = true ··· 50 55 let isOwnProfile = viewer != nil && viewer == did 51 56 if isOwnProfile, favoriteGalleries.isEmpty { 52 57 let cached = FeedCache.shared.load(key: Self.favoritesCacheKey(did: did)) 53 - let hydrated = Self.hydratedFavorites(cached) 58 + let hydrated = hydratedFavorites(cached) 54 59 if !hydrated.isEmpty { 55 60 favoriteGalleries = hydrated 56 61 } ··· 130 135 profileLogger.info("loadFavorites start did=\(did, privacy: .public) hasAuth=\(auth != nil, privacy: .public)") 131 136 do { 132 137 let response = try await client.getActorFavorites(actor: did, auth: auth) 133 - favoriteGalleries = Self.hydratedFavorites(response.items ?? []) 138 + favoriteGalleries = hydratedFavorites(response.items ?? []) 134 139 favoritesCursor = response.cursor 135 140 hasMoreFavorites = response.cursor != nil 136 141 profileLogger.info("loadFavorites ok count=\(favoriteGalleries.count, privacy: .public)") ··· 151 156 "favorites_\(did)" 152 157 } 153 158 154 - /// Drops favorites whose underlying gallery has no photos — either an 155 - /// empty/deleted gallery the server couldn't hydrate, or a dangling 156 - /// favorite pointing at a since-deleted record. Without this, the grid 157 - /// shows a black void where the thumbnail would be. 158 - private static func hydratedFavorites(_ galleries: [GrainGallery]) -> [GrainGallery] { 159 - galleries.filter { !($0.items?.isEmpty ?? true) } 159 + /// Drops favorites that would render empty: galleries with no photos 160 + /// (deleted or never-populated), and galleries whose thumbnail already 161 + /// failed to load this session (tracked in `brokenFavoriteUris`). Applied 162 + /// on every load path so the dangling ones disappear on the next refresh. 163 + private func hydratedFavorites(_ galleries: [GrainGallery]) -> [GrainGallery] { 164 + galleries.filter { 165 + !($0.items?.isEmpty ?? true) && !brokenFavoriteUris.contains($0.uri) 166 + } 160 167 } 161 168 162 169 func loadMoreFavorites(did: String, auth: AuthContext? = nil) async { ··· 164 171 isLoading = true 165 172 do { 166 173 let response = try await client.getActorFavorites(actor: did, cursor: cursor, auth: auth) 167 - favoriteGalleries.append(contentsOf: Self.hydratedFavorites(response.items ?? [])) 174 + favoriteGalleries.append(contentsOf: hydratedFavorites(response.items ?? [])) 168 175 favoritesCursor = response.cursor 169 176 hasMoreFavorites = response.cursor != nil 170 177 } catch {}
+94 -5
Grain/Views/Profile/ProfileView.swift
··· 26 26 @State private var viewMode: ProfileViewMode = .grid 27 27 @State private var tabPageWidth: CGFloat = 0 28 28 @State private var tabScrollOffsetX: CGFloat = 0 29 + @State private var tabHeights: [ProfileViewMode: CGFloat] = [:] 29 30 @State private var tabSectionViewportMinY: CGFloat = .infinity 30 31 @State private var zoomState = ImageZoomState() 31 32 @State private var cardStoryAuthor: GrainStoryAuthor? ··· 645 646 ScrollView(.horizontal) { 646 647 HStack(alignment: .top, spacing: 0) { 647 648 galleriesGrid 648 - .frame(maxHeight: .infinity, alignment: .top) 649 649 .containerRelativeFrame(.horizontal) 650 + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { h in 651 + tabHeights[.grid] = h 652 + } 650 653 .id(ProfileViewMode.grid) 651 654 favoritesGrid 652 - .frame(maxHeight: .infinity, alignment: .top) 653 655 .containerRelativeFrame(.horizontal) 656 + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { h in 657 + tabHeights[.favorites] = h 658 + } 654 659 .id(ProfileViewMode.favorites) 655 660 storyArchiveGrid 656 - .frame(maxHeight: .infinity, alignment: .top) 657 661 .containerRelativeFrame(.horizontal) 662 + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { h in 663 + tabHeights[.stories] = h 664 + } 658 665 .id(ProfileViewMode.stories) 659 666 } 660 667 .scrollTargetLayout() ··· 670 677 .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { newWidth in 671 678 if newWidth > 0 { tabPageWidth = newWidth } 672 679 } 673 - .frame(minHeight: 500) 680 + .frame(height: interpolatedTabHeight(modes: modes)) 681 + } 682 + } 683 + 684 + private func interpolatedTabHeight(modes: [ProfileViewMode]) -> CGFloat { 685 + let fallback: CGFloat = 200 686 + let heights = modes.map { tabHeights[$0] ?? fallback } 687 + guard tabPageWidth > 0 else { 688 + let idx = modes.firstIndex(of: viewMode) ?? 0 689 + return max(heights[idx], fallback) 674 690 } 691 + let raw = tabScrollOffsetX / tabPageWidth 692 + let clamped = max(0, min(raw, CGFloat(modes.count - 1))) 693 + let lower = Int(clamped.rounded(.down)) 694 + let upper = min(lower + 1, modes.count - 1) 695 + let t = clamped - CGFloat(lower) 696 + return max(heights[lower] * (1 - t) + heights[upper] * t, fallback) 675 697 } 676 698 677 699 @ViewBuilder ··· 848 870 Color.clear 849 871 .aspectRatio(3.0 / 4.0, contentMode: .fit) 850 872 .overlay { 851 - if let photo = gallery.items?.first { 873 + if viewModel.brokenFavoriteUris.contains(gallery.uri) { 874 + DeletedGalleryCard() 875 + } else if let photo = gallery.items?.first { 852 876 LazyImage(url: URL(string: photo.thumb)) { state in 853 877 if let image = state.image { 854 878 image ··· 858 882 Rectangle().fill(.quaternary) 859 883 } 860 884 } 885 + // Dangling blob refs (server returns an item 886 + // but the CID 404s on the CDN) — mark the uri 887 + // so the next render swaps in the deleted 888 + // card, and the next favorites load filters 889 + // it out entirely. 890 + .onCompletion { result in 891 + if case .failure = result { 892 + viewModel.brokenFavoriteUris.insert(gallery.uri) 893 + } 894 + } 895 + } else { 896 + DeletedGalleryCard() 861 897 } 862 898 } 863 899 .clipped() ··· 881 917 } 882 918 } 883 919 } 920 + // Eagerly probe the first few thumbs so broken ones above the 921 + // LazyVGrid fold get marked before the user scrolls to them. 922 + // Keyed on the top-N uris so it reruns when the list head changes. 923 + .task(id: viewModel.favoriteGalleries.prefix(9).map(\.uri).joined(separator: "|")) { 924 + await probeTopFavoriteThumbs(limit: 9) 925 + } 926 + } 927 + } 928 + 929 + private func probeTopFavoriteThumbs(limit: Int) async { 930 + let targets: [(uri: String, thumb: String?)] = viewModel.favoriteGalleries 931 + .prefix(limit) 932 + .map { ($0.uri, $0.items?.first?.thumb) } 933 + var broken: [String] = [] 934 + await withTaskGroup(of: (String, Bool).self) { group in 935 + for target in targets { 936 + let uri = target.uri 937 + let thumb = target.thumb 938 + group.addTask { 939 + guard let thumb, let url = URL(string: thumb) else { 940 + return (uri, false) 941 + } 942 + do { 943 + _ = try await ImagePipeline.shared.image(for: url) 944 + return (uri, true) 945 + } catch { 946 + return (uri, false) 947 + } 948 + } 949 + } 950 + for await (uri, ok) in group where !ok { 951 + broken.append(uri) 952 + } 953 + } 954 + for uri in broken { 955 + viewModel.brokenFavoriteUris.insert(uri) 884 956 } 885 957 } 886 958 ··· 1049 1121 .font(.caption) 1050 1122 .foregroundStyle(.secondary) 1051 1123 } 1124 + } 1125 + } 1126 + 1127 + private struct DeletedGalleryCard: View { 1128 + var body: some View { 1129 + Rectangle() 1130 + .fill(.quaternary) 1131 + .overlay { 1132 + VStack(spacing: 6) { 1133 + Image(systemName: "trash") 1134 + .font(.system(size: 18, weight: .regular)) 1135 + .foregroundStyle(.secondary) 1136 + Text("Deleted") 1137 + .font(.caption2) 1138 + .foregroundStyle(.secondary) 1139 + } 1140 + } 1052 1141 } 1053 1142 } 1054 1143