Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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