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/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}