From 2265fedd2ac4d006e3c55dbb81ee387b93be9830 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 5 Sep 2024 13:45:13 -0500 Subject: [PATCH] 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 --- assets/icons/crop_stroke2_corner0_rounded.svg | 1 + src/components/dms/MessageItemEmbed.tsx | 8 +- src/components/icons/Crop.tsx | 5 + src/view/com/post-thread/PostThreadItem.tsx | 14 +- src/view/com/post/Post.tsx | 8 +- src/view/com/posts/FeedItem.tsx | 3 +- src/view/com/util/images/AutoSizedImage.tsx | 267 +++++++++++++----- src/view/com/util/images/Gallery.tsx | 74 +++-- src/view/com/util/images/ImageLayoutGrid.tsx | 107 +++---- src/view/com/util/post-embeds/QuoteEmbed.tsx | 59 +++- src/view/com/util/post-embeds/index.tsx | 49 ++-- src/view/com/util/post-embeds/types.ts | 9 + 12 files changed, 397 insertions(+), 207 deletions(-) create mode 100644 assets/icons/crop_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/Crop.tsx create mode 100644 src/view/com/util/post-embeds/types.ts diff --git a/assets/icons/crop_stroke2_corner0_rounded.svg b/assets/icons/crop_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..118d148f --- /dev/null +++ b/assets/icons/crop_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index aefd62b9..3db00aec 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 ( - + ) } diff --git a/src/components/icons/Crop.tsx b/src/components/icons/Crop.tsx new file mode 100644 index 00000000..4b3fc560 --- /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 3b5ddb1d..8cd6e70b 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 && ( - + )} @@ -591,7 +595,11 @@ let PostThreadItemLoaded = ({ ) : undefined} {post.embed && ( - + )} ) : undefined} {post.embed ? ( - + ) : null} ) : null} diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 61cb6f69..f4fb3a1b 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 - children?: React.ReactNode +export function useImageAspectRatio({ + src, + dimensions, +}: { + src: string + dimensions: Dimensions | undefined +}) { + const [raw, setAspectRatio] = React.useState( + 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(() => { + // capped to square or shorter + const ratio = Math.min(1 / aspectRatio, 1) + return `${ratio * 100}%` + }, [aspectRatio]) + + return ( + + + + + {children} + + + + + ) } 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( - dimensionsHint || imageSizes.get(uri), - ) - const [aspectRatio, setAspectRatio] = React.useState( - 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 = ( + <> + + + {(hasAlt || isCropped) && !hideBadge ? ( + + {isCropped && ( + + )} + {hasAlt && ( + + ALT + + )} + + ) : 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 - - {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} ) + } else { + return ( + + + {contents} + + + ) } - - return ( - - - {children} - - ) } 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 9bbb2ac1..839674c8 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['style'] + imageStyle?: ComponentProps['style'] + viewContext?: PostEmbedViewContext } export const GalleryItem: FC = ({ @@ -27,57 +29,69 @@ export const GalleryItem: FC = ({ 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 ( 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.alt === '' ? null : ( - + {hasAlt && !hideBadges ? ( + + style={[a.font_heavy, largeAltBadge ? a.text_xs : {fontSize: 8}]}> ALT - )} + ) : null} ) } - -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 ba6c04f5..45da7f07 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 + 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 ( - - + + ) @@ -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 ( - - - + + + - - + + ) case 3: return ( - - - + + + - - - + + + - - + + @@ -65,20 +82,20 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { case 4: return ( <> - - - + + + - - + + - - - + + + - - + + @@ -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 c61cda68..53cc3b8a 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 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 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 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 ? ( ) : null} - {richText ? ( - - ) : null} - {embed && } + + {viewContext === QuoteEmbedViewContext.FeedEmbedRecordWithMedia && + isImagesEmbed ? ( + + {embed && ( + + + + )} + {richText ? ( + + + + ) : null} + + ) : ( + <> + {richText ? ( + + ) : null} + {embed && } + + )} ) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index d9e075e7..b4a6cf82 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 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} + /> + - ) } @@ -124,27 +135,26 @@ export function PostEmbeds({ } if (images.length === 1) { - const {alt, thumb, aspectRatio} = images[0] + const image = images[0] return ( _openLightbox(0)} onPressIn={() => onPressIn(0)} - style={a.rounded_sm}> - {alt === '' ? null : ( - - - ALT - - - )} - + hideBadge={ + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> ) @@ -157,6 +167,7 @@ export function PostEmbeds({ images={embed.images} onPress={_openLightbox} onPressIn={onPressIn} + viewContext={viewContext} /> 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 00000000..08e90327 --- /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, +}