about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--assets/icons/crop_stroke2_corner0_rounded.svg1
-rw-r--r--src/components/dms/MessageItemEmbed.tsx8
-rw-r--r--src/components/icons/Crop.tsx5
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx14
-rw-r--r--src/view/com/post/Post.tsx8
-rw-r--r--src/view/com/posts/FeedItem.tsx3
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx265
-rw-r--r--src/view/com/util/images/Gallery.tsx74
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx107
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx59
-rw-r--r--src/view/com/util/post-embeds/index.tsx49
-rw-r--r--src/view/com/util/post-embeds/types.ts9
12 files changed, 396 insertions, 206 deletions
diff --git a/assets/icons/crop_stroke2_corner0_rounded.svg b/assets/icons/crop_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..118d148f3
--- /dev/null
+++ b/assets/icons/crop_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z" clip-rule="evenodd"/></svg>
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index aefd62b9a..3db00aece 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {View} from 'react-native'
 import {AppBskyEmbedRecord} from '@atproto/api'
 
-import {PostEmbeds} from '#/view/com/util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
 import {atoms as a, native, useTheme} from '#/alf'
 
 let MessageItemEmbed = ({
@@ -14,7 +14,11 @@ let MessageItemEmbed = ({
 
   return (
     <View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
-      <PostEmbeds embed={embed} allowNestedQuotes />
+      <PostEmbeds
+        embed={embed}
+        allowNestedQuotes
+        viewContext={PostEmbedViewContext.Feed}
+      />
     </View>
   )
 }
diff --git a/src/components/icons/Crop.tsx b/src/components/icons/Crop.tsx
new file mode 100644
index 000000000..4b3fc560f
--- /dev/null
+++ b/src/components/icons/Crop.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z',
+})
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 3b5ddb1dc..8cd6e70be 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -43,7 +43,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Link, TextLink} from '../util/Link'
 import {formatCount} from '../util/numeric/format'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostEmbeds} from '../util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
 import {PostMeta} from '../util/PostMeta'
 import {Text} from '../util/text/Text'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
@@ -363,7 +363,11 @@ let PostThreadItemLoaded = ({
               ) : undefined}
               {post.embed && (
                 <View style={[a.pb_sm]}>
-                  <PostEmbeds embed={post.embed} moderation={moderation} />
+                  <PostEmbeds
+                    embed={post.embed}
+                    moderation={moderation}
+                    viewContext={PostEmbedViewContext.ThreadHighlighted}
+                  />
                 </View>
               )}
             </ContentHider>
@@ -591,7 +595,11 @@ let PostThreadItemLoaded = ({
               ) : undefined}
               {post.embed && (
                 <View style={[a.pb_xs]}>
-                  <PostEmbeds embed={post.embed} moderation={moderation} />
+                  <PostEmbeds
+                    embed={post.embed}
+                    moderation={moderation}
+                    viewContext={PostEmbedViewContext.Feed}
+                  />
                 </View>
               )}
               <PostCtrls
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 8121b8abc..9033fb96f 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -32,7 +32,7 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
 import {PostAlerts} from '../../../components/moderation/PostAlerts'
 import {Link, TextLink} from '../util/Link'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostEmbeds} from '../util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
 import {PostMeta} from '../util/PostMeta'
 import {Text} from '../util/text/Text'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
@@ -238,7 +238,11 @@ function PostInner({
               />
             ) : undefined}
             {post.embed ? (
-              <PostEmbeds embed={post.embed} moderation={moderation} />
+              <PostEmbeds
+                embed={post.embed}
+                moderation={moderation}
+                viewContext={PostEmbedViewContext.Feed}
+              />
             ) : null}
           </ContentHider>
           <PostCtrls
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 3a775c6b7..6c1bb04c3 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -34,7 +34,7 @@ import {useComposerControls} from '#/state/shell/composer'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {FeedNameText} from '#/view/com/util/FeedInfoText'
 import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
-import {PostEmbeds} from '#/view/com/util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
 import {PostMeta} from '#/view/com/util/PostMeta'
 import {Text} from '#/view/com/util/text/Text'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
@@ -488,6 +488,7 @@ let PostContent = ({
             embed={postEmbed}
             moderation={moderation}
             onOpen={onOpenEmbed}
+            viewContext={PostEmbedViewContext.Feed}
           />
         </View>
       ) : null}
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,
-  },
-})
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index c61cda68c..53cc3b8a1 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -33,7 +33,7 @@ import {InfoCircleIcon} from 'lib/icons'
 import {makeProfileLink} from 'lib/routes/links'
 import {precacheProfile} from 'state/queries/profile'
 import {ComposerOptsQuote} from 'state/shell/composer'
-import {atoms as a} from '#/alf'
+import {atoms as a, useBreakpoints} from '#/alf'
 import {RichText} from '#/components/RichText'
 import {ContentHider} from '../../../../components/moderation/ContentHider'
 import {PostAlerts} from '../../../../components/moderation/PostAlerts'
@@ -41,17 +41,20 @@ import {Link} from '../Link'
 import {PostMeta} from '../PostMeta'
 import {Text} from '../text/Text'
 import {PostEmbeds} from '.'
+import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
 
 export function MaybeQuoteEmbed({
   embed,
   onOpen,
   style,
   allowNestedQuotes,
+  viewContext,
 }: {
   embed: AppBskyEmbedRecord.View
   onOpen?: () => void
   style?: StyleProp<ViewStyle>
   allowNestedQuotes?: boolean
+  viewContext?: QuoteEmbedViewContext
 }) {
   const pal = usePalette('default')
   const {currentAccount} = useSession()
@@ -67,6 +70,7 @@ export function MaybeQuoteEmbed({
         onOpen={onOpen}
         style={style}
         allowNestedQuotes={allowNestedQuotes}
+        viewContext={viewContext}
       />
     )
   } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
@@ -113,12 +117,14 @@ function QuoteEmbedModerated({
   onOpen,
   style,
   allowNestedQuotes,
+  viewContext,
 }: {
   viewRecord: AppBskyEmbedRecord.ViewRecord
   postRecord: AppBskyFeedPost.Record
   onOpen?: () => void
   style?: StyleProp<ViewStyle>
   allowNestedQuotes?: boolean
+  viewContext?: QuoteEmbedViewContext
 }) {
   const moderationOpts = useModerationOpts()
   const moderation = React.useMemo(() => {
@@ -144,6 +150,7 @@ function QuoteEmbedModerated({
       onOpen={onOpen}
       style={style}
       allowNestedQuotes={allowNestedQuotes}
+      viewContext={viewContext}
     />
   )
 }
@@ -154,18 +161,21 @@ export function QuoteEmbed({
   onOpen,
   style,
   allowNestedQuotes,
+  viewContext,
 }: {
   quote: ComposerOptsQuote
   moderation?: ModerationDecision
   onOpen?: () => void
   style?: StyleProp<ViewStyle>
   allowNestedQuotes?: boolean
+  viewContext?: QuoteEmbedViewContext
 }) {
   const queryClient = useQueryClient()
   const pal = usePalette('default')
   const itemUrip = new AtUri(quote.uri)
   const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
   const itemTitle = `Post by ${quote.author.handle}`
+  const {gtMobile} = useBreakpoints()
 
   const richText = React.useMemo(
     () =>
@@ -197,6 +207,7 @@ export function QuoteEmbed({
       }
     }
   }, [quote.embeds, allowNestedQuotes])
+  const isImagesEmbed = AppBskyEmbedImages.isView(embed)
 
   const onBeforePress = React.useCallback(() => {
     precacheProfile(queryClient, quote.author)
@@ -226,15 +237,43 @@ export function QuoteEmbed({
         {moderation ? (
           <PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
         ) : null}
-        {richText ? (
-          <RichText
-            value={richText}
-            style={a.text_md}
-            numberOfLines={20}
-            disableLinks
-          />
-        ) : null}
-        {embed && <PostEmbeds embed={embed} moderation={moderation} />}
+
+        {viewContext === QuoteEmbedViewContext.FeedEmbedRecordWithMedia &&
+        isImagesEmbed ? (
+          <View style={[a.flex_row, a.gap_md]}>
+            {embed && (
+              <View style={[{width: gtMobile ? 100 : 80}]}>
+                <PostEmbeds
+                  embed={embed}
+                  moderation={moderation}
+                  viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
+                />
+              </View>
+            )}
+            {richText ? (
+              <View style={[a.flex_1, a.pt_xs]}>
+                <RichText
+                  value={richText}
+                  style={a.text_md}
+                  numberOfLines={20}
+                  disableLinks
+                />
+              </View>
+            ) : null}
+          </View>
+        ) : (
+          <>
+            {richText ? (
+              <RichText
+                value={richText}
+                style={a.text_md}
+                numberOfLines={20}
+                disableLinks
+              />
+            ) : null}
+            {embed && <PostEmbeds embed={embed} moderation={moderation} />}
+          </>
+        )}
       </Link>
     </ContentHider>
   )
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index d9e075e77..b4a6cf825 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -3,7 +3,6 @@ import {
   InteractionManager,
   StyleProp,
   StyleSheet,
-  Text,
   View,
   ViewStyle,
 } from 'react-native'
@@ -22,7 +21,6 @@ import {
 } from '@atproto/api'
 
 import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
-import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {usePalette} from 'lib/hooks/usePalette'
 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
@@ -34,8 +32,11 @@ import {AutoSizedImage} from '../images/AutoSizedImage'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
 import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {MaybeQuoteEmbed} from './QuoteEmbed'
+import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
 import {VideoEmbed} from './VideoEmbed'
 
+export * from './types'
+
 type Embed =
   | AppBskyEmbedRecord.View
   | AppBskyEmbedImages.View
@@ -50,15 +51,16 @@ export function PostEmbeds({
   onOpen,
   style,
   allowNestedQuotes,
+  viewContext,
 }: {
   embed?: Embed
   moderation?: ModerationDecision
   onOpen?: () => void
   style?: StyleProp<ViewStyle>
   allowNestedQuotes?: boolean
+  viewContext?: PostEmbedViewContext
 }) {
   const {openLightbox} = useLightboxControls()
-  const largeAltBadge = useLargeAltBadgeEnabled()
 
   // quote post with media
   // =
@@ -69,8 +71,17 @@ export function PostEmbeds({
           embed={embed.media}
           moderation={moderation}
           onOpen={onOpen}
+          viewContext={viewContext}
+        />
+        <MaybeQuoteEmbed
+          embed={embed.record}
+          onOpen={onOpen}
+          viewContext={
+            viewContext === PostEmbedViewContext.Feed
+              ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
+              : undefined
+          }
         />
-        <MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} />
       </View>
     )
   }
@@ -124,27 +135,26 @@ export function PostEmbeds({
       }
 
       if (images.length === 1) {
-        const {alt, thumb, aspectRatio} = images[0]
+        const image = images[0]
         return (
           <ContentHider modui={moderation?.ui('contentMedia')}>
             <View style={[styles.container, style]}>
               <AutoSizedImage
-                alt={alt}
-                uri={thumb}
-                dimensionsHint={aspectRatio}
+                crop={
+                  viewContext === PostEmbedViewContext.ThreadHighlighted
+                    ? 'none'
+                    : viewContext ===
+                      PostEmbedViewContext.FeedEmbedRecordWithMedia
+                    ? 'square'
+                    : 'constrained'
+                }
+                image={image}
                 onPress={() => _openLightbox(0)}
                 onPressIn={() => onPressIn(0)}
-                style={a.rounded_sm}>
-                {alt === '' ? null : (
-                  <View style={styles.altContainer}>
-                    <Text
-                      style={[styles.alt, largeAltBadge && a.text_xs]}
-                      accessible={false}>
-                      ALT
-                    </Text>
-                  </View>
-                )}
-              </AutoSizedImage>
+                hideBadge={
+                  viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+                }
+              />
             </View>
           </ContentHider>
         )
@@ -157,6 +167,7 @@ export function PostEmbeds({
               images={embed.images}
               onPress={_openLightbox}
               onPressIn={onPressIn}
+              viewContext={viewContext}
             />
           </View>
         </ContentHider>
diff --git a/src/view/com/util/post-embeds/types.ts b/src/view/com/util/post-embeds/types.ts
new file mode 100644
index 000000000..08e903276
--- /dev/null
+++ b/src/view/com/util/post-embeds/types.ts
@@ -0,0 +1,9 @@
+export enum PostEmbedViewContext {
+  ThreadHighlighted = 'ThreadHighlighted',
+  Feed = 'Feed',
+  FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
+}
+
+export enum QuoteEmbedViewContext {
+  FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
+}