diff options
Diffstat (limited to 'src/view/com/util/images/AutoSizedImage.tsx')
-rw-r--r-- | src/view/com/util/images/AutoSizedImage.tsx | 265 |
1 files changed, 189 insertions, 76 deletions
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 61cb6f69f..f4fb3a1b3 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -1,106 +1,219 @@ import React from 'react' -import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native' +import {DimensionValue, Pressable, View} from 'react-native' import {Image} from 'expo-image' -import {clamp} from 'lib/numbers' -import {Dimensions} from 'lib/media/types' -import * as imageSizes from 'lib/media/image-sizes' +import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -const MIN_ASPECT_RATIO = 0.33 // 1/3 -const MAX_ASPECT_RATIO = 10 // 10/1 +import * as imageSizes from '#/lib/media/image-sizes' +import {Dimensions} from '#/lib/media/types' +import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' +import {atoms as a, useTheme} from '#/alf' +import {Crop_Stroke2_Corner0_Rounded as Crop} from '#/components/icons/Crop' +import {Text} from '#/components/Typography' -interface Props { - alt?: string - uri: string - dimensionsHint?: Dimensions - onPress?: () => void - onLongPress?: () => void - onPressIn?: () => void - style?: StyleProp<ViewStyle> - children?: React.ReactNode +export function useImageAspectRatio({ + src, + dimensions, +}: { + src: string + dimensions: Dimensions | undefined +}) { + const [raw, setAspectRatio] = React.useState<number>( + dimensions ? calc(dimensions) : 1, + ) + const {isCropped, constrained, max} = React.useMemo(() => { + const a34 = 0.75 // max of 3:4 ratio in feeds + const constrained = Math.max(raw, a34) + const max = Math.max(raw, 0.25) // max of 1:4 in thread + const isCropped = raw < constrained + return { + isCropped, + constrained, + max, + } + }, [raw]) + + React.useEffect(() => { + let aborted = false + if (dimensions) return + imageSizes.fetch(src).then(newDim => { + if (aborted) return + setAspectRatio(calc(newDim)) + }) + return () => { + aborted = true + } + }, [dimensions, setAspectRatio, src]) + + return { + dimensions, + raw, + constrained, + max, + isCropped, + } +} + +export function ConstrainedImage({ + aspectRatio, + fullBleed, + children, +}: { + aspectRatio: number + fullBleed?: boolean + children: React.ReactNode +}) { + const t = useTheme() + /** + * Computed as a % value to apply as `paddingTop` + */ + const outerAspectRatio = React.useMemo<DimensionValue>(() => { + // capped to square or shorter + const ratio = Math.min(1 / aspectRatio, 1) + return `${ratio * 100}%` + }, [aspectRatio]) + + 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_sm, + a.overflow_hidden, + t.atoms.bg_contrast_25, + fullBleed ? a.w_full : {aspectRatio}, + ]}> + {children} + </View> + </View> + </View> + </View> + ) } export function AutoSizedImage({ - alt, - uri, - dimensionsHint, + image, + crop = 'constrained', + hideBadge, onPress, onLongPress, onPressIn, - style, - children = null, -}: Props) { +}: { + image: AppBskyEmbedImages.ViewImage + crop?: 'none' | 'square' | 'constrained' + hideBadge?: boolean + onPress?: () => void + onLongPress?: () => void + onPressIn?: () => void +}) { + const t = useTheme() const {_} = useLingui() - const [dim, setDim] = React.useState<Dimensions | undefined>( - dimensionsHint || imageSizes.get(uri), - ) - const [aspectRatio, setAspectRatio] = React.useState<number>( - dim ? calc(dim) : 1, + const largeAlt = useLargeAltBadgeEnabled() + const { + constrained, + max, + isCropped: rawIsCropped, + } = useImageAspectRatio({ + src: image.thumb, + dimensions: image.aspectRatio, + }) + const cropDisabled = crop === 'none' + const isCropped = rawIsCropped && !cropDisabled + const hasAlt = !!image.alt + + const contents = ( + <> + <Image + style={[a.w_full, a.h_full]} + source={image.thumb} + accessible={true} // Must set for `accessibilityLabel` to work + accessibilityIgnoresInvertColors + accessibilityLabel={image.alt} + accessibilityHint="" + /> + + {(hasAlt || isCropped) && !hideBadge ? ( + <View + accessible={false} + style={[ + a.absolute, + a.flex_row, + a.align_center, + a.rounded_xs, + t.atoms.bg_contrast_25, + { + gap: 3, + padding: 3, + bottom: a.p_xs.padding, + right: a.p_xs.padding, + opacity: 0.8, + }, + largeAlt && [ + { + gap: 4, + padding: 5, + }, + ], + ]}> + {isCropped && ( + <Crop + fill={t.atoms.text_contrast_high.color} + width={largeAlt ? 18 : 12} + /> + )} + {hasAlt && ( + <Text style={[a.font_heavy, largeAlt ? a.text_xs : {fontSize: 8}]}> + ALT + </Text> + )} + </View> + ) : null} + </> ) - React.useEffect(() => { - let aborted = false - if (dim) { - return - } - imageSizes.fetch(uri).then(newDim => { - if (aborted) { - return - } - setDim(newDim) - setAspectRatio(calc(newDim)) - }) - }, [dim, setDim, setAspectRatio, uri]) - if (onPress || onLongPress || onPressIn) { + if (cropDisabled) { return ( - // disable a11y rule because in this case we want the tags on the image (#1640) - // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors <Pressable onPress={onPress} onLongPress={onLongPress} onPressIn={onPressIn} - style={[styles.container, style]}> - <Image - style={[styles.image, {aspectRatio}]} - source={uri} - accessible={true} // Must set for `accessibilityLabel` to work - accessibilityIgnoresInvertColors - accessibilityLabel={alt} - accessibilityHint={_(msg`Tap to view fully`)} - /> - {children} + // alt here is what screen readers actually use + accessibilityLabel={image.alt} + accessibilityHint={_(msg`Tap to view full image`)} + style={[ + a.w_full, + a.rounded_sm, + a.overflow_hidden, + t.atoms.bg_contrast_25, + {aspectRatio: max}, + ]}> + {contents} </Pressable> ) + } else { + return ( + <ConstrainedImage fullBleed={crop === 'square'} aspectRatio={constrained}> + <Pressable + onPress={onPress} + onLongPress={onLongPress} + onPressIn={onPressIn} + // alt here is what screen readers actually use + accessibilityLabel={image.alt} + accessibilityHint={_(msg`Tap to view full image`)} + style={[a.h_full]}> + {contents} + </Pressable> + </ConstrainedImage> + ) } - - return ( - <View style={[styles.container, style]}> - <Image - style={[styles.image, {aspectRatio}]} - source={{uri}} - accessible={true} // Must set for `accessibilityLabel` to work - accessibilityIgnoresInvertColors - accessibilityLabel={alt} - accessibilityHint="" - /> - {children} - </View> - ) } function calc(dim: Dimensions) { if (dim.width === 0 || dim.height === 0) { return 1 } - return clamp(dim.width / dim.height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + return dim.width / dim.height } - -const styles = StyleSheet.create({ - container: { - overflow: 'hidden', - }, - image: { - width: '100%', - }, -}) |