diff options
Diffstat (limited to 'src/view/com/util/images')
-rw-r--r-- | src/view/com/util/images/AutoSizedImage.tsx | 265 | ||||
-rw-r--r-- | src/view/com/util/images/Gallery.tsx | 74 | ||||
-rw-r--r-- | src/view/com/util/images/ImageLayoutGrid.tsx | 107 |
3 files changed, 277 insertions, 169 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%', - }, -}) diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 9bbb2ac10..839674c8c 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -1,13 +1,14 @@ import React, {ComponentProps, FC} from 'react' -import {Pressable, StyleSheet, Text, View} from 'react-native' +import {Pressable, View} from 'react-native' import {Image} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {isWeb} from '#/platform/detection' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' -import {atoms as a} from '#/alf' +import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' type EventFunction = (index: number) => void @@ -17,7 +18,8 @@ interface GalleryItemProps { onPress?: EventFunction onLongPress?: EventFunction onPressIn?: EventFunction - imageStyle: ComponentProps<typeof Image>['style'] + imageStyle?: ComponentProps<typeof Image>['style'] + viewContext?: PostEmbedViewContext } export const GalleryItem: FC<GalleryItemProps> = ({ @@ -27,57 +29,69 @@ export const GalleryItem: FC<GalleryItemProps> = ({ onPress, onPressIn, onLongPress, + viewContext, }) => { + const t = useTheme() const {_} = useLingui() const largeAltBadge = useLargeAltBadgeEnabled() const image = images[index] + const hasAlt = !!image.alt + const hideBadges = + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia return ( <View style={a.flex_1}> <Pressable onPress={onPress ? () => onPress(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined} - style={a.flex_1} + style={[ + a.flex_1, + a.rounded_xs, + a.overflow_hidden, + t.atoms.bg_contrast_25, + imageStyle, + ]} accessibilityRole="button" accessibilityLabel={image.alt || _(msg`Image`)} accessibilityHint=""> <Image source={{uri: image.thumb}} - style={[a.flex_1, a.rounded_xs, imageStyle]} + style={[a.flex_1]} accessible={true} accessibilityLabel={image.alt} accessibilityHint="" accessibilityIgnoresInvertColors /> </Pressable> - {image.alt === '' ? null : ( - <View style={styles.altContainer}> + {hasAlt && !hideBadges ? ( + <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, + }, + largeAltBadge && [ + { + gap: 4, + padding: 5, + }, + ], + ]}> <Text - style={[styles.alt, largeAltBadge && a.text_xs]} - accessible={false}> + style={[a.font_heavy, largeAltBadge ? a.text_xs : {fontSize: 8}]}> ALT </Text> </View> - )} + ) : null} </View> ) } - -const styles = StyleSheet.create({ - altContainer: { - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderRadius: 6, - paddingHorizontal: 6, - paddingVertical: 3, - position: 'absolute', - // Related to margin/gap hack. This keeps the alt label in the same position - // on all platforms - right: isWeb ? 8 : 5, - bottom: isWeb ? 8 : 5, - }, - alt: { - color: 'white', - fontSize: 7, - fontWeight: 'bold', - }, -}) diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index ba6c04f50..45da7f076 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,8 +1,10 @@ import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {StyleProp, View, ViewStyle} from 'react-native' import {AppBskyEmbedImages} from '@atproto/api' + +import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' +import {atoms as a, useBreakpoints} from '#/alf' import {GalleryItem} from './Gallery' -import {isWeb} from 'platform/detection' interface ImageLayoutGridProps { images: AppBskyEmbedImages.ViewImage[] @@ -10,13 +12,25 @@ interface ImageLayoutGridProps { onLongPress?: (index: number) => void onPressIn?: (index: number) => void style?: StyleProp<ViewStyle> + viewContext?: PostEmbedViewContext } export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { + const {gtMobile} = useBreakpoints() + const gap = + props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + ? gtMobile + ? a.gap_xs + : a.gap_2xs + : gtMobile + ? a.gap_sm + : a.gap_xs + const count = props.images.length + const aspectRatio = count === 2 ? 2 : count === 3 ? 1.5 : 1 return ( <View style={style}> - <View style={styles.container}> - <ImageLayoutGridInner {...props} /> + <View style={[gap, {aspectRatio}]}> + <ImageLayoutGridInner {...props} gap={gap} /> </View> </View> ) @@ -27,36 +41,39 @@ interface ImageLayoutGridInnerProps { onPress?: (index: number) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void + viewContext?: PostEmbedViewContext + gap: {gap: number} } function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { + const gap = props.gap const count = props.images.length switch (count) { case 2: return ( - <View style={styles.flexRow}> - <View style={styles.smallItem}> - <GalleryItem {...props} index={0} imageStyle={styles.image} /> + <View style={[a.flex_1, a.flex_row, gap]}> + <View style={[a.flex_1, {aspectRatio: 1}]}> + <GalleryItem {...props} index={0} /> </View> - <View style={styles.smallItem}> - <GalleryItem {...props} index={1} imageStyle={styles.image} /> + <View style={[a.flex_1, {aspectRatio: 1}]}> + <GalleryItem {...props} index={1} /> </View> </View> ) case 3: return ( - <View style={styles.flexRow}> - <View style={styles.threeSingle}> - <GalleryItem {...props} index={0} imageStyle={styles.image} /> + <View style={[a.flex_1, a.flex_row, gap]}> + <View style={{flex: 2}}> + <GalleryItem {...props} index={0} /> </View> - <View style={styles.threeDouble}> - <View style={styles.smallItem}> - <GalleryItem {...props} index={1} imageStyle={styles.image} /> + <View style={[a.flex_1, gap]}> + <View style={[a.flex_1, {aspectRatio: 1}]}> + <GalleryItem {...props} index={1} /> </View> - <View style={styles.smallItem}> - <GalleryItem {...props} index={2} imageStyle={styles.image} /> + <View style={[a.flex_1, {aspectRatio: 1}]}> + <GalleryItem {...props} index={2} /> </View> </View> </View> @@ -65,20 +82,20 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { case 4: return ( <> - <View style={styles.flexRow}> - <View style={styles.smallItem}> - <GalleryItem {...props} index={0} imageStyle={styles.image} /> + <View style={[a.flex_row, gap]}> + <View style={[a.flex_1, {aspectRatio: 1}]}> + <GalleryItem {...props} index={0} /> </View> - <View style={styles.smallItem}> - <GalleryItem {...props} index={1} imageStyle={styles.image} /> + <View style={[a.flex_1, {aspectRatio: 1}]}> + <GalleryItem {...props} index={1} /> </View> </View> - <View style={styles.flexRow}> - <View style={styles.smallItem}> - <GalleryItem {...props} index={2} imageStyle={styles.image} /> + <View style={[a.flex_row, gap]}> + <View style={[a.flex_1, {aspectRatio: 1}]}> + <GalleryItem {...props} index={2} /> </View> - <View style={styles.smallItem}> - <GalleryItem {...props} index={3} imageStyle={styles.image} /> + <View style={[a.flex_1, {aspectRatio: 1}]}> + <GalleryItem {...props} index={3} /> </View> </View> </> @@ -88,39 +105,3 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { return null } } - -// On web we use margin to calculate gap, as aspectRatio does not properly size -// all images on web. On native though we cannot rely on margin, since the -// negative margin interferes with the swipe controls on pagers. -// https://github.com/facebook/yoga/issues/1418 -// https://github.com/bluesky-social/social-app/issues/2601 -const IMAGE_GAP = 5 - -const styles = StyleSheet.create({ - container: isWeb - ? { - marginHorizontal: -IMAGE_GAP / 2, - marginVertical: -IMAGE_GAP / 2, - } - : { - gap: IMAGE_GAP, - }, - flexRow: { - flexDirection: 'row', - gap: isWeb ? undefined : IMAGE_GAP, - }, - smallItem: {flex: 1, aspectRatio: 1}, - image: isWeb - ? { - margin: IMAGE_GAP / 2, - } - : {}, - threeSingle: { - flex: 2, - aspectRatio: isWeb ? 1 : undefined, - }, - threeDouble: { - flex: 1, - gap: isWeb ? undefined : IMAGE_GAP, - }, -}) |