forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect, useState} from 'react'
2import {Pressable, StyleSheet, View} from 'react-native'
3import {Image} from 'expo-image'
4import {msg} from '@lingui/macro'
5import {useLingui} from '@lingui/react'
6import {FocusGuards, FocusScope} from 'radix-ui/internal'
7import {RemoveScrollBar} from 'react-remove-scroll-bar'
8
9import {useA11y} from '#/state/a11y'
10import {useLightbox, useLightboxControls} from '#/state/lightbox'
11import {
12 atoms as a,
13 flatten,
14 ThemeProvider,
15 useBreakpoints,
16 useTheme,
17} from '#/alf'
18import {Button} from '#/components/Button'
19import {Backdrop} from '#/components/Dialog'
20import {
21 ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon,
22 ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
23} from '#/components/icons/Chevron'
24import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
25import {Loader} from '#/components/Loader'
26import {Text} from '#/components/Typography'
27import {type ImageSource} from './ImageViewing/@types'
28
29export function Lightbox() {
30 const {activeLightbox} = useLightbox()
31 const {closeLightbox} = useLightboxControls()
32 const isActive = !!activeLightbox
33
34 if (!isActive) {
35 return null
36 }
37
38 const initialIndex = activeLightbox.index
39 const imgs = activeLightbox.images
40 return (
41 <ThemeProvider theme="dark">
42 <LightboxContainer handleBackgroundPress={closeLightbox}>
43 <LightboxGallery
44 key={activeLightbox.id}
45 imgs={imgs}
46 initialIndex={initialIndex}
47 onClose={closeLightbox}
48 />
49 </LightboxContainer>
50 </ThemeProvider>
51 )
52}
53
54function LightboxContainer({
55 children,
56 handleBackgroundPress,
57}: {
58 children: React.ReactNode
59 handleBackgroundPress: () => void
60}) {
61 const {_} = useLingui()
62 FocusGuards.useFocusGuards()
63 return (
64 <Pressable
65 accessibilityHint={undefined}
66 accessibilityLabel={_(msg`Close image viewer`)}
67 onPress={handleBackgroundPress}
68 style={[a.fixed, a.inset_0, a.z_10]}>
69 <Backdrop />
70 <RemoveScrollBar />
71 <FocusScope.FocusScope loop trapped asChild>
72 <div style={{position: 'absolute', inset: 0}}>{children}</div>
73 </FocusScope.FocusScope>
74 </Pressable>
75 )
76}
77
78function LightboxGallery({
79 imgs,
80 initialIndex = 0,
81 onClose,
82}: {
83 imgs: ImageSource[]
84 initialIndex: number
85 onClose: () => void
86}) {
87 const t = useTheme()
88 const {_} = useLingui()
89 const {reduceMotionEnabled} = useA11y()
90 const [index, setIndex] = useState(initialIndex)
91 const [hasAnyLoaded, setAnyHasLoaded] = useState(false)
92 const [isAltExpanded, setAltExpanded] = useState(false)
93
94 const {gtPhone} = useBreakpoints()
95
96 const canGoLeft = index >= 1
97 const canGoRight = index < imgs.length - 1
98 const onPressLeft = useCallback(() => {
99 if (canGoLeft) {
100 setIndex(index - 1)
101 }
102 }, [index, canGoLeft])
103 const onPressRight = useCallback(() => {
104 if (canGoRight) {
105 setIndex(index + 1)
106 }
107 }, [index, canGoRight])
108
109 const onKeyDown = useCallback(
110 (e: KeyboardEvent) => {
111 if (e.key === 'Escape') {
112 e.preventDefault()
113 onClose()
114 } else if (e.key === 'ArrowLeft') {
115 onPressLeft()
116 } else if (e.key === 'ArrowRight') {
117 onPressRight()
118 }
119 },
120 [onClose, onPressLeft, onPressRight],
121 )
122
123 useEffect(() => {
124 window.addEventListener('keydown', onKeyDown)
125 return () => window.removeEventListener('keydown', onKeyDown)
126 }, [onKeyDown])
127
128 const delayedFadeInAnim = !reduceMotionEnabled && [
129 a.fade_in,
130 {animationDelay: '0.2s', animationFillMode: 'both'},
131 ]
132
133 const img = imgs[index]
134
135 return (
136 <View style={[a.absolute, a.inset_0]}>
137 <View style={[a.flex_1, a.justify_center, a.align_center]}>
138 <LightboxGalleryItem
139 key={index}
140 source={img.uri}
141 alt={img.alt}
142 type={img.type}
143 hasAnyLoaded={hasAnyLoaded}
144 onLoad={() => setAnyHasLoaded(true)}
145 />
146 {canGoLeft && (
147 <Button
148 onPress={onPressLeft}
149 style={[
150 a.absolute,
151 styles.leftBtn,
152 styles.blurredBackdrop,
153 a.transition_color,
154 delayedFadeInAnim,
155 ]}
156 hoverStyle={styles.blurredBackdropHover}
157 color="secondary"
158 label={_(msg`Previous image`)}
159 shape="round"
160 size={gtPhone ? 'large' : 'small'}>
161 <ChevronLeftIcon
162 size={gtPhone ? 'md' : 'sm'}
163 style={{color: t.palette.white}}
164 />
165 </Button>
166 )}
167 {canGoRight && (
168 <Button
169 onPress={onPressRight}
170 style={[
171 a.absolute,
172 styles.rightBtn,
173 styles.blurredBackdrop,
174 a.transition_color,
175 delayedFadeInAnim,
176 ]}
177 hoverStyle={styles.blurredBackdropHover}
178 color="secondary"
179 label={_(msg`Next image`)}
180 shape="round"
181 size={gtPhone ? 'large' : 'small'}>
182 <ChevronRightIcon
183 size={gtPhone ? 'md' : 'sm'}
184 style={{color: t.palette.white}}
185 />
186 </Button>
187 )}
188 </View>
189 {img.alt ? (
190 <View style={[a.px_4xl, a.py_2xl, t.atoms.bg, delayedFadeInAnim]}>
191 <Pressable
192 accessibilityLabel={_(msg`Expand alt text`)}
193 accessibilityHint={_(
194 msg`If alt text is long, toggles alt text expanded state`,
195 )}
196 onPress={() => {
197 setAltExpanded(!isAltExpanded)
198 }}>
199 <Text
200 style={[a.text_md, a.leading_snug]}
201 numberOfLines={isAltExpanded ? 0 : 3}
202 ellipsizeMode="tail">
203 {img.alt}
204 </Text>
205 </Pressable>
206 </View>
207 ) : null}
208 <Button
209 onPress={onClose}
210 style={[
211 a.absolute,
212 styles.closeBtn,
213 styles.blurredBackdrop,
214 a.transition_color,
215 delayedFadeInAnim,
216 ]}
217 hoverStyle={styles.blurredBackdropHover}
218 color="secondary"
219 label={_(msg`Close image viewer`)}
220 shape="round"
221 size={gtPhone ? 'large' : 'small'}>
222 <XIcon size={gtPhone ? 'md' : 'sm'} style={{color: t.palette.white}} />
223 </Button>
224 </View>
225 )
226}
227
228function LightboxGalleryItem({
229 source,
230 alt,
231 type,
232 onLoad,
233 hasAnyLoaded,
234}: {
235 source: string
236 alt: string | undefined
237 type: ImageSource['type']
238 onLoad: () => void
239 hasAnyLoaded: boolean
240}) {
241 const {reduceMotionEnabled} = useA11y()
242 const [hasLoaded, setHasLoaded] = useState(false)
243 const [isFirstToLoad] = useState(!hasAnyLoaded)
244
245 /**
246 * We want to show a zoom/fade in animation when the lightbox first opens.
247 * To avoid showing it as we switch between images, we keep track in the parent
248 * whether any image has loaded yet. We then save what the value of this is on first
249 * render (as when it changes, we don't want to then *remove* then animation). when
250 * the image loads, if this is the first image to load, we play the animation.
251 *
252 * We also use this `hasLoaded` state to show a loading indicator. This is on a 1s
253 * delay and then a slow fade in to avoid flicker. -sfn
254 */
255 const zoomInWhenReady =
256 !reduceMotionEnabled &&
257 isFirstToLoad &&
258 (hasAnyLoaded
259 ? [a.zoom_fade_in, {animationDuration: '0.5s'}]
260 : {opacity: 0})
261
262 const handleLoad = () => {
263 setHasLoaded(true)
264 onLoad()
265 }
266
267 let image = null
268 switch (type) {
269 case 'circle-avi':
270 case 'rect-avi':
271 image = (
272 <img
273 src={source}
274 style={flatten([
275 styles.avi,
276 {
277 borderRadius:
278 type === 'circle-avi' ? '50%' : type === 'rect-avi' ? '10%' : 0,
279 },
280 zoomInWhenReady,
281 ])}
282 alt={alt}
283 onLoad={handleLoad}
284 />
285 )
286 break
287 case 'image':
288 image = (
289 <Image
290 source={{uri: source}}
291 alt={alt}
292 style={[a.w_full, a.h_full, zoomInWhenReady]}
293 onLoad={handleLoad}
294 contentFit="contain"
295 accessibilityIgnoresInvertColors
296 />
297 )
298 break
299 }
300
301 return (
302 <>
303 {image}
304 {!hasLoaded && (
305 <View
306 style={[
307 a.absolute,
308 a.inset_0,
309 a.justify_center,
310 a.align_center,
311 a.fade_in,
312 {
313 opacity: 0,
314 animationDuration: '500ms',
315 animationDelay: '1s',
316 animationFillMode: 'both',
317 },
318 ]}>
319 <Loader size="xl" />
320 </View>
321 )}
322 </>
323 )
324}
325
326const styles = StyleSheet.create({
327 avi: {
328 // @ts-ignore web-only
329 maxWidth: `calc(min(400px, 100vw))`,
330 // @ts-ignore web-only
331 maxHeight: `calc(min(400px, 100vh))`,
332 padding: 16,
333 boxSizing: 'border-box',
334 },
335 closeBtn: {
336 top: 20,
337 right: 20,
338 },
339 leftBtn: {
340 left: 20,
341 right: 'auto',
342 top: '50%',
343 },
344 rightBtn: {
345 right: 20,
346 left: 'auto',
347 top: '50%',
348 },
349 blurredBackdrop: {
350 backgroundColor: '#00000077',
351 // @ts-expect-error web only -sfn
352 backdropFilter: 'blur(10px)',
353 },
354 blurredBackdropHover: {
355 backgroundColor: '#00000088',
356 },
357})