Files
bsky-social-app/src/components/Lightbox/Lightbox.web.tsx
T
2026-09-07 13:57:57 +02:00

497 lines
14 KiB
TypeScript

import {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
import {Image} from 'expo-image'
import {Trans, useLingui} from '@lingui/react/macro'
import * as FocusGuards from '@radix-ui/react-focus-guards'
import * as FocusScope from '@radix-ui/react-focus-scope'
import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {saveImageToMediaLibrary} from '#/lib/media/manip'
import {useA11y} from '#/state/a11y'
import {
atoms as a,
flatten,
ThemeProvider,
useBreakpoints,
useTheme,
} from '#/alf'
import {Button} from '#/components/Button'
import {Backdrop} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
import {
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon,
ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
} from '#/components/icons/Chevron'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {CircleChromeButton} from '#/components/Lightbox/chrome/CircleChromeButton'
import {PagerDots} from '#/components/Lightbox/chrome/PagerDots'
import {useLightbox, useLightboxControls} from '#/components/Lightbox/state'
import {type ImageSource} from '#/components/Lightbox/types'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
export function Lightbox() {
const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
const isActive = !!activeLightbox
if (!isActive) {
return null
}
const initialIndex = activeLightbox.index
const imgs = activeLightbox.images
return (
<ThemeProvider theme="dark">
<LightboxContainer handleBackgroundPress={closeLightbox}>
<LightboxGallery
key={activeLightbox.id}
imgs={imgs}
initialIndex={initialIndex}
onClose={closeLightbox}
/>
</LightboxContainer>
</ThemeProvider>
)
}
function LightboxContainer({
children,
handleBackgroundPress,
}: {
children: React.ReactNode
handleBackgroundPress: () => void
}) {
const {t: l} = useLingui()
FocusGuards.useFocusGuards()
return (
<Pressable
accessibilityHint={undefined}
accessibilityLabel={l`Close image viewer`}
onPress={handleBackgroundPress}
style={[a.fixed, a.inset_0, a.z_10]}>
<Backdrop />
<RemoveScrollBar />
<FocusScope.FocusScope loop trapped asChild>
<div
role="dialog"
aria-modal="true"
aria-label={l`Image viewer`}
style={{position: 'absolute', inset: 0}}>
{children}
</div>
</FocusScope.FocusScope>
</Pressable>
)
}
function LightboxGallery({
imgs,
initialIndex = 0,
onClose,
}: {
imgs: ImageSource[]
initialIndex: number
onClose: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const {reduceMotionEnabled} = useA11y()
const [index, setIndex] = useState(initialIndex)
const [hasAnyLoaded, setAnyHasLoaded] = useState(false)
const [isAltExpanded, setAltExpanded] = useState(false)
const {gtPhone} = useBreakpoints()
const canGoLeft = index >= 1
const canGoRight = index < imgs.length - 1
const onPressLeft = useCallback(() => {
if (canGoLeft) {
setIndex(index - 1)
}
}, [index, canGoLeft])
const onPressRight = useCallback(() => {
if (canGoRight) {
setIndex(index + 1)
}
}, [index, canGoRight])
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
} else if (e.key === 'ArrowLeft') {
onPressLeft()
} else if (e.key === 'ArrowRight') {
onPressRight()
}
},
[onClose, onPressLeft, onPressRight],
)
useEffect(() => {
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onKeyDown])
// Push a history entry so the browser back button closes the lightbox
// instead of navigating away from the page.
const closedByPopStateRef = useRef(false)
useEffect(() => {
history.pushState({lightbox: true}, '')
const handlePopState = () => {
closedByPopStateRef.current = true
onClose()
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
// Only pop our entry if it's still the current one. If navigation
// already pushed a new entry on top, leave the orphaned entry —
// it shares the same URL so traversing through it is harmless.
if (
!closedByPopStateRef.current &&
(history.state as {lightbox?: boolean})?.lightbox
) {
history.back()
}
}
}, [onClose])
const delayedFadeInAnim = !reduceMotionEnabled && [
a.fade_in,
{animationDelay: '0.2s', animationFillMode: 'both'},
]
const img = imgs[index]
return (
<View style={[a.absolute, a.inset_0]}>
<View style={[a.flex_1, a.justify_center, a.align_center]}>
<LightboxGalleryItem
key={index}
source={img.uri}
alt={img.alt}
type={img.type}
hasAnyLoaded={hasAnyLoaded}
onLoad={() => setAnyHasLoaded(true)}
/>
{canGoLeft && (
<Button
onPress={onPressLeft}
style={[
a.absolute,
styles.leftBtn,
styles.blurredBackdrop,
a.transition_color,
delayedFadeInAnim,
]}
hoverStyle={styles.blurredBackdropHover}
color="secondary"
label={l`Previous image`}
shape="round"
size={gtPhone ? 'large' : 'small'}>
<ChevronLeftIcon
size={gtPhone ? 'md' : 'sm'}
style={{color: t.palette.white}}
/>
</Button>
)}
{canGoRight && (
<Button
onPress={onPressRight}
style={[
a.absolute,
styles.rightBtn,
styles.blurredBackdrop,
a.transition_color,
delayedFadeInAnim,
]}
hoverStyle={styles.blurredBackdropHover}
color="secondary"
label={l`Next image`}
shape="round"
size={gtPhone ? 'large' : 'small'}>
<ChevronRightIcon
size={gtPhone ? 'md' : 'sm'}
style={{color: t.palette.white}}
/>
</Button>
)}
</View>
{img.alt ? (
<ScrollView
// Cap the overlay height so long alt text scrolls within the overlay
// instead of growing past the top of the screen and pushing the image
// out of view. Only scrollable once expanded.
style={[
styles.altScroll,
{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
// @ts-expect-error web only
backdropFilter: 'blur(16px)',
},
delayedFadeInAnim,
]}
scrollEnabled={isAltExpanded}
contentContainerStyle={[a.px_4xl, a.py_2xl]}>
<Pressable
accessibilityLabel={l`Expand alt text`}
accessibilityHint={l`If alt text is long, toggles alt text expanded state`}
onPress={() => {
setAltExpanded(!isAltExpanded)
}}>
<Text
style={[a.text_md, a.leading_snug, {color: '#fff'}]}
numberOfLines={isAltExpanded ? 0 : 3}
ellipsizeMode="tail">
{img.alt}
</Text>
</Pressable>
</ScrollView>
) : null}
{imgs.length > 1 && (
<div aria-live="polite" aria-atomic="true" style={a.sr_only}>
<Text>{l`Image ${index + 1} of ${imgs.length}`}</Text>
</div>
)}
<Menu.Root>
<Menu.Trigger label={l`Image options`}>
{({props}) => (
<View style={[a.absolute, styles.menuBtn, delayedFadeInAnim]}>
<CircleChromeButton
{...props}
accessible={false}
icon={EllipsisIcon}
iconStyle={{transform: [{rotate: '90deg'}]}}
label={l`Image options`}
/>
</View>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={l`Share image`}
onPress={async () => {
const url = img.uri
if (
typeof navigator !== 'undefined' &&
'share' in navigator &&
navigator.share
) {
try {
await navigator.share({url})
} catch {
// User cancelled or share failed; no-op
}
} else if (
typeof navigator !== 'undefined' &&
navigator.clipboard
) {
try {
await navigator.clipboard.writeText(url)
Toast.show(l`Link copied to clipboard`)
} catch {
Toast.show(l`Failed to copy link`, {type: 'error'})
}
}
}}>
<Menu.ItemText>
<Trans>Share image</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ShareIcon} position="right" />
</Menu.Item>
<Menu.Item
label={l`Download image`}
onPress={() => {
saveImageToMediaLibrary({uri: img.uri}).then(
() => {
Toast.show(l`Image saved`)
},
() => {
Toast.show(l`Failed to save image`, {type: 'error'})
},
)
}}>
<Menu.ItemText>
<Trans>Download image</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={DownloadIcon} position="right" />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<View style={[a.absolute, styles.closeBtn, delayedFadeInAnim]}>
<CircleChromeButton
icon={XIcon}
label={l`Close image viewer`}
onPress={onClose}
/>
</View>
{imgs.length > 1 && (
<View
style={[a.absolute, styles.pagerDots, delayedFadeInAnim]}
pointerEvents="none">
<PagerDots count={imgs.length} activeIndex={index} />
</View>
)}
</View>
)
}
function LightboxGalleryItem({
source,
alt,
type,
onLoad,
hasAnyLoaded,
}: {
source: string
alt: string | undefined
type: ImageSource['type']
onLoad: () => void
hasAnyLoaded: boolean
}) {
const {reduceMotionEnabled} = useA11y()
const [hasLoaded, setHasLoaded] = useState(false)
const [isFirstToLoad] = useState(!hasAnyLoaded)
/**
* We want to show a zoom/fade in animation when the lightbox first opens.
* To avoid showing it as we switch between images, we keep track in the parent
* whether any image has loaded yet. We then save what the value of this is on first
* render (as when it changes, we don't want to then *remove* then animation). when
* the image loads, if this is the first image to load, we play the animation.
*
* We also use this `hasLoaded` state to show a loading indicator. This is on a 1s
* delay and then a slow fade in to avoid flicker. -sfn
*/
const zoomInWhenReady =
!reduceMotionEnabled &&
isFirstToLoad &&
(hasAnyLoaded
? [a.zoom_fade_in, {animationDuration: '0.5s'}]
: {opacity: 0})
const handleLoad = () => {
setHasLoaded(true)
onLoad()
}
let image = null
switch (type) {
case 'circle-avi':
case 'rect-avi':
image = (
<img
src={source}
style={flatten([
styles.avi,
{
borderRadius:
type === 'circle-avi' ? '50%' : type === 'rect-avi' ? '10%' : 0,
},
zoomInWhenReady,
])}
alt={alt}
onLoad={handleLoad}
/>
)
break
case 'image':
image = (
<Image
source={{uri: source}}
alt={alt}
style={[a.w_full, a.h_full, zoomInWhenReady]}
onLoad={handleLoad}
contentFit="contain"
accessibilityIgnoresInvertColors
/>
)
break
}
return (
<>
{image}
{!hasLoaded && (
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.align_center,
a.fade_in,
{
opacity: 0,
animationDuration: '500ms',
animationDelay: '1s',
animationFillMode: 'both',
},
]}>
<Loader size="xl" />
</View>
)}
</>
)
}
const styles = StyleSheet.create({
avi: {
maxWidth: `calc(min(400px, 100vw))`,
maxHeight: `calc(min(400px, 100vh))`,
padding: 16,
boxSizing: 'border-box',
},
altScroll: {
// Size to content like the View it replaced, rather than filling the
// column via ScrollView's default flexGrow.
flexGrow: 0,
flexShrink: 0,
maxHeight: '50vh',
},
menuBtn: {
top: 20,
left: 20,
},
closeBtn: {
top: 20,
right: 20,
},
pagerDots: {
top: 20,
left: 0,
right: 0,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
leftBtn: {
left: 20,
right: 'auto',
top: '50%',
},
rightBtn: {
right: 20,
left: 'auto',
top: '50%',
},
blurredBackdrop: {
backgroundColor: '#00000077',
// @ts-expect-error web only -sfn
backdropFilter: 'blur(10px)',
},
blurredBackdropHover: {
backgroundColor: '#00000088',
},
})