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 {Trans} from '@lingui/react/macro'
7import {FocusGuards, FocusScope} from 'radix-ui/internal'
8import {RemoveScrollBar} from 'react-remove-scroll-bar'
9
10import {saveImageToMediaLibrary} from '#/lib/media/manip'
11import {useA11y} from '#/state/a11y'
12import {useLightbox, useLightboxControls} from '#/state/lightbox'
13import {
14 atoms as a,
15 flatten,
16 ThemeProvider,
17 useBreakpoints,
18 useTheme,
19} from '#/alf'
20import {Button} from '#/components/Button'
21import {Backdrop} from '#/components/Dialog'
22import {
23 ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon,
24 ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
25} from '#/components/icons/Chevron'
26import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
27import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
28import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
29import {Loader} from '#/components/Loader'
30import * as Menu from '#/components/Menu'
31import * as Toast from '#/components/Toast'
32import {Text} from '#/components/Typography'
33import {type ImageSource} from './ImageViewing/@types'
34
35export function Lightbox() {
36 const {activeLightbox} = useLightbox()
37 const {closeLightbox} = useLightboxControls()
38 const isActive = !!activeLightbox
39
40 if (!isActive) {
41 return null
42 }
43
44 const initialIndex = activeLightbox.index
45 const imgs = activeLightbox.images
46 return (
47 <ThemeProvider theme="dark">
48 <LightboxContainer handleBackgroundPress={closeLightbox}>
49 <LightboxGallery
50 key={activeLightbox.id}
51 imgs={imgs}
52 initialIndex={initialIndex}
53 onClose={closeLightbox}
54 />
55 </LightboxContainer>
56 </ThemeProvider>
57 )
58}
59
60function LightboxContainer({
61 children,
62 handleBackgroundPress,
63}: {
64 children: React.ReactNode
65 handleBackgroundPress: () => void
66}) {
67 const {_} = useLingui()
68 FocusGuards.useFocusGuards()
69 return (
70 <Pressable
71 accessibilityHint={undefined}
72 accessibilityLabel={_(msg`Close image viewer`)}
73 onPress={handleBackgroundPress}
74 style={[a.fixed, a.inset_0, a.z_10]}>
75 <Backdrop />
76 <RemoveScrollBar />
77 <FocusScope.FocusScope loop trapped asChild>
78 <div
79 role="dialog"
80 aria-modal="true"
81 aria-label={_(msg`Image viewer`)}
82 style={{position: 'absolute', inset: 0}}>
83 {children}
84 </div>
85 </FocusScope.FocusScope>
86 </Pressable>
87 )
88}
89
90function LightboxGallery({
91 imgs,
92 initialIndex = 0,
93 onClose,
94}: {
95 imgs: ImageSource[]
96 initialIndex: number
97 onClose: () => void
98}) {
99 const t = useTheme()
100 const {_} = useLingui()
101 const {reduceMotionEnabled} = useA11y()
102 const [index, setIndex] = useState(initialIndex)
103 const [hasAnyLoaded, setAnyHasLoaded] = useState(false)
104 const [isAltExpanded, setAltExpanded] = useState(false)
105
106 const {gtPhone} = useBreakpoints()
107
108 const canGoLeft = index >= 1
109 const canGoRight = index < imgs.length - 1
110 const onPressLeft = useCallback(() => {
111 if (canGoLeft) {
112 setIndex(index - 1)
113 }
114 }, [index, canGoLeft])
115 const onPressRight = useCallback(() => {
116 if (canGoRight) {
117 setIndex(index + 1)
118 }
119 }, [index, canGoRight])
120
121 const onKeyDown = useCallback(
122 (e: KeyboardEvent) => {
123 if (e.key === 'Escape') {
124 e.preventDefault()
125 onClose()
126 } else if (e.key === 'ArrowLeft') {
127 onPressLeft()
128 } else if (e.key === 'ArrowRight') {
129 onPressRight()
130 }
131 },
132 [onClose, onPressLeft, onPressRight],
133 )
134
135 useEffect(() => {
136 window.addEventListener('keydown', onKeyDown)
137 return () => window.removeEventListener('keydown', onKeyDown)
138 }, [onKeyDown])
139
140 // Push a history entry so the browser back button closes the lightbox
141 // instead of navigating away from the page.
142 const closedByPopStateRef = useRef(false)
143 useEffect(() => {
144 history.pushState({lightbox: true}, '')
145
146 const handlePopState = () => {
147 closedByPopStateRef.current = true
148 onClose()
149 }
150 window.addEventListener('popstate', handlePopState)
151
152 return () => {
153 window.removeEventListener('popstate', handlePopState)
154 // Only pop our entry if it's still the current one. If navigation
155 // already pushed a new entry on top, leave the orphaned entry —
156 // it shares the same URL so traversing through it is harmless.
157 if (
158 !closedByPopStateRef.current &&
159 (history.state as {lightbox?: boolean})?.lightbox
160 ) {
161 history.back()
162 }
163 }
164 }, [onClose])
165
166 const delayedFadeInAnim = !reduceMotionEnabled && [
167 a.fade_in,
168 {animationDelay: '0.2s', animationFillMode: 'both'},
169 ]
170
171 const img = imgs[index]
172
173 return (
174 <View style={[a.absolute, a.inset_0]}>
175 <View style={[a.flex_1, a.justify_center, a.align_center]}>
176 <LightboxGalleryItem
177 key={index}
178 source={img.uri}
179 alt={img.alt}
180 type={img.type}
181 hasAnyLoaded={hasAnyLoaded}
182 onLoad={() => setAnyHasLoaded(true)}
183 />
184 {canGoLeft && (
185 <Button
186 onPress={onPressLeft}
187 style={[
188 a.absolute,
189 styles.leftBtn,
190 styles.blurredBackdrop,
191 a.transition_color,
192 delayedFadeInAnim,
193 ]}
194 hoverStyle={styles.blurredBackdropHover}
195 color="secondary"
196 label={_(msg`Previous image`)}
197 shape="round"
198 size={gtPhone ? 'large' : 'small'}>
199 <ChevronLeftIcon
200 size={gtPhone ? 'md' : 'sm'}
201 style={{color: t.palette.white}}
202 />
203 </Button>
204 )}
205 {canGoRight && (
206 <Button
207 onPress={onPressRight}
208 style={[
209 a.absolute,
210 styles.rightBtn,
211 styles.blurredBackdrop,
212 a.transition_color,
213 delayedFadeInAnim,
214 ]}
215 hoverStyle={styles.blurredBackdropHover}
216 color="secondary"
217 label={_(msg`Next image`)}
218 shape="round"
219 size={gtPhone ? 'large' : 'small'}>
220 <ChevronRightIcon
221 size={gtPhone ? 'md' : 'sm'}
222 style={{color: t.palette.white}}
223 />
224 </Button>
225 )}
226 </View>
227 {img.alt ? (
228 <View style={[a.px_4xl, a.py_2xl, t.atoms.bg, delayedFadeInAnim]}>
229 <Pressable
230 accessibilityLabel={_(msg`Expand alt text`)}
231 accessibilityHint={_(
232 msg`If alt text is long, toggles alt text expanded state`,
233 )}
234 onPress={() => {
235 setAltExpanded(!isAltExpanded)
236 }}>
237 <Text
238 style={[a.text_md, a.leading_snug]}
239 numberOfLines={isAltExpanded ? 0 : 3}
240 ellipsizeMode="tail">
241 {img.alt}
242 </Text>
243 </Pressable>
244 </View>
245 ) : null}
246 {imgs.length > 1 && (
247 <div aria-live="polite" aria-atomic="true" style={a.sr_only}>
248 <Text>{_(msg`Image ${index + 1} of ${imgs.length}`)}</Text>
249 </div>
250 )}
251 <Menu.Root>
252 <Menu.Trigger label={_(msg`Image options`)}>
253 {({props}) => (
254 <Button
255 {...props}
256 style={[
257 a.absolute,
258 styles.menuBtn,
259 styles.blurredBackdrop,
260 a.transition_color,
261 delayedFadeInAnim,
262 ]}
263 hoverStyle={styles.blurredBackdropHover}
264 color="secondary"
265 label={_(msg`Image options`)}
266 shape="round"
267 size={gtPhone ? 'large' : 'small'}>
268 <EllipsisIcon
269 size={gtPhone ? 'md' : 'sm'}
270 style={{color: t.palette.white}}
271 />
272 </Button>
273 )}
274 </Menu.Trigger>
275 <Menu.Outer>
276 <Menu.Group>
277 <Menu.Item
278 label={_(msg`Download image`)}
279 onPress={() => {
280 saveImageToMediaLibrary({uri: img.uri}).then(
281 () => {
282 Toast.show(_(msg`Image saved`))
283 },
284 () => {
285 Toast.show(_(msg`Failed to save image`), {type: 'error'})
286 },
287 )
288 }}>
289 <Menu.ItemText>
290 <Trans>Download image</Trans>
291 </Menu.ItemText>
292 <Menu.ItemIcon icon={DownloadIcon} position="right" />
293 </Menu.Item>
294 </Menu.Group>
295 </Menu.Outer>
296 </Menu.Root>
297 <Button
298 onPress={onClose}
299 style={[
300 a.absolute,
301 styles.closeBtn,
302 styles.blurredBackdrop,
303 a.transition_color,
304 delayedFadeInAnim,
305 ]}
306 hoverStyle={styles.blurredBackdropHover}
307 color="secondary"
308 label={_(msg`Close image viewer`)}
309 shape="round"
310 size={gtPhone ? 'large' : 'small'}>
311 <XIcon size={gtPhone ? 'md' : 'sm'} style={{color: t.palette.white}} />
312 </Button>
313 </View>
314 )
315}
316
317function LightboxGalleryItem({
318 source,
319 alt,
320 type,
321 onLoad,
322 hasAnyLoaded,
323}: {
324 source: string
325 alt: string | undefined
326 type: ImageSource['type']
327 onLoad: () => void
328 hasAnyLoaded: boolean
329}) {
330 const {reduceMotionEnabled} = useA11y()
331 const [hasLoaded, setHasLoaded] = useState(false)
332 const [isFirstToLoad] = useState(!hasAnyLoaded)
333
334 /**
335 * We want to show a zoom/fade in animation when the lightbox first opens.
336 * To avoid showing it as we switch between images, we keep track in the parent
337 * whether any image has loaded yet. We then save what the value of this is on first
338 * render (as when it changes, we don't want to then *remove* then animation). when
339 * the image loads, if this is the first image to load, we play the animation.
340 *
341 * We also use this `hasLoaded` state to show a loading indicator. This is on a 1s
342 * delay and then a slow fade in to avoid flicker. -sfn
343 */
344 const zoomInWhenReady =
345 !reduceMotionEnabled &&
346 isFirstToLoad &&
347 (hasAnyLoaded
348 ? [a.zoom_fade_in, {animationDuration: '0.5s'}]
349 : {opacity: 0})
350
351 const handleLoad = () => {
352 setHasLoaded(true)
353 onLoad()
354 }
355
356 let image = null
357 switch (type) {
358 case 'circle-avi':
359 case 'rect-avi':
360 image = (
361 <img
362 src={source}
363 style={flatten([
364 styles.avi,
365 {
366 borderRadius:
367 type === 'circle-avi' ? '50%' : type === 'rect-avi' ? '10%' : 0,
368 },
369 zoomInWhenReady,
370 ])}
371 alt={alt}
372 onLoad={handleLoad}
373 />
374 )
375 break
376 case 'image':
377 image = (
378 <Image
379 source={{uri: source}}
380 alt={alt}
381 style={[a.w_full, a.h_full, zoomInWhenReady]}
382 onLoad={handleLoad}
383 contentFit="contain"
384 accessibilityIgnoresInvertColors
385 />
386 )
387 break
388 }
389
390 return (
391 <>
392 {image}
393 {!hasLoaded && (
394 <View
395 style={[
396 a.absolute,
397 a.inset_0,
398 a.justify_center,
399 a.align_center,
400 a.fade_in,
401 {
402 opacity: 0,
403 animationDuration: '500ms',
404 animationDelay: '1s',
405 animationFillMode: 'both',
406 },
407 ]}>
408 <Loader size="xl" />
409 </View>
410 )}
411 </>
412 )
413}
414
415const styles = StyleSheet.create({
416 avi: {
417 // @ts-ignore web-only
418 maxWidth: `calc(min(400px, 100vw))`,
419 // @ts-ignore web-only
420 maxHeight: `calc(min(400px, 100vh))`,
421 padding: 16,
422 boxSizing: 'border-box',
423 },
424 menuBtn: {
425 top: 20,
426 left: 20,
427 },
428 closeBtn: {
429 top: 20,
430 right: 20,
431 },
432 leftBtn: {
433 left: 20,
434 right: 'auto',
435 top: '50%',
436 },
437 rightBtn: {
438 right: 20,
439 left: 'auto',
440 top: '50%',
441 },
442 blurredBackdrop: {
443 backgroundColor: '#00000077',
444 // @ts-expect-error web only -sfn
445 backdropFilter: 'blur(10px)',
446 },
447 blurredBackdropHover: {
448 backgroundColor: '#00000088',
449 },
450})