Bluesky app fork with some witchin' additions 💫
witchsky.app
bluesky
fork
client
1import {useCallback, useEffect, useRef, useState} from 'react'
2import {Pressable, StyleSheet, View} from 'react-native'
3import {Image} from 'expo-image'
4import {msg} from '@lingui/core/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
73 role="dialog"
74 aria-modal="true"
75 aria-label={_(msg`Image viewer`)}
76 style={{position: 'absolute', inset: 0}}>
77 {children}
78 </div>
79 </FocusScope.FocusScope>
80 </Pressable>
81 )
82}
83
84function LightboxGallery({
85 imgs,
86 initialIndex = 0,
87 onClose,
88}: {
89 imgs: ImageSource[]
90 initialIndex: number
91 onClose: () => void
92}) {
93 const t = useTheme()
94 const {_} = useLingui()
95 const {reduceMotionEnabled} = useA11y()
96 const [index, setIndex] = useState(initialIndex)
97 const [hasAnyLoaded, setAnyHasLoaded] = useState(false)
98 const [isAltExpanded, setAltExpanded] = useState(false)
99
100 const {gtPhone} = useBreakpoints()
101
102 const canGoLeft = index >= 1
103 const canGoRight = index < imgs.length - 1
104 const onPressLeft = useCallback(() => {
105 if (canGoLeft) {
106 setIndex(index - 1)
107 }
108 }, [index, canGoLeft])
109 const onPressRight = useCallback(() => {
110 if (canGoRight) {
111 setIndex(index + 1)
112 }
113 }, [index, canGoRight])
114
115 const onKeyDown = useCallback(
116 (e: KeyboardEvent) => {
117 if (e.key === 'Escape') {
118 e.preventDefault()
119 onClose()
120 } else if (e.key === 'ArrowLeft') {
121 onPressLeft()
122 } else if (e.key === 'ArrowRight') {
123 onPressRight()
124 }
125 },
126 [onClose, onPressLeft, onPressRight],
127 )
128
129 useEffect(() => {
130 window.addEventListener('keydown', onKeyDown)
131 return () => window.removeEventListener('keydown', onKeyDown)
132 }, [onKeyDown])
133
134 // Push a history entry so the browser back button closes the lightbox
135 // instead of navigating away from the page.
136 const closedByPopStateRef = useRef(false)
137 useEffect(() => {
138 history.pushState({lightbox: true}, '')
139
140 const handlePopState = () => {
141 closedByPopStateRef.current = true
142 onClose()
143 }
144 window.addEventListener('popstate', handlePopState)
145
146 return () => {
147 window.removeEventListener('popstate', handlePopState)
148 // Only pop our entry if it's still the current one. If navigation
149 // already pushed a new entry on top, leave the orphaned entry —
150 // it shares the same URL so traversing through it is harmless.
151 if (
152 !closedByPopStateRef.current &&
153 (history.state as {lightbox?: boolean})?.lightbox
154 ) {
155 history.back()
156 }
157 }
158 }, [onClose])
159
160 const delayedFadeInAnim = !reduceMotionEnabled && [
161 a.fade_in,
162 {animationDelay: '0.2s', animationFillMode: 'both'},
163 ]
164
165 const img = imgs[index]
166
167 return (
168 <View style={[a.absolute, a.inset_0]}>
169 <View style={[a.flex_1, a.justify_center, a.align_center]}>
170 <LightboxGalleryItem
171 key={index}
172 source={img.uri}
173 alt={img.alt}
174 type={img.type}
175 hasAnyLoaded={hasAnyLoaded}
176 onLoad={() => setAnyHasLoaded(true)}
177 />
178 {canGoLeft && (
179 <Button
180 onPress={onPressLeft}
181 style={[
182 a.absolute,
183 styles.leftBtn,
184 styles.blurredBackdrop,
185 a.transition_color,
186 delayedFadeInAnim,
187 ]}
188 hoverStyle={styles.blurredBackdropHover}
189 color="secondary"
190 label={_(msg`Previous image`)}
191 shape="round"
192 size={gtPhone ? 'large' : 'small'}>
193 <ChevronLeftIcon
194 size={gtPhone ? 'md' : 'sm'}
195 style={{color: t.palette.white}}
196 />
197 </Button>
198 )}
199 {canGoRight && (
200 <Button
201 onPress={onPressRight}
202 style={[
203 a.absolute,
204 styles.rightBtn,
205 styles.blurredBackdrop,
206 a.transition_color,
207 delayedFadeInAnim,
208 ]}
209 hoverStyle={styles.blurredBackdropHover}
210 color="secondary"
211 label={_(msg`Next image`)}
212 shape="round"
213 size={gtPhone ? 'large' : 'small'}>
214 <ChevronRightIcon
215 size={gtPhone ? 'md' : 'sm'}
216 style={{color: t.palette.white}}
217 />
218 </Button>
219 )}
220 </View>
221 {img.alt ? (
222 <View style={[a.px_4xl, a.py_2xl, t.atoms.bg, delayedFadeInAnim]}>
223 <Pressable
224 accessibilityLabel={_(msg`Expand alt text`)}
225 accessibilityHint={_(
226 msg`If alt text is long, toggles alt text expanded state`,
227 )}
228 onPress={() => {
229 setAltExpanded(!isAltExpanded)
230 }}>
231 <Text
232 style={[a.text_md, a.leading_snug]}
233 numberOfLines={isAltExpanded ? 0 : 3}
234 ellipsizeMode="tail">
235 {img.alt}
236 </Text>
237 </Pressable>
238 </View>
239 ) : null}
240 {imgs.length > 1 && (
241 <div aria-live="polite" aria-atomic="true" style={a.sr_only}>
242 <Text>{_(msg`Image ${index + 1} of ${imgs.length}`)}</Text>
243 </div>
244 )}
245 <Button
246 onPress={onClose}
247 style={[
248 a.absolute,
249 styles.closeBtn,
250 styles.blurredBackdrop,
251 a.transition_color,
252 delayedFadeInAnim,
253 ]}
254 hoverStyle={styles.blurredBackdropHover}
255 color="secondary"
256 label={_(msg`Close image viewer`)}
257 shape="round"
258 size={gtPhone ? 'large' : 'small'}>
259 <XIcon size={gtPhone ? 'md' : 'sm'} style={{color: t.palette.white}} />
260 </Button>
261 </View>
262 )
263}
264
265function LightboxGalleryItem({
266 source,
267 alt,
268 type,
269 onLoad,
270 hasAnyLoaded,
271}: {
272 source: string
273 alt: string | undefined
274 type: ImageSource['type']
275 onLoad: () => void
276 hasAnyLoaded: boolean
277}) {
278 const {reduceMotionEnabled} = useA11y()
279 const [hasLoaded, setHasLoaded] = useState(false)
280 const [isFirstToLoad] = useState(!hasAnyLoaded)
281
282 /**
283 * We want to show a zoom/fade in animation when the lightbox first opens.
284 * To avoid showing it as we switch between images, we keep track in the parent
285 * whether any image has loaded yet. We then save what the value of this is on first
286 * render (as when it changes, we don't want to then *remove* then animation). when
287 * the image loads, if this is the first image to load, we play the animation.
288 *
289 * We also use this `hasLoaded` state to show a loading indicator. This is on a 1s
290 * delay and then a slow fade in to avoid flicker. -sfn
291 */
292 const zoomInWhenReady =
293 !reduceMotionEnabled &&
294 isFirstToLoad &&
295 (hasAnyLoaded
296 ? [a.zoom_fade_in, {animationDuration: '0.5s'}]
297 : {opacity: 0})
298
299 const handleLoad = () => {
300 setHasLoaded(true)
301 onLoad()
302 }
303
304 let image = null
305 switch (type) {
306 case 'circle-avi':
307 case 'rect-avi':
308 image = (
309 <img
310 src={source}
311 style={flatten([
312 styles.avi,
313 {
314 borderRadius:
315 type === 'circle-avi' ? '50%' : type === 'rect-avi' ? '10%' : 0,
316 },
317 zoomInWhenReady,
318 ])}
319 alt={alt}
320 onLoad={handleLoad}
321 />
322 )
323 break
324 case 'image':
325 image = (
326 <Image
327 source={{uri: source}}
328 alt={alt}
329 style={[a.w_full, a.h_full, zoomInWhenReady]}
330 onLoad={handleLoad}
331 contentFit="contain"
332 accessibilityIgnoresInvertColors
333 />
334 )
335 break
336 }
337
338 return (
339 <>
340 {image}
341 {!hasLoaded && (
342 <View
343 style={[
344 a.absolute,
345 a.inset_0,
346 a.justify_center,
347 a.align_center,
348 a.fade_in,
349 {
350 opacity: 0,
351 animationDuration: '500ms',
352 animationDelay: '1s',
353 animationFillMode: 'both',
354 },
355 ]}>
356 <Loader size="xl" />
357 </View>
358 )}
359 </>
360 )
361}
362
363const styles = StyleSheet.create({
364 avi: {
365 // @ts-ignore web-only
366 maxWidth: `calc(min(400px, 100vw))`,
367 // @ts-ignore web-only
368 maxHeight: `calc(min(400px, 100vh))`,
369 padding: 16,
370 boxSizing: 'border-box',
371 },
372 closeBtn: {
373 top: 20,
374 right: 20,
375 },
376 leftBtn: {
377 left: 20,
378 right: 'auto',
379 top: '50%',
380 },
381 rightBtn: {
382 right: 20,
383 left: 'auto',
384 top: '50%',
385 },
386 blurredBackdrop: {
387 backgroundColor: '#00000077',
388 // @ts-expect-error web only -sfn
389 backdropFilter: 'blur(10px)',
390 },
391 blurredBackdropHover: {
392 backgroundColor: '#00000088',
393 },
394})