diff options
author | Eric Bailey <git@esb.lol> | 2024-09-05 13:45:13 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-09-05 13:45:13 -0500 |
commit | 2265fedd2ac4d006e3c55dbb81ee387b93be9830 (patch) | |
tree | 83ce7cb032161fd8dee24b2a7a6e561ee2bcb9f5 | |
parent | 117926357d3a59db8fb12f9486f657c7b0f1cf69 (diff) | |
download | voidsky-2265fedd2ac4d006e3c55dbb81ee387b93be9830.tar.zst |
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
-rw-r--r-- | assets/icons/crop_stroke2_corner0_rounded.svg | 1 | ||||
-rw-r--r-- | src/components/dms/MessageItemEmbed.tsx | 8 | ||||
-rw-r--r-- | src/components/icons/Crop.tsx | 5 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 14 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 8 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 3 | ||||
-rw-r--r-- | src/view/com/util/images/AutoSizedImage.tsx | 265 | ||||
-rw-r--r-- | src/view/com/util/images/Gallery.tsx | 74 | ||||
-rw-r--r-- | src/view/com/util/images/ImageLayoutGrid.tsx | 107 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/QuoteEmbed.tsx | 59 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/index.tsx | 49 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/types.ts | 9 |
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, +} |