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