import {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/macro' import {useLingui} from '@lingui/react' import {type Dimensions} from '#/lib/media/types' import { maybeModifyHighQualityImage, useHighQualityImages, } from '#/state/preferences/high-quality-images' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {atoms as a, useTheme} 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(() => { 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 ( {children} ) } export function AutoSizedImage({ image, crop = 'constrained', hideBadge, onPress, onLongPress, onPressIn, }: { image: AppBskyEmbedImages.ViewImage crop?: 'none' | 'square' | 'constrained' hideBadge?: boolean onPress?: ( containerRef: AnimatedRef, fetchedDims: Dimensions | null, ) => void onLongPress?: () => void onPressIn?: () => void }) { const t = useTheme() const {_} = useLingui() const largeAlt = useLargeAltBadgeEnabled() const containerRef = useAnimatedRef() const fetchedDimsRef = useRef<{width: number; height: number} | null>(null) const highQualityImages = useHighQualityImages() 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 = ( { if (!isContain) { fetchedDimsRef.current = { width: e.source.width, height: e.source.height, } } }} loading="lazy" /> {(hasAlt || isCropped) && !hideBadge ? ( {isCropped && ( )} {hasAlt && ( ALT )} ) : null} ) if (cropDisabled) { return ( 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={[ a.w_full, a.rounded_md, a.overflow_hidden, t.atoms.bg_contrast_25, {aspectRatio: max ?? 1}, ]}> {contents} ) } else { return ( 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={[a.h_full]}> {contents} ) } }