From a7fabd9e0deda4a4378a2671018c95daf7265c7e Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Tue, 5 May 2026 12:25:29 -0400 Subject: [PATCH] Refresh lightbox designs (#10330) Co-authored-by: Claude Opus 4.7 (1M context) --- .gitignore | 3 + src/App.native.tsx | 2 +- src/App.web.tsx | 2 +- .../Lightbox}/Lightbox.tsx | 4 +- .../Lightbox}/Lightbox.web.tsx | 111 ++++++---- .../Lightbox/chrome/CircleChromeButton.tsx | 65 ++++++ .../chrome/CircleChromeButton.web.tsx | 68 ++++++ src/components/Lightbox/chrome/Footer.tsx | 84 ++++++++ src/components/Lightbox/chrome/Header.tsx | 59 ++++++ src/components/Lightbox/chrome/ImageMenu.tsx | 195 ++++++++++++++++++ src/components/Lightbox/chrome/PagerDots.tsx | 54 +++++ src/components/Lightbox/index.tsx | 1 + .../pager}/ImageItem/ImageItem.android.tsx | 7 +- .../pager}/ImageItem/ImageItem.ios.tsx | 2 +- .../Lightbox/pager}/ImageItem/ImageItem.tsx | 2 +- .../Lightbox/pager/ImagePager.tsx} | 171 +++------------ .../Lightbox/pager}/transforms.ts | 2 +- .../Lightbox/state.tsx} | 2 +- .../index.ts => components/Lightbox/types.ts} | 3 +- src/components/Post/Embed/ImageEmbed.tsx | 4 +- src/components/images/ImageLayoutGrid.tsx | 2 +- src/screens/Profile/Header/Shell.tsx | 2 +- src/state/util.ts | 2 +- .../components/ImageDefaultHeader.tsx | 61 ------ src/view/com/profile/ProfileSubpageHeader.tsx | 2 +- src/view/com/util/List.tsx | 2 +- src/view/shell/index.tsx | 2 +- src/view/shell/index.web.tsx | 2 +- 28 files changed, 653 insertions(+), 263 deletions(-) rename src/{view/com/lightbox => components/Lightbox}/Lightbox.tsx (81%) rename src/{view/com/lightbox => components/Lightbox}/Lightbox.web.tsx (80%) create mode 100644 src/components/Lightbox/chrome/CircleChromeButton.tsx create mode 100644 src/components/Lightbox/chrome/CircleChromeButton.web.tsx create mode 100644 src/components/Lightbox/chrome/Footer.tsx create mode 100644 src/components/Lightbox/chrome/Header.tsx create mode 100644 src/components/Lightbox/chrome/ImageMenu.tsx create mode 100644 src/components/Lightbox/chrome/PagerDots.tsx create mode 100644 src/components/Lightbox/index.tsx rename src/{view/com/lightbox/ImageViewing/components => components/Lightbox/pager}/ImageItem/ImageItem.android.tsx (99%) rename src/{view/com/lightbox/ImageViewing/components => components/Lightbox/pager}/ImageItem/ImageItem.ios.tsx (99%) rename src/{view/com/lightbox/ImageViewing/components => components/Lightbox/pager}/ImageItem/ImageItem.tsx (97%) rename src/{view/com/lightbox/ImageViewing/index.tsx => components/Lightbox/pager/ImagePager.tsx} (83%) rename src/{view/com/lightbox/ImageViewing => components/Lightbox/pager}/transforms.ts (98%) rename src/{state/lightbox.tsx => components/Lightbox/state.tsx} (97%) rename src/{view/com/lightbox/ImageViewing/@types/index.ts => components/Lightbox/types.ts} (92%) delete mode 100644 src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx diff --git a/.gitignore b/.gitignore index c0adc37228..27b7ac9ded 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/src/App.native.tsx b/src/App.native.tsx index 3a0a5a6a57..7bbe72a8d1 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -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' diff --git a/src/App.web.tsx b/src/App.web.tsx index a840437a1b..178d3b8298 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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' diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/components/Lightbox/Lightbox.tsx similarity index 81% rename from src/view/com/lightbox/Lightbox.tsx rename to src/components/Lightbox/Lightbox.tsx index 003d01a94a..c283cc89cb 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/components/Lightbox/Lightbox.tsx @@ -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() diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/components/Lightbox/Lightbox.web.tsx similarity index 80% rename from src/view/com/lightbox/Lightbox.web.tsx rename to src/components/Lightbox/Lightbox.web.tsx index ddfc4fd8b7..0d140747c8 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/components/Lightbox/Lightbox.web.tsx @@ -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({ )} {img.alt ? ( - + {img.alt} @@ -251,29 +260,51 @@ function LightboxGallery({ {({props}) => ( - + )} + { + 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'}) + } + } + }}> + + Share image + + + { @@ -294,22 +325,20 @@ function LightboxGallery({ - + + + + {imgs.length > 1 && ( + + + + )} ) } @@ -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', diff --git a/src/components/Lightbox/chrome/CircleChromeButton.tsx b/src/components/Lightbox/chrome/CircleChromeButton.tsx new file mode 100644 index 0000000000..735a98ded2 --- /dev/null +++ b/src/components/Lightbox/chrome/CircleChromeButton.tsx @@ -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 + iconStyle?: StyleProp + 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 ( + [styles.root, pressed && styles.pressed]}> + + + + + ) +} + +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, + }, +}) diff --git a/src/components/Lightbox/chrome/CircleChromeButton.web.tsx b/src/components/Lightbox/chrome/CircleChromeButton.web.tsx new file mode 100644 index 0000000000..10de083d48 --- /dev/null +++ b/src/components/Lightbox/chrome/CircleChromeButton.web.tsx @@ -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 + iconStyle?: StyleProp + 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 ( + [styles.root, pressed && styles.pressed]}> + + + + + ) +} + +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, + }, +}) diff --git a/src/components/Lightbox/chrome/Footer.tsx b/src/components/Lightbox/chrome/Footer.tsx new file mode 100644 index 0000000000..14b5e1bd2c --- /dev/null +++ b/src/components/Lightbox/chrome/Footer.tsx @@ -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 ( + + + { + isMomentumScrolling.current = true + }} + onMomentumScrollEnd={() => { + isMomentumScrolling.current = false + }} + contentContainerStyle={[a.px_md, a.py_sm]}> + { + if (isMomentumScrolling.current) return + LayoutAnimation.configureNext({ + duration: 450, + update: {type: 'spring', springDamping: 1}, + }) + onToggleAltExpanded() + }}> + + {altText} + + + + + + ) +} + +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', + }, +}) diff --git a/src/components/Lightbox/chrome/Header.tsx b/src/components/Lightbox/chrome/Header.tsx new file mode 100644 index 0000000000..3504526ae3 --- /dev/null +++ b/src/components/Lightbox/chrome/Header.tsx @@ -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 ( + + + + + + ) +} + +const styles = StyleSheet.create({ + root: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, +}) diff --git a/src/components/Lightbox/chrome/ImageMenu.tsx b/src/components/Lightbox/chrome/ImageMenu.tsx new file mode 100644 index 0000000000..c677e6d491 --- /dev/null +++ b/src/components/Lightbox/chrome/ImageMenu.tsx @@ -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(null) + const [isMounted, setIsMounted] = useState(false) + const [anchor, setAnchor] = useState(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 ( + <> + + + + + + {anchor && ( + + runAction(onPressShare)} + /> + runAction(onPressSave)} + /> + + )} + + + ) +} + +function MenuCard({ + anchor, + progress, + children, +}: { + anchor: Anchor + progress: ReturnType> + children: React.ReactNode +}) { + const animatedStyle = useAnimatedStyle(() => ({ + opacity: progress.get(), + transform: [{scale: interpolate(progress.get(), [0, 1], [0.9, 1])}], + })) + + return ( + + {children} + + ) +} + +function MenuItem({ + icon: Icon, + label, + onPress, +}: { + icon: React.ComponentType + label: string + onPress: () => void +}) { + return ( + [styles.item, pressed && styles.itemPressed]}> + + {label} + + ) +} + +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, + }, +}) diff --git a/src/components/Lightbox/chrome/PagerDots.tsx b/src/components/Lightbox/chrome/PagerDots.tsx new file mode 100644 index 0000000000..a5fea5d4de --- /dev/null +++ b/src/components/Lightbox/chrome/PagerDots.tsx @@ -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 ( + + {Array.from({length: count}).map((_, i) => { + const isActive = i === activeIndex + return ( + + ) + })} + + ) +} + +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)', + }, +}) diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx new file mode 100644 index 0000000000..ef6d5fb318 --- /dev/null +++ b/src/components/Lightbox/index.tsx @@ -0,0 +1 @@ +export {Lightbox} from './Lightbox' diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/components/Lightbox/pager/ImageItem/ImageItem.android.tsx similarity index 99% rename from src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx rename to src/components/Lightbox/pager/ImageItem/ImageItem.android.tsx index 9d128a41cf..62e28523a7 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/components/Lightbox/pager/ImageItem/ImageItem.android.tsx @@ -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(value: T): T { 'worklet' return withSpring(value, {overshootClamping: true}) } diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx similarity index 99% rename from src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx rename to src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx index 52173e057a..c0bb99c89e 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx @@ -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 diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/components/Lightbox/pager/ImageItem/ImageItem.tsx similarity index 97% rename from src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx rename to src/components/Lightbox/pager/ImageItem/ImageItem.tsx index cd0e45d3c5..513c20f6af 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/components/Lightbox/pager/ImageItem/ImageItem.tsx @@ -10,7 +10,7 @@ import { type Dimensions as ImageDimensions, type ImageSource, type LightboxTransforms, -} from '../../@types' +} from '../../types' type Props = { imageSrc: ImageSource diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/components/Lightbox/pager/ImagePager.tsx similarity index 83% rename from src/view/com/lightbox/ImageViewing/index.tsx rename to src/components/Lightbox/pager/ImagePager.tsx index 40cabd722d..1c8836a0fd 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/components/Lightbox/pager/ImagePager.tsx @@ -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) => ( - + ))} - + - +
onPressShare(images[imageIndex].uri)} + onPressSave={() => onPressSave(images[imageIndex].uri)} + imageCount={images.length} + activeIndex={imageIndex} + /> - setIsAltExpanded(e => !e)} - onPressSave={onPressSave} - onPressShare={onPressShare} + onToggleAltExpanded={() => setIsAltExpanded(e => !e)} /> @@ -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 ( - { - isMomentumScrolling.current = true - }} - onMomentumScrollEnd={() => { - isMomentumScrolling.current = false - }} - contentContainerStyle={{ - paddingVertical: 12, - paddingHorizontal: 24, - }}> - - {altText ? ( - - { - if (isMomentumScrolling.current) { - return - } - LayoutAnimation.configureNext({ - duration: 450, - update: {type: 'spring', springDamping: 1}, - }) - toggleAltExpanded() - }} - onLongPress={() => {}} - emoji> - {altText} - - - ) : null} - - - - - - - ) -} - 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( + value: T, + config: WithSpringConfig, +): T { 'worklet' return withSpring(value, {...config, overshootClamping: true}) } diff --git a/src/view/com/lightbox/ImageViewing/transforms.ts b/src/components/Lightbox/pager/transforms.ts similarity index 98% rename from src/view/com/lightbox/ImageViewing/transforms.ts rename to src/components/Lightbox/pager/transforms.ts index ae6e4aed80..25b826995f 100644 --- a/src/view/com/lightbox/ImageViewing/transforms.ts +++ b/src/components/Lightbox/pager/transforms.ts @@ -1,4 +1,4 @@ -import {type Position} from './@types' +import {type Position} from '../types' export type TransformMatrix = [ number, diff --git a/src/state/lightbox.tsx b/src/components/Lightbox/state.tsx similarity index 97% rename from src/state/lightbox.tsx rename to src/components/Lightbox/state.tsx index 7d688dab20..23af527734 100644 --- a/src/state/lightbox.tsx +++ b/src/components/Lightbox/state.tsx @@ -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 diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/components/Lightbox/types.ts similarity index 92% rename from src/view/com/lightbox/ImageViewing/@types/index.ts rename to src/components/Lightbox/types.ts index c3aca3d859..19789fdc62 100644 --- a/src/view/com/lightbox/ImageViewing/@types/index.ts +++ b/src/components/Lightbox/types.ts @@ -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 | null + thumbRef?: AnimatedRef | null thumbBorderRadius?: number alt?: string type: 'image' | 'circle-avi' | 'rect-avi' diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index 2adc33b6e6..d779d04876 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -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' diff --git a/src/components/images/ImageLayoutGrid.tsx b/src/components/images/ImageLayoutGrid.tsx index 320dba70a5..0017ddf9cf 100644 --- a/src/components/images/ImageLayoutGrid.tsx +++ b/src/components/images/ImageLayoutGrid.tsx @@ -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' diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 1d33539e25..a1eff2f19b 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -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' diff --git a/src/state/util.ts b/src/state/util.ts index cdd8214a96..239aab5aca 100644 --- a/src/state/util.ts +++ b/src/state/util.ts @@ -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' diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx deleted file mode 100644 index feaef3bac4..0000000000 --- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx +++ /dev/null @@ -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 ( - - - - - - ) -} - -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 diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index 1c0bf40843..072583938a 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -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, diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index d9a7f06af6..aee397bb79 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -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' diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index d15980ba4d..53c716d222 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -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, diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index bd420ef002..bfce0729d2 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -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,