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