fix(mobile): open the lightbox on the cached thumbnail and size thumbnails to the card

The lightbox placeholder was the original image URL instead of the
proxied thumbnail the card had loaded, so it never came from the cache,
and expo-image on Android decodes placeholders at full size. Every
lightbox page also got the tapped image as its placeholder, so the other
pages showed the wrong picture until their own loaded.

Hand the lightbox the card's proxied thumbnail for the tapped page only,
and let the carousel and the lightbox page through the same deduplicated
media list so an index means the same image in both. The Social Media
view had the same placeholder bug.

Cards in the Pictures view asked the proxy for 1200 px tall images. Ask
for one side sized from the measured card instead, rounded up to 50 dp
so the URL stays stable, and the card's height for images relatively
wider than the card so cover fit stays sharp. Asking for both sides
would make the proxy crop the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DIYgod
2026-10-16 12:00:43 +08:00
parent a044040bad
commit 13e169760d
3 changed files with 39 additions and 31 deletions
@@ -23,6 +23,12 @@ import { VideoPlayer } from "../video/VideoPlayer"
const getMediaKey = (mediaItem: MediaModel) =>
mediaItem.url || mediaItem.preview_image_url || `${mediaItem.type}-media`
// Thumbnails are sized to the card, rounded up so that a slightly different layout asks for the
// same URL and hits the cache
const THUMBNAIL_SIZE_STEP = 50
const toThumbnailSize = (size: number) =>
Math.ceil(size / THUMBNAIL_SIZE_STEP) * THUMBNAIL_SIZE_STEP
export const MediaCarousel = ({
ref,
entryId,
@@ -34,7 +40,7 @@ export const MediaCarousel = ({
ref?: Ref<View>
entryId: string
media: MediaModel[]
onPreview?: (index: number, placeholder: ImageSource | undefined) => void
onPreview?: (index: number, thumbnail: ImageSource | undefined) => void
aspectRatio: number
view: FeedViewType
}) => {
@@ -81,20 +87,26 @@ export const MediaCarousel = ({
style={{ height: containerHeight }}
>
{pages.map(({ media: m, index, imageUrl }, position) => {
// An entry can carry dozens of images, so only the visible page and its neighbors load
if (Math.abs(position - activeIndex) > 1) {
// An entry can carry dozens of images, so only the visible page and its neighbors load,
// and only once the card is measured, as their thumbnails are sized to it
if (!containerWidth || Math.abs(position - activeIndex) > 1) {
return (
<View key={imageUrl} style={{ width: containerWidth, height: containerHeight }} />
)
}
const proxy = {
height: 400,
}
// The image covers the card, so one relatively wider than the card needs the card's
// height and any other its width. Asking for one side keeps the image uncropped
const proxy =
m.width && m.height && m.width / m.height > aspectRatio
? { height: toThumbnailSize(containerHeight) }
: { width: toThumbnailSize(containerWidth) }
const ImageItem = (
<NativePressable
onPress={() => {
const [placeholder] = getAllSources({ uri: imageUrl }, proxy)
onPreview?.(index, { blurhash: m.blurhash, ...placeholder })
// The proxied thumbnail is what this card shows, so the lightbox can show it from
// the cache while the original loads
const [, thumbnail] = getAllSources({ uri: imageUrl }, proxy)
onPreview?.(index, thumbnail)
}}
>
<Image
@@ -28,6 +28,8 @@ export function EntryPictureItem({ id }: { id: string }) {
publishedAt: state.publishedAt,
author: state.author,
}))
// The carousel and the lightbox page through the same list, so an index means one image in both
const media = useMemo(() => uniqBy(item?.media ?? [], "url"), [item?.media])
if (!item || !item.media) {
return null
}
@@ -48,9 +50,9 @@ export function EntryPictureItem({ id }: { id: string }) {
<View className="m-1">
<MediaItems
ref={aviRef}
media={item.media}
media={media}
entryId={id}
onPreview={(index, placeholder) => {
onPreview={(index, thumbnail) => {
const feed = getFeedById(item.feedId!)
if (!feed) {
return
@@ -64,11 +66,10 @@ export function EntryPictureItem({ id }: { id: string }) {
const rect = measure(aviRef)
runOnJS(openLightbox)({
images: (item.media ?? []).map((media) => ({
uri: media.url,
thumbUri: placeholder ?? {
uri: media.url,
},
images: media.map((mediaItem, mediaIndex) => ({
uri: mediaItem.url,
// Only the tapped image has its thumbnail at hand, other pages wait for their own
thumbUri: mediaIndex === index ? thumbnail : undefined,
thumbDimensions: null,
thumbRect: rect,
dimensions: rect
@@ -102,13 +103,10 @@ const MediaItems = ({
ref?: Ref<View>
media: MediaModel[]
entryId: string
onPreview?: (index: number, placeholder: ImageSource | undefined) => void
onPreview?: (index: number, thumbnail: ImageSource | undefined) => void
aspectRatio?: number
}) => {
const firstMedia = media[0]
const uniqMedia = useMemo(() => {
return uniqBy(media, "url")
}, [media])
if (!firstMedia) {
return null
}
@@ -120,7 +118,7 @@ const MediaItems = ({
ref={ref}
view={FeedViewType.Pictures}
entryId={entryId}
media={uniqMedia}
media={media}
onPreview={onPreview}
aspectRatio={realAspectRatio}
/>
@@ -84,12 +84,12 @@ export const EntrySocialItem = memo(
})
}, [entry?.feedId, navigation])
const onPreviewImage = useCallback(
(index: number, rect: MeasuredDimensions | null, placeholder: ImageSource | undefined) => {
(index: number, rect: MeasuredDimensions | null, thumbnail: ImageSource | undefined) => {
"worklet"
runOnJS(openLightbox)({
images: (entry?.media ?? [])
.map((mediaItem) => {
.map((mediaItem, mediaIndex) => {
const imageUrl =
mediaItem.type === "video"
? mediaItem.preview_image_url
@@ -102,9 +102,8 @@ export const EntrySocialItem = memo(
width: mediaItem.width ?? 0,
height: mediaItem.height ?? 0,
},
thumbUri: placeholder ?? {
uri: imageUrl,
},
// Only the tapped image has its thumbnail at hand, other pages wait for their own
thumbUri: mediaIndex === index ? thumbnail : undefined,
thumbDimensions: null,
thumbRect: rect,
type: "image" as const,
@@ -194,7 +193,7 @@ interface EntryMediaItemProps {
onPreviewImage: (
index: number,
rect: MeasuredDimensions | null,
placeholder: ImageSource | undefined,
thumbnail: ImageSource | undefined,
) => void
}
const EntryMediaItem = memo(
@@ -214,7 +213,9 @@ const EntryMediaItem = memo(
<Animated.View ref={aviRef} collapsable={false}>
<NativePressable
onPress={() => {
const [placeholder] = getAllSources(
// The proxied thumbnail is what this tile shows, so the lightbox can show it from the
// cache while the original loads
const [, thumbnail] = getAllSources(
{
uri: imageUrl,
},
@@ -224,10 +225,7 @@ const EntryMediaItem = memo(
"worklet"
const rect = measure(aviRef)
onPreviewImage(index, rect, {
blurhash: mediaItem.blurhash,
...placeholder,
})
onPreviewImage(index, rect, thumbnail)
})()
}}
>