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 {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>
)
}

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

View File

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

View File

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

View File

@ -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,
)
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])
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
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 (
// 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%',
},
})

View File

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

View File

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

View File

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

View File

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

View File

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