Bluesky app fork with some witchin' additions 💫

Redo web lightbox, add animations and keyboard a11y (#9481)

* redo web lightbox, add animations

* add rect-avi to shell

* fix lingui msgs

authored by samuel.fm and committed by

GitHub d45a2c83 fd49349b

+257 -179
+3 -1
src/alf/atoms.ts
··· 5 5 import {native, platform, web} from '#/alf/util/platform' 6 6 import * as Layout from '#/components/Layout' 7 7 8 + const EXP_CURVE = 'cubic-bezier(0.16, 1, 0.3, 1)' 9 + 8 10 export const atoms = { 9 11 ...baseAtoms, 10 12 ··· 103 105 }), 104 106 // special composite animation for dialogs 105 107 zoom_fade_in: web({ 106 - animation: 'zoomIn ease-out 0.1s, fadeIn ease-out 0.1s', 108 + animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`, 107 109 }), 108 110 109 111 /**
+4
src/components/Dialog/index.tsx
··· 409 409 export function Close() { 410 410 return null 411 411 } 412 + 413 + export function Backdrop() { 414 + return null 415 + }
+4 -4
src/components/Dialog/index.web.tsx
··· 3 3 FlatList, 4 4 type FlatListProps, 5 5 type GestureResponderEvent, 6 + Pressable, 6 7 type StyleProp, 7 - TouchableWithoutFeedback, 8 8 View, 9 9 type ViewStyle, 10 10 } from 'react-native' ··· 113 113 <Portal> 114 114 <Context.Provider value={context}> 115 115 <RemoveScrollBar /> 116 - <TouchableWithoutFeedback 116 + <Pressable 117 117 accessibilityHint={undefined} 118 118 accessibilityLabel={_(msg`Close active dialog`)} 119 119 onPress={handleBackgroundPress}> ··· 146 146 {children} 147 147 </View> 148 148 </View> 149 - </TouchableWithoutFeedback> 149 + </Pressable> 150 150 </Context.Provider> 151 151 </Portal> 152 152 )} ··· 304 304 return null 305 305 } 306 306 307 - function Backdrop() { 307 + export function Backdrop() { 308 308 const t = useTheme() 309 309 const {reduceMotionEnabled} = useA11y() 310 310 return (
+4 -3
src/screens/Profile/Header/Shell.tsx
··· 78 78 ( 79 79 uri: string, 80 80 thumbRect: MeasuredDimensions | null, 81 - type: 'circle-avi' | 'image' = 'circle-avi', 81 + type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi', 82 82 ) => { 83 83 openLightbox({ 84 84 images: [ ··· 87 87 thumbUri: uri, 88 88 thumbRect, 89 89 dimensions: 90 - type === 'circle-avi' 90 + type === 'circle-avi' || type === 'rect-avi' 91 91 ? { 92 92 // It's fine if it's actually smaller but we know it's 1:1. 93 93 height: 1000, ··· 129 129 } else { 130 130 const modui = moderation.ui('avatar') 131 131 const avatar = profile.avatar 132 + const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi' 132 133 if (avatar && !(modui.blur && modui.noOverride)) { 133 134 runOnUI(() => { 134 135 'worklet' 135 136 const rect = measure(aviRef) 136 - runOnJS(_openLightbox)(avatar, rect) 137 + runOnJS(_openLightbox)(avatar, rect, type) 137 138 })() 138 139 } 139 140 }
+242 -171
src/view/com/lightbox/Lightbox.web.tsx
··· 1 - import React, {useCallback, useEffect, useState} from 'react' 2 - import { 3 - Image, 4 - type ImageStyle, 5 - Pressable, 6 - StyleSheet, 7 - TouchableOpacity, 8 - TouchableWithoutFeedback, 9 - View, 10 - type ViewStyle, 11 - } from 'react-native' 12 - import { 13 - FontAwesomeIcon, 14 - type FontAwesomeIconStyle, 15 - } from '@fortawesome/react-native-fontawesome' 1 + import {useCallback, useEffect, useState} from 'react' 2 + import {Pressable, StyleSheet, View} from 'react-native' 3 + import {Image} from 'expo-image' 16 4 import {msg} from '@lingui/macro' 17 5 import {useLingui} from '@lingui/react' 6 + import {FocusGuards, FocusScope} from 'radix-ui/internal' 18 7 import {RemoveScrollBar} from 'react-remove-scroll-bar' 19 8 20 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 21 - import {colors, s} from '#/lib/styles' 9 + import {useA11y} from '#/state/a11y' 22 10 import {useLightbox, useLightboxControls} from '#/state/lightbox' 23 - import {Text} from '../util/text/Text' 11 + import { 12 + atoms as a, 13 + flatten, 14 + ThemeProvider, 15 + useBreakpoints, 16 + useTheme, 17 + } from '#/alf' 18 + import {Button} from '#/components/Button' 19 + import {Backdrop} from '#/components/Dialog' 20 + import { 21 + ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon, 22 + ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon, 23 + } from '#/components/icons/Chevron' 24 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 25 + import {Loader} from '#/components/Loader' 26 + import {Text} from '#/components/Typography' 24 27 import {type ImageSource} from './ImageViewing/@types' 25 - import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' 26 28 27 29 export function Lightbox() { 28 30 const {activeLightbox} = useLightbox() ··· 36 38 const initialIndex = activeLightbox.index 37 39 const imgs = activeLightbox.images 38 40 return ( 39 - <> 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 + 54 + function 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 lightbox`)} 67 + onPress={handleBackgroundPress} 68 + style={[a.fixed, a.inset_0, a.z_10]}> 69 + <Backdrop /> 40 70 <RemoveScrollBar /> 41 - <LightboxInner 42 - imgs={imgs} 43 - initialIndex={initialIndex} 44 - onClose={closeLightbox} 45 - /> 46 - </> 71 + <FocusScope.FocusScope loop trapped asChild> 72 + <div style={{position: 'absolute', inset: 0}}>{children}</div> 73 + </FocusScope.FocusScope> 74 + </Pressable> 47 75 ) 48 76 } 49 77 50 - function LightboxInner({ 78 + function LightboxGallery({ 51 79 imgs, 52 80 initialIndex = 0, 53 81 onClose, ··· 56 84 initialIndex: number 57 85 onClose: () => void 58 86 }) { 87 + const t = useTheme() 59 88 const {_} = useLingui() 60 - const [index, setIndex] = useState<number>(initialIndex) 89 + const {reduceMotionEnabled} = useA11y() 90 + const [index, setIndex] = useState(initialIndex) 91 + const [hasAnyLoaded, setAnyHasLoaded] = useState(false) 61 92 const [isAltExpanded, setAltExpanded] = useState(false) 93 + 94 + const {gtPhone} = useBreakpoints() 62 95 63 96 const canGoLeft = index >= 1 64 97 const canGoRight = index < imgs.length - 1 ··· 92 125 return () => window.removeEventListener('keydown', onKeyDown) 93 126 }, [onKeyDown]) 94 127 95 - const {isTabletOrDesktop} = useWebMediaQueries() 96 - const btnStyle = React.useMemo(() => { 97 - return isTabletOrDesktop ? styles.btnTablet : styles.btnMobile 98 - }, [isTabletOrDesktop]) 99 - const iconSize = React.useMemo(() => { 100 - return isTabletOrDesktop ? 32 : 24 101 - }, [isTabletOrDesktop]) 128 + const delayedFadeInAnim = !reduceMotionEnabled && [ 129 + a.fade_in, 130 + {animationDelay: '0.2s', animationFillMode: 'both'}, 131 + ] 102 132 103 133 const img = imgs[index] 104 - const isAvi = img.type === 'circle-avi' || img.type === 'rect-avi' 134 + 105 135 return ( 106 - <View style={styles.mask}> 107 - <TouchableWithoutFeedback 108 - onPress={onClose} 109 - accessibilityRole="button" 110 - accessibilityLabel={_(msg`Close image viewer`)} 111 - accessibilityHint={_(msg`Exits image view`)} 112 - onAccessibilityEscape={onClose}> 113 - {isAvi ? ( 114 - <View style={styles.aviCenterer}> 115 - <img 116 - src={img.uri} 117 - // @ts-ignore web-only 118 - style={ 119 - { 120 - ...styles.avi, 121 - borderRadius: 122 - img.type === 'circle-avi' 123 - ? '50%' 124 - : img.type === 'rect-avi' 125 - ? '10%' 126 - : 0, 127 - } as ImageStyle 128 - } 129 - alt={img.alt} 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}} 130 164 /> 131 - </View> 132 - ) : ( 133 - <View style={styles.imageCenterer}> 134 - <Image 135 - accessibilityIgnoresInvertColors 136 - source={img} 137 - style={styles.image as ImageStyle} 138 - accessibilityLabel={img.alt} 139 - accessibilityHint="" 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}} 140 185 /> 141 - {canGoLeft && ( 142 - <TouchableOpacity 143 - onPress={onPressLeft} 144 - style={[ 145 - styles.btn, 146 - btnStyle, 147 - styles.leftBtn, 148 - styles.blurredBackground, 149 - ]} 150 - accessibilityRole="button" 151 - accessibilityLabel={_(msg`Previous image`)} 152 - accessibilityHint=""> 153 - <FontAwesomeIcon 154 - icon="angle-left" 155 - style={styles.icon as FontAwesomeIconStyle} 156 - size={iconSize} 157 - /> 158 - </TouchableOpacity> 159 - )} 160 - {canGoRight && ( 161 - <TouchableOpacity 162 - onPress={onPressRight} 163 - style={[ 164 - styles.btn, 165 - btnStyle, 166 - styles.rightBtn, 167 - styles.blurredBackground, 168 - ]} 169 - accessibilityRole="button" 170 - accessibilityLabel={_(msg`Next image`)} 171 - accessibilityHint=""> 172 - <FontAwesomeIcon 173 - icon="angle-right" 174 - style={styles.icon as FontAwesomeIconStyle} 175 - size={iconSize} 176 - /> 177 - </TouchableOpacity> 178 - )} 179 - </View> 186 + </Button> 180 187 )} 181 - </TouchableWithoutFeedback> 188 + </View> 182 189 {img.alt ? ( 183 - <View style={styles.footer}> 190 + <View style={[a.px_4xl, a.py_2xl, t.atoms.bg, delayedFadeInAnim]}> 184 191 <Pressable 185 192 accessibilityLabel={_(msg`Expand alt text`)} 186 193 accessibilityHint={_( ··· 190 197 setAltExpanded(!isAltExpanded) 191 198 }}> 192 199 <Text 193 - style={s.white} 200 + style={[a.text_md, a.leading_snug]} 194 201 numberOfLines={isAltExpanded ? 0 : 3} 195 202 ellipsizeMode="tail"> 196 203 {img.alt} ··· 198 205 </Pressable> 199 206 </View> 200 207 ) : null} 201 - <View style={styles.closeBtn}> 202 - <ImageDefaultHeader onRequestClose={onClose} /> 203 - </View> 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 lightbox`)} 220 + shape="round" 221 + size={gtPhone ? 'large' : 'small'}> 222 + <XIcon size={gtPhone ? 'md' : 'sm'} style={{color: t.palette.white}} /> 223 + </Button> 204 224 </View> 205 225 ) 206 226 } 207 227 228 + function 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 + 208 326 const styles = StyleSheet.create({ 209 - mask: { 210 - // @ts-ignore 211 - position: 'fixed', 212 - top: 0, 213 - left: 0, 214 - width: '100%', 215 - height: '100%', 216 - backgroundColor: '#000c', 217 - }, 218 - imageCenterer: { 219 - flex: 1, 220 - alignItems: 'center', 221 - justifyContent: 'center', 222 - }, 223 - image: { 224 - width: '100%', 225 - height: '100%', 226 - resizeMode: 'contain', 227 - }, 228 - aviCenterer: { 229 - flex: 1, 230 - alignItems: 'center', 231 - justifyContent: 'center', 232 - }, 233 327 avi: { 234 328 // @ts-ignore web-only 235 329 maxWidth: `calc(min(400px, 100vw))`, ··· 238 332 padding: 16, 239 333 boxSizing: 'border-box', 240 334 }, 241 - icon: { 242 - color: colors.white, 243 - }, 244 335 closeBtn: { 245 - position: 'absolute', 246 - top: 10, 247 - right: 10, 248 - }, 249 - btn: { 250 - position: 'absolute', 251 - backgroundColor: '#00000077', 252 - justifyContent: 'center', 253 - alignItems: 'center', 254 - }, 255 - btnTablet: { 256 - width: 50, 257 - height: 50, 258 - borderRadius: 25, 259 - left: 30, 260 - right: 30, 261 - }, 262 - btnMobile: { 263 - width: 44, 264 - height: 44, 265 - borderRadius: 22, 266 - left: 20, 336 + top: 20, 267 337 right: 20, 268 338 }, 269 339 leftBtn: { 340 + left: 20, 270 341 right: 'auto', 271 342 top: '50%', 272 343 }, 273 344 rightBtn: { 345 + right: 20, 274 346 left: 'auto', 275 347 top: '50%', 276 348 }, 277 - footer: { 278 - paddingHorizontal: 32, 279 - paddingVertical: 24, 280 - backgroundColor: colors.black, 281 - }, 282 - blurredBackground: { 349 + blurredBackdrop: { 350 + backgroundColor: '#00000077', 351 + // @ts-expect-error web only -sfn 283 352 backdropFilter: 'blur(10px)', 284 - WebkitBackdropFilter: 'blur(10px)', 285 - } as ViewStyle, 353 + }, 354 + blurredBackdropHover: { 355 + backgroundColor: '#00000088', 356 + }, 286 357 })