mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-16 20:51:43 +08:00
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:
@@ -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)
|
||||
})()
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user