Refresh lightbox designs (#10330)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Spence Pope
2026-05-05 12:25:29 -04:00
committed by GitHub
parent 48ea9e0b96
commit a7fabd9e0d
28 changed files with 653 additions and 263 deletions
+3
View File
@@ -127,3 +127,6 @@ bskyweb/static/media/*.webp
bskyweb/static/media/*.jpg
bskyweb/static/media/*.png
bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
+1 -1
View File
@@ -31,7 +31,6 @@ import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
@@ -62,6 +61,7 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
+1 -1
View File
@@ -24,7 +24,6 @@ import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
@@ -54,6 +53,7 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
@@ -2,8 +2,8 @@ import {useCallback} from 'react'
import {shareImageModal} from '#/lib/media/manip'
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
import {useLightbox, useLightboxControls} from '#/state/lightbox'
import ImageView from './ImageViewing'
import ImageView from '#/components/Lightbox/pager/ImagePager'
import {useLightbox, useLightboxControls} from '#/components/Lightbox/state'
export function Lightbox() {
const {activeLightbox} = useLightbox()
@@ -9,7 +9,6 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {saveImageToMediaLibrary} from '#/lib/media/manip'
import {useA11y} from '#/state/a11y'
import {useLightbox, useLightboxControls} from '#/state/lightbox'
import {
atoms as a,
flatten,
@@ -19,6 +18,7 @@ import {
} 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,
@@ -26,11 +26,14 @@ import {
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'
import {type ImageSource} from './ImageViewing/@types'
export function Lightbox() {
const {activeLightbox} = useLightbox()
@@ -225,7 +228,13 @@ function LightboxGallery({
)}
</View>
{img.alt ? (
<View style={[a.px_4xl, a.py_2xl, t.atoms.bg, delayedFadeInAnim]}>
<View
style={[
a.px_4xl,
a.py_2xl,
{backgroundColor: 'rgba(0, 0, 0, 0.45)'},
delayedFadeInAnim,
]}>
<Pressable
accessibilityLabel={_(msg`Expand alt text`)}
accessibilityHint={_(
@@ -235,7 +244,7 @@ function LightboxGallery({
setAltExpanded(!isAltExpanded)
}}>
<Text
style={[a.text_md, a.leading_snug]}
style={[a.text_md, a.leading_snug, {color: '#fff'}]}
numberOfLines={isAltExpanded ? 0 : 3}
ellipsizeMode="tail">
{img.alt}
@@ -251,29 +260,51 @@ function LightboxGallery({
<Menu.Root>
<Menu.Trigger label={_(msg`Image options`)}>
{({props}) => (
<Button
<Pressable
{...props}
style={[
a.absolute,
styles.menuBtn,
styles.blurredBackdrop,
a.transition_color,
delayedFadeInAnim,
]}
hoverStyle={styles.blurredBackdropHover}
color="secondary"
label={_(msg`Image options`)}
shape="round"
size={gtPhone ? 'large' : 'small'}>
<EllipsisIcon
size={gtPhone ? 'md' : 'sm'}
style={{color: t.palette.white}}
accessible={false}
style={[a.absolute, styles.menuBtn, delayedFadeInAnim]}>
<CircleChromeButton
icon={EllipsisIcon}
iconStyle={{transform: [{rotate: '90deg'}]}}
label={_(msg`Image options`)}
/>
</Button>
</Pressable>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`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(_(msg`Link copied to clipboard`))
} catch {
Toast.show(_(msg`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={_(msg`Download image`)}
onPress={() => {
@@ -294,22 +325,20 @@ function LightboxGallery({
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Button
onPress={onClose}
style={[
a.absolute,
styles.closeBtn,
styles.blurredBackdrop,
a.transition_color,
delayedFadeInAnim,
]}
hoverStyle={styles.blurredBackdropHover}
color="secondary"
label={_(msg`Close image viewer`)}
shape="round"
size={gtPhone ? 'large' : 'small'}>
<XIcon size={gtPhone ? 'md' : 'sm'} style={{color: t.palette.white}} />
</Button>
<View style={[a.absolute, styles.closeBtn, delayedFadeInAnim]}>
<CircleChromeButton
icon={XIcon}
label={_(msg`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>
)
}
@@ -429,6 +458,14 @@ const styles = StyleSheet.create({
top: 20,
right: 20,
},
pagerDots: {
top: 20,
left: 0,
right: 0,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
leftBtn: {
left: 20,
right: 'auto',
@@ -0,0 +1,65 @@
import {type ComponentType} from 'react'
import {
Pressable,
type PressableProps,
type StyleProp,
StyleSheet,
type TextStyle,
} from 'react-native'
import {BlurView} from 'expo-blur'
import {HITSLOP_10} from '#/lib/constants'
import {type Props as IconProps} from '#/components/icons/common'
type Props = {
icon: ComponentType<IconProps>
iconStyle?: StyleProp<TextStyle>
label: string
onPress?: PressableProps['onPress']
testID?: string
}
const SIZE = 44
const RADIUS = 24
const ICON = 24
export function CircleChromeButton({
icon: Icon,
iconStyle,
label,
onPress,
testID,
}: Props) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint=""
hitSlop={HITSLOP_10}
onPress={onPress}
testID={testID}
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
<BlurView intensity={20} tint="dark" style={styles.inner}>
<Icon width={ICON} fill="#fff" style={iconStyle} />
</BlurView>
</Pressable>
)
}
const styles = StyleSheet.create({
root: {
width: SIZE,
height: SIZE,
borderRadius: RADIUS,
overflow: 'hidden',
},
inner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
pressed: {
opacity: 0.85,
},
})
@@ -0,0 +1,68 @@
import {type ComponentType} from 'react'
import {
Pressable,
type PressableProps,
type StyleProp,
StyleSheet,
type TextStyle,
View,
} from 'react-native'
import {HITSLOP_10} from '#/lib/constants'
import {type Props as IconProps} from '#/components/icons/common'
type Props = {
icon: ComponentType<IconProps>
iconStyle?: StyleProp<TextStyle>
label: string
onPress?: PressableProps['onPress']
testID?: string
}
const SIZE = 44
const RADIUS = 24
const ICON = 24
export function CircleChromeButton({
icon: Icon,
iconStyle,
label,
onPress,
testID,
}: Props) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint=""
hitSlop={HITSLOP_10}
onPress={onPress}
testID={testID}
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
<View style={styles.inner}>
<Icon width={ICON} fill="#fff" style={iconStyle} />
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
root: {
width: SIZE,
height: SIZE,
borderRadius: RADIUS,
overflow: 'hidden',
},
inner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
// @ts-expect-error web-only
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
},
pressed: {
opacity: 0.85,
},
})
+84
View File
@@ -0,0 +1,84 @@
import {useRef} from 'react'
import {
LayoutAnimation,
Pressable,
ScrollView,
StyleSheet,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
type Props = {
altText: string | undefined
isAltExpanded: boolean
onToggleAltExpanded: () => void
}
export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
const {_} = useLingui()
const insets = useSafeAreaInsets()
const isMomentumScrolling = useRef(false)
if (!altText) return null
return (
<View
style={[styles.root, {paddingBottom: insets.bottom + 8}]}
pointerEvents="box-none">
<View style={[a.mx_md, styles.altWrap]}>
<ScrollView
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={[a.px_md, a.py_sm]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Expand alt text`)}
accessibilityHint=""
onPress={() => {
if (isMomentumScrolling.current) return
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
onToggleAltExpanded()
}}>
<Text
emoji
selectable
style={[a.text_sm, styles.altText]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</Pressable>
</ScrollView>
</View>
</View>
)
}
const styles = StyleSheet.create({
root: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
altWrap: {
backgroundColor: 'rgba(0, 0, 0, 0.45)',
borderRadius: 12,
overflow: 'hidden',
},
altText: {
color: '#fff',
},
})
+59
View File
@@ -0,0 +1,59 @@
import {StyleSheet, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
import {CircleChromeButton} from './CircleChromeButton'
import {ImageMenu} from './ImageMenu'
import {PagerDots} from './PagerDots'
type Props = {
onRequestClose: () => void
onPressShare: () => void
onPressSave: () => void
imageCount: number
activeIndex: number
}
export function Header({
onRequestClose,
onPressShare,
onPressSave,
imageCount,
activeIndex,
}: Props) {
const {_} = useLingui()
const insets = useSafeAreaInsets()
return (
<View
style={[
styles.root,
a.flex_row,
a.justify_between,
a.align_center,
a.px_md,
{paddingTop: insets.top + 8},
]}
pointerEvents="box-none">
<ImageMenu onPressShare={onPressShare} onPressSave={onPressSave} />
<PagerDots count={imageCount} activeIndex={activeIndex} />
<CircleChromeButton
icon={CloseIcon}
label={_(msg`Close image`)}
onPress={onRequestClose}
/>
</View>
)
}
const styles = StyleSheet.create({
root: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
})
@@ -0,0 +1,195 @@
import {useRef, useState} from 'react'
import {Modal, Pressable, StyleSheet, View} from 'react-native'
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {type Props as IconProps} from '#/components/icons/common'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {Text} from '#/components/Typography'
import {CircleChromeButton} from './CircleChromeButton'
type Props = {
onPressShare: () => void
onPressSave: () => void
}
type Anchor = {x: number; y: number; width: number; height: number}
const MENU_WIDTH = 160
const GAP = 6
const CARD_BG = '#000000'
const CARD_BORDER = '#232e3e'
const ITEM_TEXT = '#f9fafb'
const SPRING_IN = {damping: 18, mass: 0.6, stiffness: 240}
const TIMING_OUT = {duration: 150}
export function ImageMenu({onPressShare, onPressSave}: Props) {
const {_} = useLingui()
const triggerRef = useRef<View>(null)
const [isMounted, setIsMounted] = useState(false)
const [anchor, setAnchor] = useState<Anchor | null>(null)
const progress = useSharedValue(0)
const open = () => {
triggerRef.current?.measureInWindow((x, y, width, height) => {
setAnchor({x, y, width, height})
setIsMounted(true)
progress.set(withSpring(1, SPRING_IN))
})
}
const close = () => {
progress.set(
withTiming(0, TIMING_OUT, finished => {
if (finished) {
runOnJS(setIsMounted)(false)
}
}),
)
}
const runAction = (action: () => void) => {
close()
action()
}
return (
<>
<View ref={triggerRef} collapsable={false}>
<CircleChromeButton
icon={DotsIcon}
iconStyle={{transform: [{rotate: '90deg'}]}}
label={_(msg`Image options`)}
onPress={open}
/>
</View>
<Modal
transparent
visible={isMounted}
animationType="none"
onRequestClose={close}
statusBarTranslucent>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Close menu`)}
accessibilityHint=""
style={StyleSheet.absoluteFill}
onPress={close}
/>
{anchor && (
<MenuCard anchor={anchor} progress={progress}>
<MenuItem
icon={ShareIcon}
label={_(msg`Share image`)}
onPress={() => runAction(onPressShare)}
/>
<MenuItem
icon={DownloadIcon}
label={_(msg`Save image`)}
onPress={() => runAction(onPressSave)}
/>
</MenuCard>
)}
</Modal>
</>
)
}
function MenuCard({
anchor,
progress,
children,
}: {
anchor: Anchor
progress: ReturnType<typeof useSharedValue<number>>
children: React.ReactNode
}) {
const animatedStyle = useAnimatedStyle(() => ({
opacity: progress.get(),
transform: [{scale: interpolate(progress.get(), [0, 1], [0.9, 1])}],
}))
return (
<Animated.View
style={[
a.absolute,
styles.card,
{
top: anchor.y + anchor.height + GAP,
left: anchor.x,
transformOrigin: 'top left',
},
animatedStyle,
]}>
{children}
</Animated.View>
)
}
function MenuItem({
icon: Icon,
label,
onPress,
}: {
icon: React.ComponentType<IconProps>
label: string
onPress: () => void
}) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint=""
onPress={onPress}
style={({pressed}) => [styles.item, pressed && styles.itemPressed]}>
<Icon width={18} fill={ITEM_TEXT} />
<Text style={styles.itemText}>{label}</Text>
</Pressable>
)
}
const styles = StyleSheet.create({
card: {
width: MENU_WIDTH,
padding: 8,
borderRadius: 16,
borderWidth: 0.5,
borderColor: CARD_BORDER,
backgroundColor: CARD_BG,
shadowColor: '#000',
shadowOffset: {width: 0, height: 4},
shadowOpacity: 0.16,
shadowRadius: 20,
elevation: 8,
},
item: {
height: 44,
paddingHorizontal: 12,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
itemPressed: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
itemText: {
flex: 1,
fontSize: 15,
fontWeight: '500',
lineHeight: 19.5,
color: ITEM_TEXT,
},
})
@@ -0,0 +1,54 @@
import {StyleSheet, View} from 'react-native'
import {atoms as a} from '#/alf'
type Props = {
count: number
activeIndex: number
}
const ACTIVE = 6
const INACTIVE = 4
const GAP = 5
export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null
return (
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</View>
)
}
const styles = StyleSheet.create({
row: {
gap: GAP,
},
activeDot: {
width: ACTIVE,
height: ACTIVE,
borderRadius: ACTIVE / 2,
},
inactiveDot: {
width: INACTIVE,
height: INACTIVE,
borderRadius: INACTIVE / 2,
},
active: {
backgroundColor: '#fff',
},
inactive: {
backgroundColor: 'rgba(255, 255, 255, 0.4)',
},
})
+1
View File
@@ -0,0 +1 @@
export {Lightbox} from './Lightbox'
@@ -6,6 +6,7 @@ import {
type PanGesture,
} from 'react-native-gesture-handler'
import Animated, {
type AnimatableValue,
runOnJS,
type SharedValue,
useAnimatedReaction,
@@ -20,7 +21,7 @@ import {
type Dimensions as ImageDimensions,
type ImageSource,
type LightboxTransforms,
} from '../../@types'
} from '../../types'
import {
applyRounding,
createTransform,
@@ -29,7 +30,7 @@ import {
prependTransform,
readTransform,
type TransformMatrix,
} from '../../transforms'
} from '../transforms'
const MIN_SCREEN_ZOOM = 2
const MAX_ORIGINAL_IMAGE_ZOOM = 2
@@ -460,7 +461,7 @@ function clampTranslation(
return clampedValue
}
function withClampedSpring(value: any) {
function withClampedSpring<T extends AnimatableValue>(value: T): T {
'worklet'
return withSpring(value, {overshootClamping: true})
}
@@ -30,7 +30,7 @@ import {
type Dimensions as ImageDimensions,
type ImageSource,
type LightboxTransforms,
} from '../../@types'
} from '../../types'
const MAX_ORIGINAL_IMAGE_ZOOM = 2
const MIN_SCREEN_ZOOM = 2
@@ -10,7 +10,7 @@ import {
type Dimensions as ImageDimensions,
type ImageSource,
type LightboxTransforms,
} from '../../@types'
} from '../../types'
type Props = {
imageSrc: ImageSource
@@ -7,18 +7,13 @@
*/
// Original code copied and simplified from the link below as the codebase is currently not maintained:
// https://github.com/jobtoday/react-native-image-viewing
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {
LayoutAnimation,
PixelRatio,
StyleSheet,
useWindowDimensions,
View,
} from 'react-native'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {PixelRatio, StyleSheet, useWindowDimensions, View} from 'react-native'
import {SystemBars} from 'react-native-edge-to-edge'
import {Gesture} from 'react-native-gesture-handler'
import PagerView from 'react-native-pager-view'
import Animated, {
type AnimatableValue,
type AnimatedRef,
cancelAnimation,
interpolate,
@@ -37,28 +32,22 @@ import Animated, {
withSpring,
type WithSpringConfig,
} from 'react-native-reanimated'
import {SafeAreaView} from 'react-native-safe-area-context'
import * as ScreenOrientation from 'expo-screen-orientation'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types'
import {colors, s} from '#/lib/styles'
import {type Lightbox} from '#/state/lightbox'
import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from '#/view/com/util/Views'
import {useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {type Lightbox} from '#/components/Lightbox/state'
import {IS_IOS} from '#/env'
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
import {Footer} from '../chrome/Footer'
import {Header} from '../chrome/Header'
import {
type ImageSource,
type LightboxTransforms,
type Transform,
} from './@types'
import ImageDefaultHeader from './components/ImageDefaultHeader'
import ImageItem from './components/ImageItem/ImageItem'
} from '../types'
import ImageItem from './ImageItem/ImageItem'
type Rect = {x: number; y: number; width: number; height: number}
@@ -393,7 +382,7 @@ function ImageView({
overdrag={true}
style={styles.pager}>
{images.map((imageSrc, i) => (
<View key={imageSrc.uri}>
<View key={`${i}-${imageSrc.uri}`}>
<LightboxImage
onTap={onTap}
onZoom={onZoom}
@@ -413,22 +402,27 @@ function ImageView({
</View>
))}
</PagerView>
<View style={styles.controls}>
<View style={styles.controls} pointerEvents="box-none">
<Animated.View
style={animatedHeaderStyle}
pointerEvents="box-none"
renderToHardwareTextureAndroid>
<ImageDefaultHeader onRequestClose={handleRequestClose} />
<Header
onRequestClose={handleRequestClose}
onPressShare={() => onPressShare(images[imageIndex].uri)}
onPressSave={() => onPressSave(images[imageIndex].uri)}
imageCount={images.length}
activeIndex={imageIndex}
/>
</Animated.View>
<Animated.View
style={animatedFooterStyle}
pointerEvents="box-none"
renderToHardwareTextureAndroid={!isAltExpanded}>
<LightboxFooter
images={images}
index={imageIndex}
<Footer
altText={images[imageIndex].alt}
isAltExpanded={isAltExpanded}
toggleAltExpanded={() => setIsAltExpanded(e => !e)}
onPressSave={onPressSave}
onPressShare={onPressShare}
onToggleAltExpanded={() => setIsAltExpanded(e => !e)}
/>
</Animated.View>
</View>
@@ -607,85 +601,6 @@ function LightboxImage({
)
}
function LightboxFooter({
images,
index,
isAltExpanded,
toggleAltExpanded,
onPressSave,
onPressShare,
}: {
images: ImageSource[]
index: number
isAltExpanded: boolean
toggleAltExpanded: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
const {alt: altText, uri} = images[index]
const isMomentumScrolling = useRef(false)
return (
<ScrollView
style={styles.footerScrollView}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={{
paddingVertical: 12,
paddingHorizontal: 24,
}}>
<SafeAreaView edges={['bottom']}>
{altText ? (
<View accessibilityRole="button" style={styles.footerText}>
<Text
style={{color: colors.gray3}}
numberOfLines={isAltExpanded ? undefined : 3}
selectable
onPress={() => {
if (isMomentumScrolling.current) {
return
}
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
toggleAltExpanded()
}}
onLongPress={() => {}}
emoji>
{altText}
</Text>
</View>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => onPressSave(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Save</Trans>
</Text>
</Button>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => onPressShare(uri)}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Share</Trans>
</Text>
</Button>
</View>
</SafeAreaView>
</ScrollView>
)
}
const styles = StyleSheet.create({
screen: {
position: 'absolute',
@@ -722,41 +637,6 @@ const styles = StyleSheet.create({
pager: {
flex: 1,
},
header: {
position: 'absolute',
width: '100%',
top: 0,
pointerEvents: 'box-none',
},
footer: {
position: 'absolute',
width: '100%',
maxHeight: '100%',
bottom: 0,
},
footerScrollView: {
backgroundColor: '#000d',
flex: 1,
position: 'absolute',
bottom: 0,
width: '100%',
maxHeight: '100%',
},
footerText: {
paddingBottom: IS_IOS ? 20 : 16,
},
footerBtns: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'transparent',
borderColor: colors.white,
},
})
function interpolatePx(
@@ -856,7 +736,10 @@ function interpolateTransform(
}
}
function withClampedSpring(value: any, config: WithSpringConfig) {
function withClampedSpring<T extends AnimatableValue>(
value: T,
config: WithSpringConfig,
): T {
'worklet'
return withSpring(value, {...config, overshootClamping: true})
}
@@ -1,4 +1,4 @@
import {type Position} from './@types'
import {type Position} from '../types'
export type TransformMatrix = [
number,
@@ -9,7 +9,7 @@ import {nanoid} from 'nanoid/non-secure'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useHotkeysContext} from '#/lib/hotkeys'
import {type ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
import {type ImageSource} from '#/components/Lightbox/types'
export type Lightbox = {
id: string
@@ -6,6 +6,7 @@
*
*/
import {type Component} from 'react'
import {type TransformsStyle} from 'react-native'
import {
type AnimatedRef,
@@ -28,7 +29,7 @@ export type ImageSource = {
thumbUri: string
thumbDimensions: Dimensions | null
thumbRect: MeasuredDimensions | null
thumbRef?: AnimatedRef<any> | null
thumbRef?: AnimatedRef<Component> | null
thumbBorderRadius?: number
alt?: string
type: 'image' | 'circle-avi' | 'rect-avi'
+2 -2
View File
@@ -2,12 +2,12 @@ import {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {useLightboxControls} from '#/state/lightbox'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a, tokens} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {useLightboxControls} from '#/components/Lightbox/state'
import {type Dimensions} from '#/components/Lightbox/types'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
+1 -1
View File
@@ -3,8 +3,8 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {type AppBskyEmbedImages} from '@atproto/api'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a, useBreakpoints} from '#/alf'
import {type Dimensions} from '#/components/Lightbox/types'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {GalleryItem} from './ImageLayoutGridItem'
+1 -1
View File
@@ -14,7 +14,6 @@ import {BACK_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {type NavigationProp} from '#/lib/routes/types'
import {type Shadow} from '#/state/cache/types'
import {useLightboxControls} from '#/state/lightbox'
import {useSession} from '#/state/session'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -23,6 +22,7 @@ import {atoms as a, platform, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {useLightboxControls} from '#/components/Lightbox/state'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
import {useAnalytics} from '#/analytics'
+1 -1
View File
@@ -1,7 +1,7 @@
import {useCallback} from 'react'
import {useDialogStateControlContext} from '#/state/dialogs'
import {useLightboxControls} from './lightbox'
import {useLightboxControls} from '#/components/Lightbox/state'
import {useModalControls} from './modals'
import {useComposerControls} from './shell/composer'
import {useSetDrawerOpen} from './shell/drawer-open'
@@ -1,61 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {StyleSheet, TouchableOpacity, type ViewStyle} from 'react-native'
import {SafeAreaView} from 'react-native-safe-area-context'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {createHitslop} from '#/lib/constants'
type Props = {
onRequestClose: () => void
}
const HIT_SLOP = createHitslop(16)
const ImageDefaultHeader = ({onRequestClose}: Props) => {
const {_} = useLingui()
return (
<SafeAreaView style={styles.root}>
<TouchableOpacity
style={[styles.closeButton, styles.blurredBackground]}
onPress={onRequestClose}
hitSlop={HIT_SLOP}
accessibilityRole="button"
accessibilityLabel={_(msg`Close image`)}
accessibilityHint={_(msg`Closes viewer for header image`)}
onAccessibilityEscape={onRequestClose}>
<FontAwesomeIcon icon="close" color={'#fff'} size={22} />
</TouchableOpacity>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
root: {
alignItems: 'flex-end',
pointerEvents: 'box-none',
},
closeButton: {
marginRight: 10,
marginTop: 10,
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 22,
backgroundColor: '#00000077',
},
blurredBackground: {
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
} as ViewStyle,
})
export default ImageDefaultHeader
@@ -13,13 +13,13 @@ import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeHandle} from '#/lib/strings/handles'
import {emitSoftReset} from '#/state/events'
import {useLightboxControls} from '#/state/lightbox'
import {TextLink} from '#/view/com/util/Link'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {Text} from '#/view/com/util/text/Text'
import {UserAvatar, type UserAvatarType} from '#/view/com/util/UserAvatar'
import {StarterPack} from '#/components/icons/StarterPack'
import * as Layout from '#/components/Layout'
import {useLightboxControls} from '#/components/Lightbox/state'
export function ProfileSubpageHeader({
isLoading,
+1 -1
View File
@@ -12,8 +12,8 @@ import {useDedupe} from '#/lib/hooks/useDedupe'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {addStyle} from '#/lib/styles'
import {useLightbox} from '#/state/lightbox'
import {useTheme} from '#/alf'
import {useLightbox} from '#/components/Lightbox/state'
import {IS_IOS} from '#/env'
import {FlatList_INTERNAL} from './Views'
+1 -1
View File
@@ -19,7 +19,6 @@ import {
useSetDrawerOpen,
} from '#/state/shell'
import {useCloseAnyActiveElement} from '#/state/util'
import {Lightbox} from '#/view/com/lightbox/Lightbox'
import {ModalsContainer} from '#/view/com/modals/Modal'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {Deactivated} from '#/screens/Deactivated'
@@ -33,6 +32,7 @@ import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Lightbox} from '#/components/Lightbox'
import {GlobalReportDialog} from '#/components/moderation/ReportDialog'
import {
Outlet as PolicyUpdateOverlayPortalOutlet,
+1 -1
View File
@@ -10,7 +10,6 @@ import {type NavigationProp} from '#/lib/routes/types'
import {useSession} from '#/state/session'
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
import {useCloseAllActiveElements} from '#/state/util'
import {Lightbox} from '#/view/com/lightbox/Lightbox'
import {ModalsContainer} from '#/view/com/modals/Modal'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {Deactivated} from '#/screens/Deactivated'
@@ -23,6 +22,7 @@ import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {SigninDialog} from '#/components/dialogs/Signin'
import {useWelcomeModal} from '#/components/hooks/useWelcomeModal'
import {Lightbox} from '#/components/Lightbox'
import {GlobalReportDialog} from '#/components/moderation/ReportDialog'
import {
Outlet as PolicyUpdateOverlayPortalOutlet,