Bluesky app fork with some witchin' additions 馃挮
at main 250 lines 7.2 kB view raw
1import {useMemo, useRef} from 'react' 2import {type DimensionValue, Pressable, View} from 'react-native' 3import Animated, { 4 type AnimatedRef, 5 useAnimatedRef, 6} from 'react-native-reanimated' 7import {Image} from 'expo-image' 8import {type AppBskyEmbedImages} from '@atproto/api' 9import {utils} from '@bsky.app/alf' 10import {msg} from '@lingui/macro' 11import {useLingui} from '@lingui/react' 12 13import {type Dimensions} from '#/lib/media/types' 14import { 15 maybeModifyHighQualityImage, 16 useHighQualityImages, 17} from '#/state/preferences/high-quality-images' 18import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 19import {atoms as a, useTheme} from '#/alf' 20import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' 21import {MediaInsetBorder} from '#/components/MediaInsetBorder' 22import {Text} from '#/components/Typography' 23import {IS_NATIVE} from '#/env' 24 25export function ConstrainedImage({ 26 aspectRatio, 27 fullBleed, 28 children, 29 minMobileAspectRatio, 30}: { 31 aspectRatio: number 32 fullBleed?: boolean 33 minMobileAspectRatio?: number 34 children: React.ReactNode 35}) { 36 const t = useTheme() 37 /** 38 * Computed as a % value to apply as `paddingTop`, this basically controls 39 * the height of the image. 40 */ 41 const outerAspectRatio = useMemo<DimensionValue>(() => { 42 const ratio = IS_NATIVE 43 ? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box 44 : Math.min(1 / aspectRatio, 1) // 1:1 bounding box 45 return `${ratio * 100}%` 46 }, [aspectRatio, minMobileAspectRatio]) 47 48 return ( 49 <View style={[a.w_full]}> 50 <View style={[a.overflow_hidden, {paddingTop: outerAspectRatio}]}> 51 <View style={[a.absolute, a.inset_0, a.flex_row]}> 52 <View 53 style={[ 54 a.h_full, 55 a.rounded_md, 56 a.overflow_hidden, 57 t.atoms.bg_contrast_25, 58 fullBleed ? a.w_full : {aspectRatio}, 59 ]}> 60 {children} 61 </View> 62 </View> 63 </View> 64 </View> 65 ) 66} 67 68export function AutoSizedImage({ 69 image, 70 crop = 'constrained', 71 hideBadge, 72 onPress, 73 onLongPress, 74 onPressIn, 75}: { 76 image: AppBskyEmbedImages.ViewImage 77 crop?: 'none' | 'square' | 'constrained' 78 hideBadge?: boolean 79 onPress?: ( 80 containerRef: AnimatedRef<any>, 81 fetchedDims: Dimensions | null, 82 ) => void 83 onLongPress?: () => void 84 onPressIn?: () => void 85}) { 86 const t = useTheme() 87 const {_} = useLingui() 88 const largeAlt = useLargeAltBadgeEnabled() 89 const containerRef = useAnimatedRef() 90 const fetchedDimsRef = useRef<{width: number; height: number} | null>(null) 91 const highQualityImages = useHighQualityImages() 92 93 let aspectRatio: number | undefined 94 const dims = image.aspectRatio 95 if (dims) { 96 aspectRatio = dims.width / dims.height 97 if (Number.isNaN(aspectRatio)) { 98 aspectRatio = undefined 99 } 100 } 101 102 let constrained: number | undefined 103 let max: number | undefined 104 let rawIsCropped: boolean | undefined 105 if (aspectRatio !== undefined) { 106 const ratio = 1 / 2 // max of 1:2 ratio in feeds 107 constrained = Math.max(aspectRatio, ratio) 108 max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread 109 rawIsCropped = aspectRatio < constrained 110 } 111 112 const cropDisabled = crop === 'none' 113 const isCropped = rawIsCropped && !cropDisabled 114 const isContain = aspectRatio === undefined 115 const hasAlt = !!image.alt 116 117 const contents = ( 118 <Animated.View ref={containerRef} collapsable={false} style={{flex: 1}}> 119 <Image 120 contentFit={isContain ? 'contain' : 'cover'} 121 style={[a.w_full, a.h_full]} 122 source={maybeModifyHighQualityImage(image.thumb, highQualityImages)} 123 accessible={true} // Must set for `accessibilityLabel` to work 124 accessibilityIgnoresInvertColors 125 accessibilityLabel={image.alt} 126 accessibilityHint="" 127 onLoad={e => { 128 if (!isContain) { 129 fetchedDimsRef.current = { 130 width: e.source.width, 131 height: e.source.height, 132 } 133 } 134 }} 135 loading="lazy" 136 /> 137 <MediaInsetBorder /> 138 139 {(hasAlt || isCropped) && !hideBadge ? ( 140 <View 141 accessible={false} 142 style={[ 143 a.absolute, 144 a.flex_row, 145 { 146 bottom: a.p_xs.padding, 147 right: a.p_xs.padding, 148 gap: 3, 149 }, 150 largeAlt && [ 151 { 152 gap: 4, 153 }, 154 ], 155 ]}> 156 {isCropped && ( 157 <View 158 style={[ 159 a.rounded_xs, 160 t.atoms.bg_contrast_25, 161 { 162 padding: 3, 163 opacity: 0.8, 164 }, 165 largeAlt && [ 166 { 167 padding: 5, 168 }, 169 ], 170 ]}> 171 <Fullscreen 172 fill={t.atoms.text_contrast_high.color} 173 width={largeAlt ? 18 : 12} 174 /> 175 </View> 176 )} 177 {hasAlt && ( 178 <View 179 style={[ 180 a.justify_center, 181 a.rounded_xs, 182 t.atoms.bg_contrast_25, 183 { 184 padding: 3, 185 opacity: 0.8, 186 }, 187 largeAlt && [ 188 { 189 padding: 5, 190 }, 191 ], 192 ]}> 193 <Text style={[a.font_bold, largeAlt ? a.text_xs : {fontSize: 8}]}> 194 ALT 195 </Text> 196 </View> 197 )} 198 </View> 199 ) : null} 200 </Animated.View> 201 ) 202 203 if (cropDisabled) { 204 return ( 205 <Pressable 206 onPress={() => onPress?.(containerRef, fetchedDimsRef.current)} 207 onLongPress={onLongPress} 208 onPressIn={onPressIn} 209 // alt here is what screen readers actually use 210 accessibilityLabel={image.alt} 211 accessibilityHint={_(msg`Views full image`)} 212 accessibilityRole="button" 213 android_ripple={{ 214 color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), 215 foreground: true, 216 }} 217 style={[ 218 a.w_full, 219 a.rounded_md, 220 a.overflow_hidden, 221 t.atoms.bg_contrast_25, 222 {aspectRatio: max ?? 1}, 223 ]}> 224 {contents} 225 </Pressable> 226 ) 227 } else { 228 return ( 229 <ConstrainedImage 230 fullBleed={crop === 'square'} 231 aspectRatio={constrained ?? 1}> 232 <Pressable 233 onPress={() => onPress?.(containerRef, fetchedDimsRef.current)} 234 onLongPress={onLongPress} 235 onPressIn={onPressIn} 236 // alt here is what screen readers actually use 237 accessibilityLabel={image.alt} 238 accessibilityHint={_(msg`Views full image`)} 239 accessibilityRole="button" 240 android_ripple={{ 241 color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), 242 foreground: true, 243 }} 244 style={[a.h_full]}> 245 {contents} 246 </Pressable> 247 </ConstrainedImage> 248 ) 249 } 250}