Files
bsky-social-app/src/components/images/AutoSizedImage.tsx
T
2026-06-04 01:03:11 +03:00

275 lines
7.8 KiB
TypeScript

import {useEffect, useMemo, useRef} from 'react'
import {type DimensionValue, Pressable, View} from 'react-native'
import Animated, {
type AnimatedRef,
useAnimatedRef,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {utils} from '@bsky.app/alf'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
export function ConstrainedImage({
aspectRatio,
fullBleed,
children,
minMobileAspectRatio,
}: {
aspectRatio: number
fullBleed?: boolean
minMobileAspectRatio?: number
children: React.ReactNode
}) {
const t = useTheme()
/**
* Computed as a % value to apply as `paddingTop`, this basically controls
* the height of the image.
*/
const outerAspectRatio = useMemo<DimensionValue>(() => {
const ratio = IS_NATIVE
? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box
: Math.min(1 / aspectRatio, 1) // 1:1 bounding box
return `${ratio * 100}%`
}, [aspectRatio, minMobileAspectRatio])
return (
<View style={[a.w_full]}>
<View style={[a.overflow_hidden, {paddingTop: outerAspectRatio}]}>
<View style={[a.absolute, a.inset_0, a.flex_row]}>
<View
style={[
a.h_full,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
fullBleed ? a.w_full : {aspectRatio},
]}>
{children}
</View>
</View>
</View>
</View>
)
}
export function AutoSizedImage({
image,
crop = 'constrained',
hideBadge,
onPress,
onLongPress,
onPressIn,
onContainerRef,
onDimsChange,
}: {
image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained'
hideBadge?: boolean
onPress?: (
containerRef: AnimatedRef<any>,
fetchedDims: Dimensions | null,
) => void
onLongPress?: () => void
onPressIn?: () => void
/** Fires once with the internal container ref so a parent can drive its
* own lightbox-return animation without waiting for an `onPress`. */
onContainerRef?: (ref: AnimatedRef<any>) => void
/** Fires when the underlying image reports its natural dimensions. */
onDimsChange?: (dims: Dimensions) => void
}) {
const t = useTheme()
const {_} = useLingui()
const largeAlt = useLargeAltBadgeEnabled()
const containerRef = useAnimatedRef()
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
useEffect(() => {
onContainerRef?.(containerRef)
}, [containerRef, onContainerRef])
let aspectRatio: number | undefined
const dims = image.aspectRatio
if (dims) {
aspectRatio = dims.width / dims.height
if (Number.isNaN(aspectRatio)) {
aspectRatio = undefined
}
}
let constrained: number | undefined
let max: number | undefined
let rawIsCropped: boolean | undefined
if (aspectRatio !== undefined) {
const ratio = 1 / 2 // max of 1:2 ratio in feeds
constrained = Math.max(aspectRatio, ratio)
max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
rawIsCropped = aspectRatio < constrained
}
const cropDisabled = crop === 'none'
const isCropped = rawIsCropped && !cropDisabled
const isContain = aspectRatio === undefined
const hasAlt = !!image.alt
const contents = (
<Animated.View ref={containerRef} collapsable={false} style={{flex: 1}}>
<Image
contentFit={isContain ? 'contain' : 'cover'}
style={[a.w_full, a.h_full]}
source={image.thumb}
accessible={true} // Must set for `accessibilityLabel` to work
accessibilityIgnoresInvertColors
accessibilityLabel={image.alt}
accessibilityHint=""
onLoad={e => {
if (!isContain) {
const dims = {
width: e.source.width,
height: e.source.height,
}
fetchedDimsRef.current = dims
onDimsChange?.(dims)
}
}}
loading="lazy"
useAppleWebpCodec
/>
<MediaInsetBorder />
{(hasAlt || isCropped) && !hideBadge ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeAlt && [
{
gap: 4,
},
],
]}>
{isCropped && (
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
{
padding: 3,
opacity: 0.8,
},
largeAlt && [
{
padding: 5,
},
],
]}>
<Fullscreen
fill={t.atoms.text_contrast_high.color}
width={largeAlt ? 18 : 12}
/>
</View>
)}
{hasAlt && (
<View
style={[
a.justify_center,
a.rounded_xs,
t.atoms.bg_contrast_25,
{
padding: 3,
opacity: 0.8,
},
largeAlt && [
{
padding: 5,
},
],
]}>
<Text style={[a.font_bold, largeAlt ? a.text_xs : {fontSize: 8}]}>
<Trans>ALT</Trans>
</Text>
</View>
)}
</View>
) : null}
</Animated.View>
)
if (cropDisabled) {
return (
<Pressable
onPress={() => onPress?.(containerRef, fetchedDimsRef.current)}
onLongPress={onLongPress}
onPressIn={onPressIn}
// alt here is what screen readers actually use
accessibilityLabel={image.alt}
accessibilityHint={_(msg`Views full image`)}
accessibilityRole="button"
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={({pressed}) => [
a.w_full,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{aspectRatio: max ?? 1},
web([
a.transition_transform,
{transitionDuration: '200ms'},
pressed && {transform: [{scale: 0.99}]},
]),
]}>
{contents}
</Pressable>
)
} else {
return (
<ConstrainedImage
fullBleed={crop === 'square'}
aspectRatio={constrained ?? 1}>
<Pressable
onPress={() => onPress?.(containerRef, fetchedDimsRef.current)}
onLongPress={onLongPress}
onPressIn={onPressIn}
// alt here is what screen readers actually use
accessibilityLabel={image.alt}
accessibilityHint={_(msg`Views full image`)}
accessibilityRole="button"
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={({pressed}) => [
a.h_full,
a.rounded_md,
a.overflow_hidden,
web([
a.transition_transform,
{transitionDuration: '200ms'},
pressed && {transform: [{scale: 0.99}]},
]),
]}>
{contents}
</Pressable>
</ConstrainedImage>
)
}
}