mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {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 {msg} from '@lingui/macro'
10import {useLingui} from '@lingui/react'
11
12import {type Dimensions} from '#/lib/media/types'
13import {isNative} from '#/platform/detection'
14import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
15import {atoms as a, useBreakpoints, useTheme} from '#/alf'
16import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
17import {MediaInsetBorder} from '#/components/MediaInsetBorder'
18import {Text} from '#/components/Typography'
19
20export function ConstrainedImage({
21 aspectRatio,
22 fullBleed,
23 children,
24 minMobileAspectRatio,
25}: {
26 aspectRatio: number
27 fullBleed?: boolean
28 minMobileAspectRatio?: number
29 children: React.ReactNode
30}) {
31 const t = useTheme()
32 const {gtMobile} = useBreakpoints()
33 /**
34 * Computed as a % value to apply as `paddingTop`, this basically controls
35 * the height of the image.
36 */
37 const outerAspectRatio = React.useMemo<DimensionValue>(() => {
38 const ratio =
39 isNative || !gtMobile
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, gtMobile, 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 />
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 style={[
208 a.w_full,
209 a.rounded_md,
210 a.overflow_hidden,
211 t.atoms.bg_contrast_25,
212 {aspectRatio: max ?? 1},
213 ]}>
214 {contents}
215 </Pressable>
216 )
217 } else {
218 return (
219 <ConstrainedImage
220 fullBleed={crop === 'square'}
221 aspectRatio={constrained ?? 1}>
222 <Pressable
223 onPress={() => onPress?.(containerRef, fetchedDimsRef.current)}
224 onLongPress={onLongPress}
225 onPressIn={onPressIn}
226 // alt here is what screen readers actually use
227 accessibilityLabel={image.alt}
228 accessibilityHint={_(msg`Views full image`)}
229 style={[a.h_full]}>
230 {contents}
231 </Pressable>
232 </ConstrainedImage>
233 )
234 }
235}