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
zio/stable
Eric Bailey 2024-09-05 13:45:13 -05:00 committed by GitHub
parent 117926357d
commit 2265fedd2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 397 additions and 207 deletions

View File

@ -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>

After

Width:  |  Height:  |  Size: 289 B

View File

@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api' 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' import {atoms as a, native, useTheme} from '#/alf'
let MessageItemEmbed = ({ let MessageItemEmbed = ({
@ -14,7 +14,11 @@ let MessageItemEmbed = ({
return ( return (
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}> <View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds embed={embed} allowNestedQuotes /> <PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View> </View>
) )
} }

View File

@ -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',
})

View File

@ -43,7 +43,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link, TextLink} from '../util/Link' import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format' import {formatCount} from '../util/numeric/format'
import {PostCtrls} from '../util/post-ctrls/PostCtrls' 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 {PostMeta} from '../util/PostMeta'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar' import {PreviewableUserAvatar} from '../util/UserAvatar'
@ -363,7 +363,11 @@ let PostThreadItemLoaded = ({
) : undefined} ) : undefined}
{post.embed && ( {post.embed && (
<View style={[a.pb_sm]}> <View style={[a.pb_sm]}>
<PostEmbeds embed={post.embed} moderation={moderation} /> <PostEmbeds
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.ThreadHighlighted}
/>
</View> </View>
)} )}
</ContentHider> </ContentHider>
@ -591,7 +595,11 @@ let PostThreadItemLoaded = ({
) : undefined} ) : undefined}
{post.embed && ( {post.embed && (
<View style={[a.pb_xs]}> <View style={[a.pb_xs]}>
<PostEmbeds embed={post.embed} moderation={moderation} /> <PostEmbeds
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
</View> </View>
)} )}
<PostCtrls <PostCtrls

View File

@ -32,7 +32,7 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
import {PostAlerts} from '../../../components/moderation/PostAlerts' import {PostAlerts} from '../../../components/moderation/PostAlerts'
import {Link, TextLink} from '../util/Link' import {Link, TextLink} from '../util/Link'
import {PostCtrls} from '../util/post-ctrls/PostCtrls' 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 {PostMeta} from '../util/PostMeta'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar' import {PreviewableUserAvatar} from '../util/UserAvatar'
@ -238,7 +238,11 @@ function PostInner({
/> />
) : undefined} ) : undefined}
{post.embed ? ( {post.embed ? (
<PostEmbeds embed={post.embed} moderation={moderation} /> <PostEmbeds
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
) : null} ) : null}
</ContentHider> </ContentHider>
<PostCtrls <PostCtrls

View File

@ -34,7 +34,7 @@ import {useComposerControls} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {FeedNameText} from '#/view/com/util/FeedInfoText' import {FeedNameText} from '#/view/com/util/FeedInfoText'
import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' 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 {PostMeta} from '#/view/com/util/PostMeta'
import {Text} from '#/view/com/util/text/Text' import {Text} from '#/view/com/util/text/Text'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
@ -488,6 +488,7 @@ let PostContent = ({
embed={postEmbed} embed={postEmbed}
moderation={moderation} moderation={moderation}
onOpen={onOpenEmbed} onOpen={onOpenEmbed}
viewContext={PostEmbedViewContext.Feed}
/> />
</View> </View>
) : null} ) : null}

View File

@ -1,106 +1,219 @@
import React from 'react' 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 {Image} from 'expo-image'
import {clamp} from 'lib/numbers' import {AppBskyEmbedImages} from '@atproto/api'
import {Dimensions} from 'lib/media/types'
import * as imageSizes from 'lib/media/image-sizes'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
const MIN_ASPECT_RATIO = 0.33 // 1/3 import * as imageSizes from '#/lib/media/image-sizes'
const MAX_ASPECT_RATIO = 10 // 10/1 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 { export function useImageAspectRatio({
alt?: string src,
uri: string dimensions,
dimensionsHint?: Dimensions }: {
onPress?: () => void src: string
onLongPress?: () => void dimensions: Dimensions | undefined
onPressIn?: () => void }) {
style?: StyleProp<ViewStyle> const [raw, setAspectRatio] = React.useState<number>(
children?: React.ReactNode 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({ export function AutoSizedImage({
alt, image,
uri, crop = 'constrained',
dimensionsHint, hideBadge,
onPress, onPress,
onLongPress, onLongPress,
onPressIn, onPressIn,
style, }: {
children = null, image: AppBskyEmbedImages.ViewImage
}: Props) { crop?: 'none' | 'square' | 'constrained'
hideBadge?: boolean
onPress?: () => void
onLongPress?: () => void
onPressIn?: () => void
}) {
const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const [dim, setDim] = React.useState<Dimensions | undefined>( const largeAlt = useLargeAltBadgeEnabled()
dimensionsHint || imageSizes.get(uri), const {
) constrained,
const [aspectRatio, setAspectRatio] = React.useState<number>( max,
dim ? calc(dim) : 1, isCropped: rawIsCropped,
) } = useImageAspectRatio({
React.useEffect(() => { src: image.thumb,
let aborted = false dimensions: image.aspectRatio,
if (dim) {
return
}
imageSizes.fetch(uri).then(newDim => {
if (aborted) {
return
}
setDim(newDim)
setAspectRatio(calc(newDim))
}) })
}, [dim, setDim, setAspectRatio, uri]) const cropDisabled = crop === 'none'
const isCropped = rawIsCropped && !cropDisabled
const hasAlt = !!image.alt
if (onPress || onLongPress || onPressIn) { 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}
</>
)
if (cropDisabled) {
return ( 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 <Pressable
onPress={onPress} onPress={onPress}
onLongPress={onLongPress} onLongPress={onLongPress}
onPressIn={onPressIn} onPressIn={onPressIn}
style={[styles.container, style]}> // alt here is what screen readers actually use
<Image accessibilityLabel={image.alt}
style={[styles.image, {aspectRatio}]} accessibilityHint={_(msg`Tap to view full image`)}
source={uri} style={[
accessible={true} // Must set for `accessibilityLabel` to work a.w_full,
accessibilityIgnoresInvertColors a.rounded_sm,
accessibilityLabel={alt} a.overflow_hidden,
accessibilityHint={_(msg`Tap to view fully`)} t.atoms.bg_contrast_25,
/> {aspectRatio: max},
{children} ]}>
{contents}
</Pressable> </Pressable>
) )
} } else {
return ( return (
<View style={[styles.container, style]}> <ConstrainedImage fullBleed={crop === 'square'} aspectRatio={constrained}>
<Image <Pressable
style={[styles.image, {aspectRatio}]} onPress={onPress}
source={{uri}} onLongPress={onLongPress}
accessible={true} // Must set for `accessibilityLabel` to work onPressIn={onPressIn}
accessibilityIgnoresInvertColors // alt here is what screen readers actually use
accessibilityLabel={alt} accessibilityLabel={image.alt}
accessibilityHint="" accessibilityHint={_(msg`Tap to view full image`)}
/> style={[a.h_full]}>
{children} {contents}
</View> </Pressable>
</ConstrainedImage>
) )
} }
}
function calc(dim: Dimensions) { function calc(dim: Dimensions) {
if (dim.width === 0 || dim.height === 0) { if (dim.width === 0 || dim.height === 0) {
return 1 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%',
},
})

View File

@ -1,13 +1,14 @@
import React, {ComponentProps, FC} from 'react' 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 {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api' import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 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 type EventFunction = (index: number) => void
@ -17,7 +18,8 @@ interface GalleryItemProps {
onPress?: EventFunction onPress?: EventFunction
onLongPress?: EventFunction onLongPress?: EventFunction
onPressIn?: EventFunction onPressIn?: EventFunction
imageStyle: ComponentProps<typeof Image>['style'] imageStyle?: ComponentProps<typeof Image>['style']
viewContext?: PostEmbedViewContext
} }
export const GalleryItem: FC<GalleryItemProps> = ({ export const GalleryItem: FC<GalleryItemProps> = ({
@ -27,57 +29,69 @@ export const GalleryItem: FC<GalleryItemProps> = ({
onPress, onPress,
onPressIn, onPressIn,
onLongPress, onLongPress,
viewContext,
}) => { }) => {
const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const largeAltBadge = useLargeAltBadgeEnabled() const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index] const image = images[index]
const hasAlt = !!image.alt
const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
return ( return (
<View style={a.flex_1}> <View style={a.flex_1}>
<Pressable <Pressable
onPress={onPress ? () => onPress(index) : undefined} onPress={onPress ? () => onPress(index) : undefined}
onPressIn={onPressIn ? () => onPressIn(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(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" accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)} accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint=""> accessibilityHint="">
<Image <Image
source={{uri: image.thumb}} source={{uri: image.thumb}}
style={[a.flex_1, a.rounded_xs, imageStyle]} style={[a.flex_1]}
accessible={true} accessible={true}
accessibilityLabel={image.alt} accessibilityLabel={image.alt}
accessibilityHint="" accessibilityHint=""
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
/> />
</Pressable> </Pressable>
{image.alt === '' ? null : ( {hasAlt && !hideBadges ? (
<View style={styles.altContainer}> <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 <Text
style={[styles.alt, largeAltBadge && a.text_xs]} style={[a.font_heavy, largeAltBadge ? a.text_xs : {fontSize: 8}]}>
accessible={false}>
ALT ALT
</Text> </Text>
</View> </View>
)} ) : null}
</View> </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',
},
})

View File

@ -1,8 +1,10 @@
import React from 'react' 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 {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 {GalleryItem} from './Gallery'
import {isWeb} from 'platform/detection'
interface ImageLayoutGridProps { interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[] images: AppBskyEmbedImages.ViewImage[]
@ -10,13 +12,25 @@ interface ImageLayoutGridProps {
onLongPress?: (index: number) => void onLongPress?: (index: number) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
viewContext?: PostEmbedViewContext
} }
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { 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 ( return (
<View style={style}> <View style={style}>
<View style={styles.container}> <View style={[gap, {aspectRatio}]}>
<ImageLayoutGridInner {...props} /> <ImageLayoutGridInner {...props} gap={gap} />
</View> </View>
</View> </View>
) )
@ -27,36 +41,39 @@ interface ImageLayoutGridInnerProps {
onPress?: (index: number) => void onPress?: (index: number) => void
onLongPress?: (index: number) => void onLongPress?: (index: number) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
gap: {gap: number}
} }
function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
const gap = props.gap
const count = props.images.length const count = props.images.length
switch (count) { switch (count) {
case 2: case 2:
return ( return (
<View style={styles.flexRow}> <View style={[a.flex_1, a.flex_row, gap]}>
<View style={styles.smallItem}> <View style={[a.flex_1, {aspectRatio: 1}]}>
<GalleryItem {...props} index={0} imageStyle={styles.image} /> <GalleryItem {...props} index={0} />
</View> </View>
<View style={styles.smallItem}> <View style={[a.flex_1, {aspectRatio: 1}]}>
<GalleryItem {...props} index={1} imageStyle={styles.image} /> <GalleryItem {...props} index={1} />
</View> </View>
</View> </View>
) )
case 3: case 3:
return ( return (
<View style={styles.flexRow}> <View style={[a.flex_1, a.flex_row, gap]}>
<View style={styles.threeSingle}> <View style={{flex: 2}}>
<GalleryItem {...props} index={0} imageStyle={styles.image} /> <GalleryItem {...props} index={0} />
</View> </View>
<View style={styles.threeDouble}> <View style={[a.flex_1, gap]}>
<View style={styles.smallItem}> <View style={[a.flex_1, {aspectRatio: 1}]}>
<GalleryItem {...props} index={1} imageStyle={styles.image} /> <GalleryItem {...props} index={1} />
</View> </View>
<View style={styles.smallItem}> <View style={[a.flex_1, {aspectRatio: 1}]}>
<GalleryItem {...props} index={2} imageStyle={styles.image} /> <GalleryItem {...props} index={2} />
</View> </View>
</View> </View>
</View> </View>
@ -65,20 +82,20 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
case 4: case 4:
return ( return (
<> <>
<View style={styles.flexRow}> <View style={[a.flex_row, gap]}>
<View style={styles.smallItem}> <View style={[a.flex_1, {aspectRatio: 1}]}>
<GalleryItem {...props} index={0} imageStyle={styles.image} /> <GalleryItem {...props} index={0} />
</View> </View>
<View style={styles.smallItem}> <View style={[a.flex_1, {aspectRatio: 1}]}>
<GalleryItem {...props} index={1} imageStyle={styles.image} /> <GalleryItem {...props} index={1} />
</View> </View>
</View> </View>
<View style={styles.flexRow}> <View style={[a.flex_row, gap]}>
<View style={styles.smallItem}> <View style={[a.flex_1, {aspectRatio: 1}]}>
<GalleryItem {...props} index={2} imageStyle={styles.image} /> <GalleryItem {...props} index={2} />
</View> </View>
<View style={styles.smallItem}> <View style={[a.flex_1, {aspectRatio: 1}]}>
<GalleryItem {...props} index={3} imageStyle={styles.image} /> <GalleryItem {...props} index={3} />
</View> </View>
</View> </View>
</> </>
@ -88,39 +105,3 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
return null 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,
},
})

View File

@ -33,7 +33,7 @@ import {InfoCircleIcon} from 'lib/icons'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {precacheProfile} from 'state/queries/profile' import {precacheProfile} from 'state/queries/profile'
import {ComposerOptsQuote} from 'state/shell/composer' 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 {RichText} from '#/components/RichText'
import {ContentHider} from '../../../../components/moderation/ContentHider' import {ContentHider} from '../../../../components/moderation/ContentHider'
import {PostAlerts} from '../../../../components/moderation/PostAlerts' import {PostAlerts} from '../../../../components/moderation/PostAlerts'
@ -41,17 +41,20 @@ import {Link} from '../Link'
import {PostMeta} from '../PostMeta' import {PostMeta} from '../PostMeta'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {PostEmbeds} from '.' import {PostEmbeds} from '.'
import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
export function MaybeQuoteEmbed({ export function MaybeQuoteEmbed({
embed, embed,
onOpen, onOpen,
style, style,
allowNestedQuotes, allowNestedQuotes,
viewContext,
}: { }: {
embed: AppBskyEmbedRecord.View embed: AppBskyEmbedRecord.View
onOpen?: () => void onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
allowNestedQuotes?: boolean allowNestedQuotes?: boolean
viewContext?: QuoteEmbedViewContext
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {currentAccount} = useSession() const {currentAccount} = useSession()
@ -67,6 +70,7 @@ export function MaybeQuoteEmbed({
onOpen={onOpen} onOpen={onOpen}
style={style} style={style}
allowNestedQuotes={allowNestedQuotes} allowNestedQuotes={allowNestedQuotes}
viewContext={viewContext}
/> />
) )
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
@ -113,12 +117,14 @@ function QuoteEmbedModerated({
onOpen, onOpen,
style, style,
allowNestedQuotes, allowNestedQuotes,
viewContext,
}: { }: {
viewRecord: AppBskyEmbedRecord.ViewRecord viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record postRecord: AppBskyFeedPost.Record
onOpen?: () => void onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
allowNestedQuotes?: boolean allowNestedQuotes?: boolean
viewContext?: QuoteEmbedViewContext
}) { }) {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => { const moderation = React.useMemo(() => {
@ -144,6 +150,7 @@ function QuoteEmbedModerated({
onOpen={onOpen} onOpen={onOpen}
style={style} style={style}
allowNestedQuotes={allowNestedQuotes} allowNestedQuotes={allowNestedQuotes}
viewContext={viewContext}
/> />
) )
} }
@ -154,18 +161,21 @@ export function QuoteEmbed({
onOpen, onOpen,
style, style,
allowNestedQuotes, allowNestedQuotes,
viewContext,
}: { }: {
quote: ComposerOptsQuote quote: ComposerOptsQuote
moderation?: ModerationDecision moderation?: ModerationDecision
onOpen?: () => void onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
allowNestedQuotes?: boolean allowNestedQuotes?: boolean
viewContext?: QuoteEmbedViewContext
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const pal = usePalette('default') const pal = usePalette('default')
const itemUrip = new AtUri(quote.uri) const itemUrip = new AtUri(quote.uri)
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${quote.author.handle}` const itemTitle = `Post by ${quote.author.handle}`
const {gtMobile} = useBreakpoints()
const richText = React.useMemo( const richText = React.useMemo(
() => () =>
@ -197,6 +207,7 @@ export function QuoteEmbed({
} }
} }
}, [quote.embeds, allowNestedQuotes]) }, [quote.embeds, allowNestedQuotes])
const isImagesEmbed = AppBskyEmbedImages.isView(embed)
const onBeforePress = React.useCallback(() => { const onBeforePress = React.useCallback(() => {
precacheProfile(queryClient, quote.author) precacheProfile(queryClient, quote.author)
@ -226,6 +237,32 @@ export function QuoteEmbed({
{moderation ? ( {moderation ? (
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} /> <PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
) : null} ) : null}
{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 ? (
<RichText <RichText
value={richText} value={richText}
@ -235,6 +272,8 @@ export function QuoteEmbed({
/> />
) : null} ) : null}
{embed && <PostEmbeds embed={embed} moderation={moderation} />} {embed && <PostEmbeds embed={embed} moderation={moderation} />}
</>
)}
</Link> </Link>
</ContentHider> </ContentHider>
) )

View File

@ -3,7 +3,6 @@ import {
InteractionManager, InteractionManager,
StyleProp, StyleProp,
StyleSheet, StyleSheet,
Text,
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
@ -22,7 +21,6 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox' import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
@ -34,8 +32,11 @@ import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed'
import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
import {VideoEmbed} from './VideoEmbed' import {VideoEmbed} from './VideoEmbed'
export * from './types'
type Embed = type Embed =
| AppBskyEmbedRecord.View | AppBskyEmbedRecord.View
| AppBskyEmbedImages.View | AppBskyEmbedImages.View
@ -50,15 +51,16 @@ export function PostEmbeds({
onOpen, onOpen,
style, style,
allowNestedQuotes, allowNestedQuotes,
viewContext,
}: { }: {
embed?: Embed embed?: Embed
moderation?: ModerationDecision moderation?: ModerationDecision
onOpen?: () => void onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
allowNestedQuotes?: boolean allowNestedQuotes?: boolean
viewContext?: PostEmbedViewContext
}) { }) {
const {openLightbox} = useLightboxControls() const {openLightbox} = useLightboxControls()
const largeAltBadge = useLargeAltBadgeEnabled()
// quote post with media // quote post with media
// = // =
@ -69,8 +71,17 @@ export function PostEmbeds({
embed={embed.media} embed={embed.media}
moderation={moderation} moderation={moderation}
onOpen={onOpen} onOpen={onOpen}
viewContext={viewContext}
/>
<MaybeQuoteEmbed
embed={embed.record}
onOpen={onOpen}
viewContext={
viewContext === PostEmbedViewContext.Feed
? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
: undefined
}
/> />
<MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} />
</View> </View>
) )
} }
@ -124,27 +135,26 @@ export function PostEmbeds({
} }
if (images.length === 1) { if (images.length === 1) {
const {alt, thumb, aspectRatio} = images[0] const image = images[0]
return ( return (
<ContentHider modui={moderation?.ui('contentMedia')}> <ContentHider modui={moderation?.ui('contentMedia')}>
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
<AutoSizedImage <AutoSizedImage
alt={alt} crop={
uri={thumb} viewContext === PostEmbedViewContext.ThreadHighlighted
dimensionsHint={aspectRatio} ? 'none'
: viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={() => _openLightbox(0)} onPress={() => _openLightbox(0)}
onPressIn={() => onPressIn(0)} onPressIn={() => onPressIn(0)}
style={a.rounded_sm}> hideBadge={
{alt === '' ? null : ( viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
<View style={styles.altContainer}> }
<Text />
style={[styles.alt, largeAltBadge && a.text_xs]}
accessible={false}>
ALT
</Text>
</View>
)}
</AutoSizedImage>
</View> </View>
</ContentHider> </ContentHider>
) )
@ -157,6 +167,7 @@ export function PostEmbeds({
images={embed.images} images={embed.images}
onPress={_openLightbox} onPress={_openLightbox}
onPressIn={onPressIn} onPressIn={onPressIn}
viewContext={viewContext}
/> />
</View> </View>
</ContentHider> </ContentHider>

View File

@ -0,0 +1,9 @@
export enum PostEmbedViewContext {
ThreadHighlighted = 'ThreadHighlighted',
Feed = 'Feed',
FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
}
export enum QuoteEmbedViewContext {
FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
}