Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
120
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a876aae44ea07494ebea9727350aa060b81f317b 450 lines 12 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 {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})