From 2265fedd2ac4d006e3c55dbb81ee387b93be9830 Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Thu, 5 Sep 2024 13:45:13 -0500
Subject: Constrain image heights in feeds and threads (#5129)

* Limit height of images within posts

* Add some future-proofness

* Comments, improve a11y

* Adjust ALT, add crop icon

* Fix disableCrop in record-with-media posts

* Clean up aspect ratios, handle very tall images

* Handle record-with-media separately, clarify intent using enums

* Adjust spacing

* Adjust rwm embed image size on mobile

* Only do reduced layout if images embed

* Adjust gap in small embed variant

* Clean up grid layout

* Hide badge on small variant with one image

* Remove crop icon from image grid, leave on single image

* Fix sizing in Firefox

* Fix fullBleed variant
---
 src/view/com/util/images/AutoSizedImage.tsx | 265 ++++++++++++++++++++--------
 1 file changed, 189 insertions(+), 76 deletions(-)

(limited to 'src/view/com/util/images/AutoSizedImage.tsx')

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%',
-  },
-})
-- 
cgit 1.4.1

