An ATproto social media client -- with an independent Appview.

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

authored by

Eric Bailey and committed by
GitHub
2265fedd 11792635

+396 -206
+1
assets/icons/crop_stroke2_corner0_rounded.svg
··· 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>
+6 -2
src/components/dms/MessageItemEmbed.tsx
··· 2 2 import {View} from 'react-native' 3 3 import {AppBskyEmbedRecord} from '@atproto/api' 4 4 5 - import {PostEmbeds} from '#/view/com/util/post-embeds' 5 + import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' 6 6 import {atoms as a, native, useTheme} from '#/alf' 7 7 8 8 let MessageItemEmbed = ({ ··· 14 14 15 15 return ( 16 16 <View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}> 17 - <PostEmbeds embed={embed} allowNestedQuotes /> 17 + <PostEmbeds 18 + embed={embed} 19 + allowNestedQuotes 20 + viewContext={PostEmbedViewContext.Feed} 21 + /> 18 22 </View> 19 23 ) 20 24 }
+5
src/components/icons/Crop.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+11 -3
src/view/com/post-thread/PostThreadItem.tsx
··· 43 43 import {Link, TextLink} from '../util/Link' 44 44 import {formatCount} from '../util/numeric/format' 45 45 import {PostCtrls} from '../util/post-ctrls/PostCtrls' 46 - import {PostEmbeds} from '../util/post-embeds' 46 + import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds' 47 47 import {PostMeta} from '../util/PostMeta' 48 48 import {Text} from '../util/text/Text' 49 49 import {PreviewableUserAvatar} from '../util/UserAvatar' ··· 363 363 ) : undefined} 364 364 {post.embed && ( 365 365 <View style={[a.pb_sm]}> 366 - <PostEmbeds embed={post.embed} moderation={moderation} /> 366 + <PostEmbeds 367 + embed={post.embed} 368 + moderation={moderation} 369 + viewContext={PostEmbedViewContext.ThreadHighlighted} 370 + /> 367 371 </View> 368 372 )} 369 373 </ContentHider> ··· 591 595 ) : undefined} 592 596 {post.embed && ( 593 597 <View style={[a.pb_xs]}> 594 - <PostEmbeds embed={post.embed} moderation={moderation} /> 598 + <PostEmbeds 599 + embed={post.embed} 600 + moderation={moderation} 601 + viewContext={PostEmbedViewContext.Feed} 602 + /> 595 603 </View> 596 604 )} 597 605 <PostCtrls
+6 -2
src/view/com/post/Post.tsx
··· 32 32 import {PostAlerts} from '../../../components/moderation/PostAlerts' 33 33 import {Link, TextLink} from '../util/Link' 34 34 import {PostCtrls} from '../util/post-ctrls/PostCtrls' 35 - import {PostEmbeds} from '../util/post-embeds' 35 + import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds' 36 36 import {PostMeta} from '../util/PostMeta' 37 37 import {Text} from '../util/text/Text' 38 38 import {PreviewableUserAvatar} from '../util/UserAvatar' ··· 238 238 /> 239 239 ) : undefined} 240 240 {post.embed ? ( 241 - <PostEmbeds embed={post.embed} moderation={moderation} /> 241 + <PostEmbeds 242 + embed={post.embed} 243 + moderation={moderation} 244 + viewContext={PostEmbedViewContext.Feed} 245 + /> 242 246 ) : null} 243 247 </ContentHider> 244 248 <PostCtrls
+2 -1
src/view/com/posts/FeedItem.tsx
··· 34 34 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 35 35 import {FeedNameText} from '#/view/com/util/FeedInfoText' 36 36 import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' 37 - import {PostEmbeds} from '#/view/com/util/post-embeds' 37 + import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' 38 38 import {PostMeta} from '#/view/com/util/PostMeta' 39 39 import {Text} from '#/view/com/util/text/Text' 40 40 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' ··· 488 488 embed={postEmbed} 489 489 moderation={moderation} 490 490 onOpen={onOpenEmbed} 491 + viewContext={PostEmbedViewContext.Feed} 491 492 /> 492 493 </View> 493 494 ) : null}
+189 -76
src/view/com/util/images/AutoSizedImage.tsx
··· 1 1 import React from 'react' 2 - import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native' 2 + import {DimensionValue, Pressable, View} from 'react-native' 3 3 import {Image} from 'expo-image' 4 - import {clamp} from 'lib/numbers' 5 - import {Dimensions} from 'lib/media/types' 6 - import * as imageSizes from 'lib/media/image-sizes' 4 + import {AppBskyEmbedImages} from '@atproto/api' 7 5 import {msg} from '@lingui/macro' 8 6 import {useLingui} from '@lingui/react' 9 7 10 - const MIN_ASPECT_RATIO = 0.33 // 1/3 11 - const MAX_ASPECT_RATIO = 10 // 10/1 8 + import * as imageSizes from '#/lib/media/image-sizes' 9 + import {Dimensions} from '#/lib/media/types' 10 + import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 11 + import {atoms as a, useTheme} from '#/alf' 12 + import {Crop_Stroke2_Corner0_Rounded as Crop} from '#/components/icons/Crop' 13 + import {Text} from '#/components/Typography' 14 + 15 + export function useImageAspectRatio({ 16 + src, 17 + dimensions, 18 + }: { 19 + src: string 20 + dimensions: Dimensions | undefined 21 + }) { 22 + const [raw, setAspectRatio] = React.useState<number>( 23 + dimensions ? calc(dimensions) : 1, 24 + ) 25 + const {isCropped, constrained, max} = React.useMemo(() => { 26 + const a34 = 0.75 // max of 3:4 ratio in feeds 27 + const constrained = Math.max(raw, a34) 28 + const max = Math.max(raw, 0.25) // max of 1:4 in thread 29 + const isCropped = raw < constrained 30 + return { 31 + isCropped, 32 + constrained, 33 + max, 34 + } 35 + }, [raw]) 36 + 37 + React.useEffect(() => { 38 + let aborted = false 39 + if (dimensions) return 40 + imageSizes.fetch(src).then(newDim => { 41 + if (aborted) return 42 + setAspectRatio(calc(newDim)) 43 + }) 44 + return () => { 45 + aborted = true 46 + } 47 + }, [dimensions, setAspectRatio, src]) 48 + 49 + return { 50 + dimensions, 51 + raw, 52 + constrained, 53 + max, 54 + isCropped, 55 + } 56 + } 57 + 58 + export function ConstrainedImage({ 59 + aspectRatio, 60 + fullBleed, 61 + children, 62 + }: { 63 + aspectRatio: number 64 + fullBleed?: boolean 65 + children: React.ReactNode 66 + }) { 67 + const t = useTheme() 68 + /** 69 + * Computed as a % value to apply as `paddingTop` 70 + */ 71 + const outerAspectRatio = React.useMemo<DimensionValue>(() => { 72 + // capped to square or shorter 73 + const ratio = Math.min(1 / aspectRatio, 1) 74 + return `${ratio * 100}%` 75 + }, [aspectRatio]) 12 76 13 - interface Props { 14 - alt?: string 15 - uri: string 16 - dimensionsHint?: Dimensions 17 - onPress?: () => void 18 - onLongPress?: () => void 19 - onPressIn?: () => void 20 - style?: StyleProp<ViewStyle> 21 - children?: React.ReactNode 77 + return ( 78 + <View style={[a.w_full]}> 79 + <View style={[a.overflow_hidden, {paddingTop: outerAspectRatio}]}> 80 + <View style={[a.absolute, a.inset_0, a.flex_row]}> 81 + <View 82 + style={[ 83 + a.h_full, 84 + a.rounded_sm, 85 + a.overflow_hidden, 86 + t.atoms.bg_contrast_25, 87 + fullBleed ? a.w_full : {aspectRatio}, 88 + ]}> 89 + {children} 90 + </View> 91 + </View> 92 + </View> 93 + </View> 94 + ) 22 95 } 23 96 24 97 export function AutoSizedImage({ 25 - alt, 26 - uri, 27 - dimensionsHint, 98 + image, 99 + crop = 'constrained', 100 + hideBadge, 28 101 onPress, 29 102 onLongPress, 30 103 onPressIn, 31 - style, 32 - children = null, 33 - }: Props) { 104 + }: { 105 + image: AppBskyEmbedImages.ViewImage 106 + crop?: 'none' | 'square' | 'constrained' 107 + hideBadge?: boolean 108 + onPress?: () => void 109 + onLongPress?: () => void 110 + onPressIn?: () => void 111 + }) { 112 + const t = useTheme() 34 113 const {_} = useLingui() 35 - const [dim, setDim] = React.useState<Dimensions | undefined>( 36 - dimensionsHint || imageSizes.get(uri), 37 - ) 38 - const [aspectRatio, setAspectRatio] = React.useState<number>( 39 - dim ? calc(dim) : 1, 114 + const largeAlt = useLargeAltBadgeEnabled() 115 + const { 116 + constrained, 117 + max, 118 + isCropped: rawIsCropped, 119 + } = useImageAspectRatio({ 120 + src: image.thumb, 121 + dimensions: image.aspectRatio, 122 + }) 123 + const cropDisabled = crop === 'none' 124 + const isCropped = rawIsCropped && !cropDisabled 125 + const hasAlt = !!image.alt 126 + 127 + const contents = ( 128 + <> 129 + <Image 130 + style={[a.w_full, a.h_full]} 131 + source={image.thumb} 132 + accessible={true} // Must set for `accessibilityLabel` to work 133 + accessibilityIgnoresInvertColors 134 + accessibilityLabel={image.alt} 135 + accessibilityHint="" 136 + /> 137 + 138 + {(hasAlt || isCropped) && !hideBadge ? ( 139 + <View 140 + accessible={false} 141 + style={[ 142 + a.absolute, 143 + a.flex_row, 144 + a.align_center, 145 + a.rounded_xs, 146 + t.atoms.bg_contrast_25, 147 + { 148 + gap: 3, 149 + padding: 3, 150 + bottom: a.p_xs.padding, 151 + right: a.p_xs.padding, 152 + opacity: 0.8, 153 + }, 154 + largeAlt && [ 155 + { 156 + gap: 4, 157 + padding: 5, 158 + }, 159 + ], 160 + ]}> 161 + {isCropped && ( 162 + <Crop 163 + fill={t.atoms.text_contrast_high.color} 164 + width={largeAlt ? 18 : 12} 165 + /> 166 + )} 167 + {hasAlt && ( 168 + <Text style={[a.font_heavy, largeAlt ? a.text_xs : {fontSize: 8}]}> 169 + ALT 170 + </Text> 171 + )} 172 + </View> 173 + ) : null} 174 + </> 40 175 ) 41 - React.useEffect(() => { 42 - let aborted = false 43 - if (dim) { 44 - return 45 - } 46 - imageSizes.fetch(uri).then(newDim => { 47 - if (aborted) { 48 - return 49 - } 50 - setDim(newDim) 51 - setAspectRatio(calc(newDim)) 52 - }) 53 - }, [dim, setDim, setAspectRatio, uri]) 54 176 55 - if (onPress || onLongPress || onPressIn) { 177 + if (cropDisabled) { 56 178 return ( 57 - // disable a11y rule because in this case we want the tags on the image (#1640) 58 - // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors 59 179 <Pressable 60 180 onPress={onPress} 61 181 onLongPress={onLongPress} 62 182 onPressIn={onPressIn} 63 - style={[styles.container, style]}> 64 - <Image 65 - style={[styles.image, {aspectRatio}]} 66 - source={uri} 67 - accessible={true} // Must set for `accessibilityLabel` to work 68 - accessibilityIgnoresInvertColors 69 - accessibilityLabel={alt} 70 - accessibilityHint={_(msg`Tap to view fully`)} 71 - /> 72 - {children} 183 + // alt here is what screen readers actually use 184 + accessibilityLabel={image.alt} 185 + accessibilityHint={_(msg`Tap to view full image`)} 186 + style={[ 187 + a.w_full, 188 + a.rounded_sm, 189 + a.overflow_hidden, 190 + t.atoms.bg_contrast_25, 191 + {aspectRatio: max}, 192 + ]}> 193 + {contents} 73 194 </Pressable> 74 195 ) 196 + } else { 197 + return ( 198 + <ConstrainedImage fullBleed={crop === 'square'} aspectRatio={constrained}> 199 + <Pressable 200 + onPress={onPress} 201 + onLongPress={onLongPress} 202 + onPressIn={onPressIn} 203 + // alt here is what screen readers actually use 204 + accessibilityLabel={image.alt} 205 + accessibilityHint={_(msg`Tap to view full image`)} 206 + style={[a.h_full]}> 207 + {contents} 208 + </Pressable> 209 + </ConstrainedImage> 210 + ) 75 211 } 76 - 77 - return ( 78 - <View style={[styles.container, style]}> 79 - <Image 80 - style={[styles.image, {aspectRatio}]} 81 - source={{uri}} 82 - accessible={true} // Must set for `accessibilityLabel` to work 83 - accessibilityIgnoresInvertColors 84 - accessibilityLabel={alt} 85 - accessibilityHint="" 86 - /> 87 - {children} 88 - </View> 89 - ) 90 212 } 91 213 92 214 function calc(dim: Dimensions) { 93 215 if (dim.width === 0 || dim.height === 0) { 94 216 return 1 95 217 } 96 - return clamp(dim.width / dim.height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) 218 + return dim.width / dim.height 97 219 } 98 - 99 - const styles = StyleSheet.create({ 100 - container: { 101 - overflow: 'hidden', 102 - }, 103 - image: { 104 - width: '100%', 105 - }, 106 - })
+44 -30
src/view/com/util/images/Gallery.tsx
··· 1 1 import React, {ComponentProps, FC} from 'react' 2 - import {Pressable, StyleSheet, Text, View} from 'react-native' 2 + import {Pressable, View} from 'react-native' 3 3 import {Image} from 'expo-image' 4 4 import {AppBskyEmbedImages} from '@atproto/api' 5 5 import {msg} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 - import {isWeb} from '#/platform/detection' 9 8 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 10 - import {atoms as a} from '#/alf' 9 + import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {Text} from '#/components/Typography' 11 12 12 13 type EventFunction = (index: number) => void 13 14 ··· 17 18 onPress?: EventFunction 18 19 onLongPress?: EventFunction 19 20 onPressIn?: EventFunction 20 - imageStyle: ComponentProps<typeof Image>['style'] 21 + imageStyle?: ComponentProps<typeof Image>['style'] 22 + viewContext?: PostEmbedViewContext 21 23 } 22 24 23 25 export const GalleryItem: FC<GalleryItemProps> = ({ ··· 27 29 onPress, 28 30 onPressIn, 29 31 onLongPress, 32 + viewContext, 30 33 }) => { 34 + const t = useTheme() 31 35 const {_} = useLingui() 32 36 const largeAltBadge = useLargeAltBadgeEnabled() 33 37 const image = images[index] 38 + const hasAlt = !!image.alt 39 + const hideBadges = 40 + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 34 41 return ( 35 42 <View style={a.flex_1}> 36 43 <Pressable 37 44 onPress={onPress ? () => onPress(index) : undefined} 38 45 onPressIn={onPressIn ? () => onPressIn(index) : undefined} 39 46 onLongPress={onLongPress ? () => onLongPress(index) : undefined} 40 - style={a.flex_1} 47 + style={[ 48 + a.flex_1, 49 + a.rounded_xs, 50 + a.overflow_hidden, 51 + t.atoms.bg_contrast_25, 52 + imageStyle, 53 + ]} 41 54 accessibilityRole="button" 42 55 accessibilityLabel={image.alt || _(msg`Image`)} 43 56 accessibilityHint=""> 44 57 <Image 45 58 source={{uri: image.thumb}} 46 - style={[a.flex_1, a.rounded_xs, imageStyle]} 59 + style={[a.flex_1]} 47 60 accessible={true} 48 61 accessibilityLabel={image.alt} 49 62 accessibilityHint="" 50 63 accessibilityIgnoresInvertColors 51 64 /> 52 65 </Pressable> 53 - {image.alt === '' ? null : ( 54 - <View style={styles.altContainer}> 66 + {hasAlt && !hideBadges ? ( 67 + <View 68 + accessible={false} 69 + style={[ 70 + a.absolute, 71 + a.flex_row, 72 + a.align_center, 73 + a.rounded_xs, 74 + t.atoms.bg_contrast_25, 75 + { 76 + gap: 3, 77 + padding: 3, 78 + bottom: a.p_xs.padding, 79 + right: a.p_xs.padding, 80 + opacity: 0.8, 81 + }, 82 + largeAltBadge && [ 83 + { 84 + gap: 4, 85 + padding: 5, 86 + }, 87 + ], 88 + ]}> 55 89 <Text 56 - style={[styles.alt, largeAltBadge && a.text_xs]} 57 - accessible={false}> 90 + style={[a.font_heavy, largeAltBadge ? a.text_xs : {fontSize: 8}]}> 58 91 ALT 59 92 </Text> 60 93 </View> 61 - )} 94 + ) : null} 62 95 </View> 63 96 ) 64 97 } 65 - 66 - const styles = StyleSheet.create({ 67 - altContainer: { 68 - backgroundColor: 'rgba(0, 0, 0, 0.75)', 69 - borderRadius: 6, 70 - paddingHorizontal: 6, 71 - paddingVertical: 3, 72 - position: 'absolute', 73 - // Related to margin/gap hack. This keeps the alt label in the same position 74 - // on all platforms 75 - right: isWeb ? 8 : 5, 76 - bottom: isWeb ? 8 : 5, 77 - }, 78 - alt: { 79 - color: 'white', 80 - fontSize: 7, 81 - fontWeight: 'bold', 82 - }, 83 - })
+44 -63
src/view/com/util/images/ImageLayoutGrid.tsx
··· 1 1 import React from 'react' 2 - import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 3 3 import {AppBskyEmbedImages} from '@atproto/api' 4 + 5 + import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' 6 + import {atoms as a, useBreakpoints} from '#/alf' 4 7 import {GalleryItem} from './Gallery' 5 - import {isWeb} from 'platform/detection' 6 8 7 9 interface ImageLayoutGridProps { 8 10 images: AppBskyEmbedImages.ViewImage[] ··· 10 12 onLongPress?: (index: number) => void 11 13 onPressIn?: (index: number) => void 12 14 style?: StyleProp<ViewStyle> 15 + viewContext?: PostEmbedViewContext 13 16 } 14 17 15 18 export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { 19 + const {gtMobile} = useBreakpoints() 20 + const gap = 21 + props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 22 + ? gtMobile 23 + ? a.gap_xs 24 + : a.gap_2xs 25 + : gtMobile 26 + ? a.gap_sm 27 + : a.gap_xs 28 + const count = props.images.length 29 + const aspectRatio = count === 2 ? 2 : count === 3 ? 1.5 : 1 16 30 return ( 17 31 <View style={style}> 18 - <View style={styles.container}> 19 - <ImageLayoutGridInner {...props} /> 32 + <View style={[gap, {aspectRatio}]}> 33 + <ImageLayoutGridInner {...props} gap={gap} /> 20 34 </View> 21 35 </View> 22 36 ) ··· 27 41 onPress?: (index: number) => void 28 42 onLongPress?: (index: number) => void 29 43 onPressIn?: (index: number) => void 44 + viewContext?: PostEmbedViewContext 45 + gap: {gap: number} 30 46 } 31 47 32 48 function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 49 + const gap = props.gap 33 50 const count = props.images.length 34 51 35 52 switch (count) { 36 53 case 2: 37 54 return ( 38 - <View style={styles.flexRow}> 39 - <View style={styles.smallItem}> 40 - <GalleryItem {...props} index={0} imageStyle={styles.image} /> 55 + <View style={[a.flex_1, a.flex_row, gap]}> 56 + <View style={[a.flex_1, {aspectRatio: 1}]}> 57 + <GalleryItem {...props} index={0} /> 41 58 </View> 42 - <View style={styles.smallItem}> 43 - <GalleryItem {...props} index={1} imageStyle={styles.image} /> 59 + <View style={[a.flex_1, {aspectRatio: 1}]}> 60 + <GalleryItem {...props} index={1} /> 44 61 </View> 45 62 </View> 46 63 ) 47 64 48 65 case 3: 49 66 return ( 50 - <View style={styles.flexRow}> 51 - <View style={styles.threeSingle}> 52 - <GalleryItem {...props} index={0} imageStyle={styles.image} /> 67 + <View style={[a.flex_1, a.flex_row, gap]}> 68 + <View style={{flex: 2}}> 69 + <GalleryItem {...props} index={0} /> 53 70 </View> 54 - <View style={styles.threeDouble}> 55 - <View style={styles.smallItem}> 56 - <GalleryItem {...props} index={1} imageStyle={styles.image} /> 71 + <View style={[a.flex_1, gap]}> 72 + <View style={[a.flex_1, {aspectRatio: 1}]}> 73 + <GalleryItem {...props} index={1} /> 57 74 </View> 58 - <View style={styles.smallItem}> 59 - <GalleryItem {...props} index={2} imageStyle={styles.image} /> 75 + <View style={[a.flex_1, {aspectRatio: 1}]}> 76 + <GalleryItem {...props} index={2} /> 60 77 </View> 61 78 </View> 62 79 </View> ··· 65 82 case 4: 66 83 return ( 67 84 <> 68 - <View style={styles.flexRow}> 69 - <View style={styles.smallItem}> 70 - <GalleryItem {...props} index={0} imageStyle={styles.image} /> 85 + <View style={[a.flex_row, gap]}> 86 + <View style={[a.flex_1, {aspectRatio: 1}]}> 87 + <GalleryItem {...props} index={0} /> 71 88 </View> 72 - <View style={styles.smallItem}> 73 - <GalleryItem {...props} index={1} imageStyle={styles.image} /> 89 + <View style={[a.flex_1, {aspectRatio: 1}]}> 90 + <GalleryItem {...props} index={1} /> 74 91 </View> 75 92 </View> 76 - <View style={styles.flexRow}> 77 - <View style={styles.smallItem}> 78 - <GalleryItem {...props} index={2} imageStyle={styles.image} /> 93 + <View style={[a.flex_row, gap]}> 94 + <View style={[a.flex_1, {aspectRatio: 1}]}> 95 + <GalleryItem {...props} index={2} /> 79 96 </View> 80 - <View style={styles.smallItem}> 81 - <GalleryItem {...props} index={3} imageStyle={styles.image} /> 97 + <View style={[a.flex_1, {aspectRatio: 1}]}> 98 + <GalleryItem {...props} index={3} /> 82 99 </View> 83 100 </View> 84 101 </> ··· 88 105 return null 89 106 } 90 107 } 91 - 92 - // On web we use margin to calculate gap, as aspectRatio does not properly size 93 - // all images on web. On native though we cannot rely on margin, since the 94 - // negative margin interferes with the swipe controls on pagers. 95 - // https://github.com/facebook/yoga/issues/1418 96 - // https://github.com/bluesky-social/social-app/issues/2601 97 - const IMAGE_GAP = 5 98 - 99 - const styles = StyleSheet.create({ 100 - container: isWeb 101 - ? { 102 - marginHorizontal: -IMAGE_GAP / 2, 103 - marginVertical: -IMAGE_GAP / 2, 104 - } 105 - : { 106 - gap: IMAGE_GAP, 107 - }, 108 - flexRow: { 109 - flexDirection: 'row', 110 - gap: isWeb ? undefined : IMAGE_GAP, 111 - }, 112 - smallItem: {flex: 1, aspectRatio: 1}, 113 - image: isWeb 114 - ? { 115 - margin: IMAGE_GAP / 2, 116 - } 117 - : {}, 118 - threeSingle: { 119 - flex: 2, 120 - aspectRatio: isWeb ? 1 : undefined, 121 - }, 122 - threeDouble: { 123 - flex: 1, 124 - gap: isWeb ? undefined : IMAGE_GAP, 125 - }, 126 - })
+49 -10
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 33 33 import {makeProfileLink} from 'lib/routes/links' 34 34 import {precacheProfile} from 'state/queries/profile' 35 35 import {ComposerOptsQuote} from 'state/shell/composer' 36 - import {atoms as a} from '#/alf' 36 + import {atoms as a, useBreakpoints} from '#/alf' 37 37 import {RichText} from '#/components/RichText' 38 38 import {ContentHider} from '../../../../components/moderation/ContentHider' 39 39 import {PostAlerts} from '../../../../components/moderation/PostAlerts' ··· 41 41 import {PostMeta} from '../PostMeta' 42 42 import {Text} from '../text/Text' 43 43 import {PostEmbeds} from '.' 44 + import {PostEmbedViewContext, QuoteEmbedViewContext} from './types' 44 45 45 46 export function MaybeQuoteEmbed({ 46 47 embed, 47 48 onOpen, 48 49 style, 49 50 allowNestedQuotes, 51 + viewContext, 50 52 }: { 51 53 embed: AppBskyEmbedRecord.View 52 54 onOpen?: () => void 53 55 style?: StyleProp<ViewStyle> 54 56 allowNestedQuotes?: boolean 57 + viewContext?: QuoteEmbedViewContext 55 58 }) { 56 59 const pal = usePalette('default') 57 60 const {currentAccount} = useSession() ··· 67 70 onOpen={onOpen} 68 71 style={style} 69 72 allowNestedQuotes={allowNestedQuotes} 73 + viewContext={viewContext} 70 74 /> 71 75 ) 72 76 } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { ··· 113 117 onOpen, 114 118 style, 115 119 allowNestedQuotes, 120 + viewContext, 116 121 }: { 117 122 viewRecord: AppBskyEmbedRecord.ViewRecord 118 123 postRecord: AppBskyFeedPost.Record 119 124 onOpen?: () => void 120 125 style?: StyleProp<ViewStyle> 121 126 allowNestedQuotes?: boolean 127 + viewContext?: QuoteEmbedViewContext 122 128 }) { 123 129 const moderationOpts = useModerationOpts() 124 130 const moderation = React.useMemo(() => { ··· 144 150 onOpen={onOpen} 145 151 style={style} 146 152 allowNestedQuotes={allowNestedQuotes} 153 + viewContext={viewContext} 147 154 /> 148 155 ) 149 156 } ··· 154 161 onOpen, 155 162 style, 156 163 allowNestedQuotes, 164 + viewContext, 157 165 }: { 158 166 quote: ComposerOptsQuote 159 167 moderation?: ModerationDecision 160 168 onOpen?: () => void 161 169 style?: StyleProp<ViewStyle> 162 170 allowNestedQuotes?: boolean 171 + viewContext?: QuoteEmbedViewContext 163 172 }) { 164 173 const queryClient = useQueryClient() 165 174 const pal = usePalette('default') 166 175 const itemUrip = new AtUri(quote.uri) 167 176 const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) 168 177 const itemTitle = `Post by ${quote.author.handle}` 178 + const {gtMobile} = useBreakpoints() 169 179 170 180 const richText = React.useMemo( 171 181 () => ··· 197 207 } 198 208 } 199 209 }, [quote.embeds, allowNestedQuotes]) 210 + const isImagesEmbed = AppBskyEmbedImages.isView(embed) 200 211 201 212 const onBeforePress = React.useCallback(() => { 202 213 precacheProfile(queryClient, quote.author) ··· 226 237 {moderation ? ( 227 238 <PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} /> 228 239 ) : null} 229 - {richText ? ( 230 - <RichText 231 - value={richText} 232 - style={a.text_md} 233 - numberOfLines={20} 234 - disableLinks 235 - /> 236 - ) : null} 237 - {embed && <PostEmbeds embed={embed} moderation={moderation} />} 240 + 241 + {viewContext === QuoteEmbedViewContext.FeedEmbedRecordWithMedia && 242 + isImagesEmbed ? ( 243 + <View style={[a.flex_row, a.gap_md]}> 244 + {embed && ( 245 + <View style={[{width: gtMobile ? 100 : 80}]}> 246 + <PostEmbeds 247 + embed={embed} 248 + moderation={moderation} 249 + viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia} 250 + /> 251 + </View> 252 + )} 253 + {richText ? ( 254 + <View style={[a.flex_1, a.pt_xs]}> 255 + <RichText 256 + value={richText} 257 + style={a.text_md} 258 + numberOfLines={20} 259 + disableLinks 260 + /> 261 + </View> 262 + ) : null} 263 + </View> 264 + ) : ( 265 + <> 266 + {richText ? ( 267 + <RichText 268 + value={richText} 269 + style={a.text_md} 270 + numberOfLines={20} 271 + disableLinks 272 + /> 273 + ) : null} 274 + {embed && <PostEmbeds embed={embed} moderation={moderation} />} 275 + </> 276 + )} 238 277 </Link> 239 278 </ContentHider> 240 279 )
+30 -19
src/view/com/util/post-embeds/index.tsx
··· 3 3 InteractionManager, 4 4 StyleProp, 5 5 StyleSheet, 6 - Text, 7 6 View, 8 7 ViewStyle, 9 8 } from 'react-native' ··· 22 21 } from '@atproto/api' 23 22 24 23 import {ImagesLightbox, useLightboxControls} from '#/state/lightbox' 25 - import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 26 24 import {useModerationOpts} from '#/state/preferences/moderation-opts' 27 25 import {usePalette} from 'lib/hooks/usePalette' 28 26 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' ··· 34 32 import {ImageLayoutGrid} from '../images/ImageLayoutGrid' 35 33 import {ExternalLinkEmbed} from './ExternalLinkEmbed' 36 34 import {MaybeQuoteEmbed} from './QuoteEmbed' 35 + import {PostEmbedViewContext, QuoteEmbedViewContext} from './types' 37 36 import {VideoEmbed} from './VideoEmbed' 38 37 38 + export * from './types' 39 + 39 40 type Embed = 40 41 | AppBskyEmbedRecord.View 41 42 | AppBskyEmbedImages.View ··· 50 51 onOpen, 51 52 style, 52 53 allowNestedQuotes, 54 + viewContext, 53 55 }: { 54 56 embed?: Embed 55 57 moderation?: ModerationDecision 56 58 onOpen?: () => void 57 59 style?: StyleProp<ViewStyle> 58 60 allowNestedQuotes?: boolean 61 + viewContext?: PostEmbedViewContext 59 62 }) { 60 63 const {openLightbox} = useLightboxControls() 61 - const largeAltBadge = useLargeAltBadgeEnabled() 62 64 63 65 // quote post with media 64 66 // = ··· 69 71 embed={embed.media} 70 72 moderation={moderation} 71 73 onOpen={onOpen} 74 + viewContext={viewContext} 72 75 /> 73 - <MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} /> 76 + <MaybeQuoteEmbed 77 + embed={embed.record} 78 + onOpen={onOpen} 79 + viewContext={ 80 + viewContext === PostEmbedViewContext.Feed 81 + ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia 82 + : undefined 83 + } 84 + /> 74 85 </View> 75 86 ) 76 87 } ··· 124 135 } 125 136 126 137 if (images.length === 1) { 127 - const {alt, thumb, aspectRatio} = images[0] 138 + const image = images[0] 128 139 return ( 129 140 <ContentHider modui={moderation?.ui('contentMedia')}> 130 141 <View style={[styles.container, style]}> 131 142 <AutoSizedImage 132 - alt={alt} 133 - uri={thumb} 134 - dimensionsHint={aspectRatio} 143 + crop={ 144 + viewContext === PostEmbedViewContext.ThreadHighlighted 145 + ? 'none' 146 + : viewContext === 147 + PostEmbedViewContext.FeedEmbedRecordWithMedia 148 + ? 'square' 149 + : 'constrained' 150 + } 151 + image={image} 135 152 onPress={() => _openLightbox(0)} 136 153 onPressIn={() => onPressIn(0)} 137 - style={a.rounded_sm}> 138 - {alt === '' ? null : ( 139 - <View style={styles.altContainer}> 140 - <Text 141 - style={[styles.alt, largeAltBadge && a.text_xs]} 142 - accessible={false}> 143 - ALT 144 - </Text> 145 - </View> 146 - )} 147 - </AutoSizedImage> 154 + hideBadge={ 155 + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 156 + } 157 + /> 148 158 </View> 149 159 </ContentHider> 150 160 ) ··· 157 167 images={embed.images} 158 168 onPress={_openLightbox} 159 169 onPressIn={onPressIn} 170 + viewContext={viewContext} 160 171 /> 161 172 </View> 162 173 </ContentHider>
+9
src/view/com/util/post-embeds/types.ts
··· 1 + export enum PostEmbedViewContext { 2 + ThreadHighlighted = 'ThreadHighlighted', 3 + Feed = 'Feed', 4 + FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia', 5 + } 6 + 7 + export enum QuoteEmbedViewContext { 8 + FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia, 9 + }