Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
at main 394 lines 11 kB view raw
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})