forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}