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