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: distinguish 404 thumbs from transient failures in favorites

The previous detection treated any LazyImage/Nuke failure as a
dangling CDN ref, so slow first loads marked healthy favorites as
Deleted. It also missed galleries whose thumb string was empty or
unparseable — LazyImage(url: nil) never loads and never fires a
completion, so the card sat blank.

- hydratedFavorites drops galleries with empty/invalid thumb URLs.
- Replace the Nuke probe with a URLSession HEAD that only marks a
uri broken on HTTP 404/410; transient errors stay retryable via
a new probedFavoriteUris set.
- Cover every loaded favorite instead of the top 9; rerun when
loadMore appends.
- Drop the .onCompletion handler and the DeletedGalleryCard path.
Broken uris are hidden at render time via a new visibleFavorites
computed property.

authored by

Hima Aramona and committed by
Chad Miller
d762ecb3 24c3f4e4

+90 -58
+29 -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. 26 + /// URIs whose thumb a HEAD probe has confirmed is gone (404/410) — usually 27 + /// dangling refs to since-deleted blobs. `visibleFavorites` filters these 28 + /// out at render time so they vanish without a "deleted" placeholder, and 29 + /// `hydratedFavorites` strips them on the next load so they stay gone. 30 30 var brokenFavoriteUris: Set<String> = [] 31 + /// URIs whose thumb has already been probed this session (either confirmed 32 + /// reachable or confirmed broken). Used to skip redundant HEAD requests 33 + /// when new favorite batches come in. Transient probe failures don't add 34 + /// here so they're retried next pass. 35 + var probedFavoriteUris: Set<String> = [] 36 + 37 + /// Favorites currently safe to render — drops anything a probe has 38 + /// confirmed broken. `favoriteGalleries` keeps the raw list so broken 39 + /// items can be retried if they come back; this computed view is what the 40 + /// grid iterates. 41 + var visibleFavorites: [GrainGallery] { 42 + favoriteGalleries.filter { !brokenFavoriteUris.contains($0.uri) } 43 + } 31 44 32 45 private var galleryCursor: String? 33 46 private(set) var hasMoreGalleries = true ··· 158 171 } 159 172 160 173 /// Drops favorites that would render empty: galleries with no photos 161 - /// (deleted or never-populated), and galleries whose thumbnail already 162 - /// failed to load this session (tracked in `brokenFavoriteUris`). Applied 163 - /// on every load path so the dangling ones disappear on the next refresh. 174 + /// (deleted or never-populated), galleries whose thumb URL is missing or 175 + /// unparseable (LazyImage can't attempt a load so it'd sit blank forever), 176 + /// and galleries whose thumbnail was already confirmed broken this session 177 + /// (tracked in `brokenFavoriteUris`). Applied on every load path. 164 178 private func hydratedFavorites(_ galleries: [GrainGallery]) -> [GrainGallery] { 165 - galleries.filter { 166 - !($0.items?.isEmpty ?? true) && !brokenFavoriteUris.contains($0.uri) 179 + galleries.filter { gallery in 180 + guard !(gallery.items?.isEmpty ?? true) else { return false } 181 + guard !brokenFavoriteUris.contains(gallery.uri) else { return false } 182 + guard let thumb = gallery.items?.first?.thumb, 183 + !thumb.isEmpty, 184 + URL(string: thumb) != nil 185 + else { return false } 186 + return true 167 187 } 168 188 } 169 189
+61 -49
Grain/Views/Profile/ProfileView.swift
··· 856 856 .frame(maxWidth: .infinity) 857 857 .padding(.top, 60) 858 858 } else { 859 + let visible = viewModel.visibleFavorites 859 860 LazyVGrid(columns: [ 860 861 GridItem(.flexible(), spacing: 2), 861 862 GridItem(.flexible(), spacing: 2), 862 863 GridItem(.flexible(), spacing: 2), 863 864 ], spacing: 2) { 864 - ForEach(viewModel.favoriteGalleries) { gallery in 865 + ForEach(visible) { gallery in 865 866 Button { 866 867 selectedGallery = nil 867 868 DispatchQueue.main.async { ··· 871 872 Color.clear 872 873 .aspectRatio(3.0 / 4.0, contentMode: .fit) 873 874 .overlay { 874 - if viewModel.brokenFavoriteUris.contains(gallery.uri) { 875 - DeletedGalleryCard() 876 - } else if let photo = gallery.items?.first { 877 - LazyImage(url: URL(string: photo.thumb)) { state in 875 + if let photo = gallery.items?.first, let url = URL(string: photo.thumb) { 876 + LazyImage(url: url) { state in 878 877 if let image = state.image { 879 878 image 880 879 .resizable() ··· 883 882 Rectangle().fill(.quaternary) 884 883 } 885 884 } 886 - // Dangling blob refs (server returns an item 887 - // but the CID 404s on the CDN) — mark the uri 888 - // so the next render swaps in the deleted 889 - // card, and the next favorites load filters 890 - // it out entirely. 891 - .onCompletion { result in 892 - if case .failure = result { 893 - viewModel.brokenFavoriteUris.insert(gallery.uri) 894 - } 895 - } 896 885 } else { 897 - DeletedGalleryCard() 886 + Rectangle().fill(.quaternary) 898 887 } 899 888 } 900 889 .clipped() ··· 912 901 .buttonStyle(.plain) 913 902 .matchedTransitionSource(id: gallery.uri, in: galleryZoomNS) 914 903 .onAppear { 915 - if gallery.id == viewModel.favoriteGalleries.last?.id { 904 + if gallery.id == visible.last?.id { 916 905 Task { await viewModel.loadMoreFavorites(did: did, auth: auth.authContext()) } 917 906 } 918 907 } 919 908 } 920 909 } 921 - // Eagerly probe the first few thumbs so broken ones above the 922 - // LazyVGrid fold get marked before the user scrolls to them. 923 - // Keyed on the top-N uris so it reruns when the list head changes. 924 - .task(id: viewModel.favoriteGalleries.prefix(9).map(\.uri).joined(separator: "|")) { 925 - await probeTopFavoriteThumbs(limit: 9) 910 + // HEAD-probe every loaded favorite thumb so dangling CDN refs get 911 + // marked before render. Keyed on the full uri list so new batches 912 + // from loadMore trigger a re-probe; probeFavoriteThumbs itself 913 + // skips uris already checked this session. 914 + .task(id: viewModel.favoriteGalleries.map(\.uri).joined(separator: "|")) { 915 + await probeFavoriteThumbs() 926 916 } 927 917 } 928 918 } 929 919 930 - private func probeTopFavoriteThumbs(limit: Int) async { 931 - let targets: [(uri: String, thumb: String?)] = viewModel.favoriteGalleries 932 - .prefix(limit) 933 - .map { ($0.uri, $0.items?.first?.thumb) } 920 + private func probeFavoriteThumbs() async { 921 + let targets: [(uri: String, thumb: String)] = viewModel.favoriteGalleries.compactMap { gallery in 922 + guard !viewModel.brokenFavoriteUris.contains(gallery.uri), 923 + !viewModel.probedFavoriteUris.contains(gallery.uri), 924 + let thumb = gallery.items?.first?.thumb, 925 + !thumb.isEmpty 926 + else { return nil } 927 + return (gallery.uri, thumb) 928 + } 929 + guard !targets.isEmpty else { return } 930 + 934 931 var broken: [String] = [] 935 - await withTaskGroup(of: (String, Bool).self) { group in 932 + var probed: [String] = [] 933 + await withTaskGroup(of: FavoriteThumbProbe.self) { group in 936 934 for target in targets { 937 935 let uri = target.uri 938 936 let thumb = target.thumb 939 937 group.addTask { 940 - guard let thumb, let url = URL(string: thumb) else { 941 - return (uri, false) 938 + guard let url = URL(string: thumb) else { 939 + return FavoriteThumbProbe(uri: uri, result: .broken) 942 940 } 941 + var req = URLRequest(url: url) 942 + req.httpMethod = "HEAD" 943 + req.timeoutInterval = 10 943 944 do { 944 - _ = try await ImagePipeline.shared.image(for: url) 945 - return (uri, true) 945 + let (_, response) = try await URLSession.shared.data(for: req) 946 + guard let http = response as? HTTPURLResponse else { 947 + return FavoriteThumbProbe(uri: uri, result: .unknown) 948 + } 949 + if http.statusCode == 404 || http.statusCode == 410 { 950 + return FavoriteThumbProbe(uri: uri, result: .broken) 951 + } 952 + return FavoriteThumbProbe(uri: uri, result: .ok) 946 953 } catch { 947 - return (uri, false) 954 + return FavoriteThumbProbe(uri: uri, result: .unknown) 948 955 } 949 956 } 950 957 } 951 - for await (uri, ok) in group where !ok { 952 - broken.append(uri) 958 + for await probe in group { 959 + switch probe.result { 960 + case .broken: 961 + broken.append(probe.uri) 962 + probed.append(probe.uri) 963 + case .ok: 964 + probed.append(probe.uri) 965 + case .unknown: 966 + break 967 + } 953 968 } 954 969 } 955 970 for uri in broken { 956 971 viewModel.brokenFavoriteUris.insert(uri) 972 + } 973 + for uri in probed { 974 + viewModel.probedFavoriteUris.insert(uri) 957 975 } 958 976 } 959 977 ··· 1125 1143 } 1126 1144 } 1127 1145 1128 - private struct DeletedGalleryCard: View { 1129 - var body: some View { 1130 - Rectangle() 1131 - .fill(.quaternary) 1132 - .overlay { 1133 - VStack(spacing: 6) { 1134 - Image(systemName: "trash") 1135 - .font(.system(size: 18, weight: .regular)) 1136 - .foregroundStyle(.secondary) 1137 - Text("Deleted") 1138 - .font(.caption2) 1139 - .foregroundStyle(.secondary) 1140 - } 1141 - } 1142 - } 1146 + private enum FavoriteThumbProbeResult { 1147 + case ok 1148 + case broken 1149 + case unknown 1150 + } 1151 + 1152 + private struct FavoriteThumbProbe { 1153 + let uri: String 1154 + let result: FavoriteThumbProbeResult 1143 1155 } 1144 1156 1145 1157 #Preview {