import { PlatformPressable } from '@react-navigation/elements'
import colorFunction from 'color'
import React, { useMemo, useState, Dispatch, SetStateAction, useCallback, useRef } from 'react'
import {
StatusBar,
StyleSheet,
Modal,
View,
Linking,
Dimensions,
NativeSyntheticEvent,
NativeScrollEvent,
ViewToken,
} from 'react-native'
import {
FlatList,
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler'
import Animated, {
runOnJS,
useAnimatedStyle,
useAnimatedReaction,
useSharedValue,
withSpring,
withDecay,
SharedValue,
} from 'react-native-reanimated'
import { useSafeAreaInsets, SafeAreaProvider } from 'react-native-safe-area-context'
import { useTheme } from '../../../hooks'
import { DenormalizedMessageAttachmentResource } from '../../../types/resources/denormalized_attachment_resource'
import { Haptic, platformFontWeightMedium } from '../../../utils'
import { formatDatePreview } from '../../../utils/date'
import { tokens } from '../../../vendor/tapestry/tokens'
import { IconButton, Image, Heading, Text } from '../../display'
import { AttachmentDeletingOverlay } from './attachment_deleting_overlay'
const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window')
const DISMISS_PAN_THRESHOLD = 175
const MIN_DISTANCE_FOR_PAN = 10 // Higher threshold gives pinching priority
const SINGLE_FINGER_POINTER = 1 // Single-finger panning / tapping helps to avoid conflicts with pinching
const DEFAULT_OPACITY = 1
const DEFAULT_TRANSLATE_X = 0
const DEFAULT_TRANSLATE_Y = 0
const DEFAULT_SCALE = 1
const MIN_SCALE = 0.5
const MAX_SCALE = 5
const MAX_SCALE_OVERSHOOT = 5
const DOUBLE_TAP_ZOOM_SCALE = 2
const DECAY_VELOCITY_FACTOR = 0.4
const RESET_SPRING_CONFIG = {
damping: 20,
stiffness: 150,
}
export type MetaProps = {
authorName: string
createdAt: string
}
export function ImageAttachment({
attachment,
imageAttachments,
currentImageIndex,
metaProps,
onMessageAttachmentLongPress,
isDeleting,
}: {
attachment: DenormalizedMessageAttachmentResource
imageAttachments: DenormalizedMessageAttachmentResource[]
currentImageIndex: number
metaProps: MetaProps
onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void
isDeleting: boolean
}) {
const { attributes } = attachment
const { url, urlMedium, filename, metadata = {} } = attributes
const { colors } = useTheme()
const styles = useStyles({ imageWidth: metadata.width, imageHeight: metadata.height })
const [visible, setVisible] = useState(false)
// Force modal to remount with fresh state
// Fixes a bug where dismissing the modal too quickly causes the Reanimated shared values (like toolbarVisible) to not reset.
const [modalKey, setModalKey] = useState(0)
return (
<>
{
if (!isDeleting) {
setModalKey(prev => prev + 1)
setVisible(true)
}
}}
onLongPress={() => !isDeleting && onMessageAttachmentLongPress(attachment)}
android_ripple={{ color: colors.androidRippleNeutral, foreground: true }}
accessibilityHint="Long press for more options"
disabled={isDeleting}
>
{isDeleting && }
>
)
}
interface LightboxModalProps {
visible: boolean
setModalVisible: Dispatch>
imageAttachments: DenormalizedMessageAttachmentResource[]
initialImageIndex: number
metaProps: MetaProps
}
const LightboxModal = ({
visible,
setModalVisible,
imageAttachments,
initialImageIndex,
metaProps,
}: LightboxModalProps) => {
const onRequestClose = useCallback(() => {
setModalVisible(false)
}, [setModalVisible])
return (
)
}
interface LightboxGalleryProps {
imageAttachments: DenormalizedMessageAttachmentResource[]
initialImageIndex: number
metaProps: MetaProps
onRequestClose: () => void
}
const LightboxGallery = ({
imageAttachments,
initialImageIndex,
metaProps,
onRequestClose,
}: LightboxGalleryProps) => {
const styles = useStyles()
const insets = useSafeAreaInsets()
const [currentImageIndex, setCurrentImageIndex] = useState(initialImageIndex)
const { soft: triggerMaxZoomHaptic, rigid: triggerDismissHaptic } = Haptic
// Get current image data
const currentImage = imageAttachments[currentImageIndex]
const { url, urlMedium, metadata = {} } = currentImage.attributes
const imageWidth = metadata.width
const imageHeight = metadata.height
// Calculate available space for image display
const availableWindowWidth = WINDOW_WIDTH
const availableWindowHeight = WINDOW_HEIGHT - insets.top - insets.bottom
/* ============================
ANIMATION VALUES
============================ */
// Native State:
const dismissY = useSharedValue(0) // vertical distance to dismiss modal
const opacity = useSharedValue(DEFAULT_OPACITY) // opacity of modal
const scale = useSharedValue(DEFAULT_SCALE) // zoom level of image
const focalX = useSharedValue(0) // focal point of image between fingers
const focalY = useSharedValue(0) // focal point of image between fingers
const translateX = useSharedValue(DEFAULT_TRANSLATE_X) // horizontal distance to pan image
const translateY = useSharedValue(DEFAULT_TRANSLATE_Y) // vertical distance to pan image
const savedScale = useSharedValue(DEFAULT_SCALE) // previous zoom level
const savedTranslateX = useSharedValue(DEFAULT_TRANSLATE_X) // previous horizontal position
const savedTranslateY = useSharedValue(DEFAULT_TRANSLATE_Y) // previous vertical position
const lightboxToolbarVisible = useSharedValue(1) // toolbar visibility state
// React (JS) State:
const [isStatusBarHidden, setIsStatusBarHidden] = useState(false)
const [flatListScrollEnabled, setFlatListScrollEnabled] = useState(true)
// Syncs toolbar useSharedValue state with React's state so that the status bar can be hidden/shown based on the toolbar's animation
useAnimatedReaction(
() => lightboxToolbarVisible.value,
value => {
runOnJS(setIsStatusBarHidden)(value === 0)
}
)
/* ============================
HANDLERS
============================ */
const handleOpenInBrowser = useCallback(() => {
Linking.openURL(urlMedium || url)
}, [urlMedium, url])
// Helper to enable or disable FlatList scroll state after gestures have ended
const enableFlatListScroll = useCallback((enableScroll: boolean) => {
setFlatListScrollEnabled(enableScroll)
}, [])
const resetDismissGestures = useCallback(() => {
dismissY.value = withSpring(0, RESET_SPRING_CONFIG)
opacity.value = withSpring(DEFAULT_OPACITY, RESET_SPRING_CONFIG)
}, [dismissY, opacity])
const resetAllGestures = useCallback(() => {
resetDismissGestures()
scale.value = withSpring(DEFAULT_SCALE, RESET_SPRING_CONFIG)
translateX.value = withSpring(DEFAULT_TRANSLATE_X, RESET_SPRING_CONFIG)
translateY.value = withSpring(DEFAULT_TRANSLATE_Y, RESET_SPRING_CONFIG)
savedScale.value = DEFAULT_SCALE
savedTranslateX.value = DEFAULT_TRANSLATE_X
savedTranslateY.value = DEFAULT_TRANSLATE_Y
lightboxToolbarVisible.value = withSpring(1, RESET_SPRING_CONFIG)
enableFlatListScroll(true)
}, [
resetDismissGestures,
scale,
translateX,
translateY,
savedScale,
savedTranslateX,
savedTranslateY,
lightboxToolbarVisible,
enableFlatListScroll,
])
const handleCloseModal = useCallback(() => {
onRequestClose()
resetAllGestures()
}, [onRequestClose, resetAllGestures])
/* ============================
UTILITY WORKLET FUNCTIONS
'worklet' runs functions on the UI thread, instead of the JS thread for better performance.
============================ */
const getImageContainedToWindowDimensions = () => {
'worklet'
if (!imageWidth || !imageHeight) {
return { width: availableWindowWidth, height: availableWindowHeight }
}
const imageAspectRatio = imageWidth / imageHeight
const windowAspectRatio = availableWindowWidth / availableWindowHeight
// Constrain image width if its wider than window
if (imageAspectRatio > windowAspectRatio) {
return {
width: availableWindowWidth,
height: availableWindowWidth / imageAspectRatio,
}
}
// Constrain image height if its taller than window
return {
width: availableWindowHeight * imageAspectRatio,
height: availableWindowHeight,
}
}
const zoomToFocalPoint = ({
focalPointX,
focalPointY,
targetScale,
currentSavedScale,
currentSavedTranslateX,
currentSavedTranslateY,
}: {
focalPointX: number
focalPointY: number
targetScale: number
currentSavedScale: number
currentSavedTranslateX: number
currentSavedTranslateY: number
}) => {
'worklet'
// How far the focal point is from the center of the window
const windowCenterX = WINDOW_WIDTH / 2
const windowCenterY = WINDOW_HEIGHT / 2
// Position of focal point relative to current image position
const focalRelativeX = focalPointX - windowCenterX - currentSavedTranslateX
const focalRelativeY = focalPointY - windowCenterY - currentSavedTranslateY
// Calculate new translation to keep focal point under fingers
const scaleRatio = targetScale / currentSavedScale
const newTranslateX = currentSavedTranslateX + focalRelativeX * (1 - scaleRatio)
const newTranslateY = currentSavedTranslateY + focalRelativeY * (1 - scaleRatio)
return {
translateX: newTranslateX,
translateY: newTranslateY,
}
}
const getMaxTranslationLimits = (currentScale: number) => {
'worklet'
const { width: containedImageWidth, height: containedImageHeight } =
getImageContainedToWindowDimensions()
// Image dimensions after scaling/zooming
const scaledWidth = containedImageWidth * currentScale
const scaledHeight = containedImageHeight * currentScale
// How much the scaled image exceeds the window container
const excessWidth = scaledWidth - availableWindowWidth
const excessHeight = scaledHeight - availableWindowHeight
// How far the image can move in each direction before hitting window edges
const maxTranslateX = Math.max(DEFAULT_TRANSLATE_X, excessWidth / 2)
const maxTranslateY = Math.max(DEFAULT_TRANSLATE_Y, excessHeight / 2)
return { maxTranslateX, maxTranslateY }
}
const clampDecay = (currentScale: number): { x: [number, number]; y: [number, number] } => {
'worklet'
const { maxTranslateX, maxTranslateY } = getMaxTranslationLimits(currentScale)
return {
x: [-maxTranslateX, maxTranslateX],
y: [-maxTranslateY, maxTranslateY],
}
}
const clampTranslation = ({
x,
y,
currentScale,
}: {
x: number
y: number
currentScale: number
}) => {
'worklet'
const { maxTranslateX, maxTranslateY } = getMaxTranslationLimits(currentScale)
return {
x: Math.min(maxTranslateX, Math.max(-maxTranslateX, x)),
y: Math.min(maxTranslateY, Math.max(-maxTranslateY, y)),
}
}
const isImageAtVerticalBoundry = (currentScale: number) => {
'worklet'
const { height: containedImageHeight } = getImageContainedToWindowDimensions()
// Calculate how much the image can exceed the window container
const scaledHeight = containedImageHeight * currentScale
const excessHeight = scaledHeight - availableWindowHeight
const maxTranslateY = Math.max(DEFAULT_TRANSLATE_Y, excessHeight / 2)
const currentTranslateY = translateY.value
const panPositionTolerance = 1 // buffer to account for translateY being at a subpixel position
const atTopBoundry = currentTranslateY >= maxTranslateY - panPositionTolerance
const atBottomBoundry = currentTranslateY <= -maxTranslateY + panPositionTolerance
return atTopBoundry || atBottomBoundry
}
/* ============================
UTILITY FLATLIST FUNCTIONS
Supports the image gallery layout and swipe functionality.
============================ */
// Use refs to avoid re-rendering FlatList callbacks...
const stableCurrentImageIndexRef = useRef(currentImageIndex)
const stableImageAttachmentsLengthRef = useRef(imageAttachments.length)
// ...while still ensuring they are updated with the latest values
stableCurrentImageIndexRef.current = currentImageIndex
stableImageAttachmentsLengthRef.current = imageAttachments.length
// Used in tandem with FlatList's initialScrollIndex to quickly calculate the position and size of each image before they load.
const getItemLayout = useCallback(
(_: unknown, index: number) => ({
length: WINDOW_WIDTH,
offset: WINDOW_WIDTH * index,
index,
}),
[]
)
// Captures the current image's index after the FlatList finishes its scroll animation.
// Used in tandem with onViewableItemsChanged to ensure the final value for currentImageIndex is set.
const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent) => {
// Calculate the index of the image that is currently visible
const imageOffsetX = event.nativeEvent.contentOffset.x
const newImageIndex = Math.round(imageOffsetX / WINDOW_WIDTH)
// Check if the image index has changed and the FlatList didn't scroll past the first or last image
const didImageIndexChange = newImageIndex !== stableCurrentImageIndexRef.current
const isImageIndexWithinBounds =
newImageIndex >= 0 && newImageIndex < stableImageAttachmentsLengthRef.current
if (didImageIndexChange && isImageIndexWithinBounds) {
setCurrentImageIndex(newImageIndex)
}
}, [])
// Supplements onMomentumScrollEnd by capturing the current image's index while the FlatList is actively scrolling.
// Used in tandem with viewabilityConfig to trigger when the image is 50% visible in the window.
const onViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken[] }) => {
if (viewableItems.length === 0) return
// Use the first viewable item which is enforced by the FlatList's pagingEnabled prop that allows only two images to be visible at a time when scrolling.
const firstViewableItem = viewableItems[0]
const newIndex = firstViewableItem.index
if (newIndex !== null && newIndex !== stableCurrentImageIndexRef.current) {
setCurrentImageIndex(newIndex)
}
},
[]
)
/* ============================
GESTURES
============================ */
const singleTapGesture = Gesture.Tap()
.minPointers(SINGLE_FINGER_POINTER)
.numberOfTaps(1)
.onStart(() => {
lightboxToolbarVisible.value = withSpring(
lightboxToolbarVisible.value > 0.5 ? 0 : 1,
RESET_SPRING_CONFIG
)
})
const doubleTapGesture = Gesture.Tap()
.minPointers(SINGLE_FINGER_POINTER)
.numberOfTaps(2)
.onStart(e => {
const isZoomedIn = scale.value > DEFAULT_SCALE
if (isZoomedIn) {
runOnJS(resetAllGestures)()
} else {
// Hide toolbar when starting to zoom
lightboxToolbarVisible.value = withSpring(0, RESET_SPRING_CONFIG)
// Zoom to 2x at tap location
const newTranslation = zoomToFocalPoint({
focalPointX: e.x,
focalPointY: e.y,
targetScale: DOUBLE_TAP_ZOOM_SCALE,
currentSavedScale: savedScale.value,
currentSavedTranslateX: savedTranslateX.value,
currentSavedTranslateY: savedTranslateY.value,
})
// Apply clamping to ensure image edges remain within window boundaries
const clampedTranslate = clampTranslation({
x: newTranslation.translateX,
y: newTranslation.translateY,
currentScale: DOUBLE_TAP_ZOOM_SCALE,
})
// Animate to new scale and translation
scale.value = withSpring(DOUBLE_TAP_ZOOM_SCALE, RESET_SPRING_CONFIG)
translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG)
translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG)
// Update saved state for next gesture
savedScale.value = DOUBLE_TAP_ZOOM_SCALE
savedTranslateX.value = clampedTranslate.x
savedTranslateY.value = clampedTranslate.y
// Disable FlatList scroll since image is now zoomed
runOnJS(enableFlatListScroll)(false)
}
})
const pinchGesture = Gesture.Pinch()
.onStart(e => {
focalX.value = e.focalX
focalY.value = e.focalY
// Hide toolbar when starting to zoom
lightboxToolbarVisible.value = withSpring(0, RESET_SPRING_CONFIG)
// Ensure that pinch accounts for the decay animation and starts from the true current position.
savedTranslateX.value = translateX.value
savedTranslateY.value = translateY.value
})
.onUpdate(e => {
// Zoom image in/out within scale limits
const newScale = savedScale.value * e.scale
const newScaleClamped = Math.min(
MAX_SCALE + MAX_SCALE_OVERSHOOT,
Math.max(MIN_SCALE, newScale)
)
// Check if we just reached the minimum scale threshold
const previousScale = scale.value
const reacjedMinScale = previousScale > MIN_SCALE && newScaleClamped <= MIN_SCALE
if (reacjedMinScale) {
runOnJS(triggerDismissHaptic)()
}
scale.value = newScaleClamped
// Calculate new translation to keep focal point under fingers
const newTranslation = zoomToFocalPoint({
focalPointX: focalX.value,
focalPointY: focalY.value,
targetScale: newScaleClamped,
currentSavedScale: savedScale.value,
currentSavedTranslateX: savedTranslateX.value,
currentSavedTranslateY: savedTranslateY.value,
})
// Apply translation without clamping to ensure focal point doesn't jump during gesture
translateX.value = newTranslation.translateX
translateY.value = newTranslation.translateY
})
.onEnd(() => {
const currentScale = scale.value
// Dismiss modal if fully zoomed out
if (currentScale <= MIN_SCALE) {
runOnJS(handleCloseModal)()
return
}
// Reset all gestures if image is near default scale
const isNearDefaultScale = currentScale <= DEFAULT_SCALE + 0.1
if (isNearDefaultScale) {
runOnJS(resetAllGestures)()
return
}
// Check if overshooting the max scale and clamp it
const finalScale = currentScale > MAX_SCALE ? MAX_SCALE : currentScale
if (finalScale === MAX_SCALE) {
runOnJS(triggerMaxZoomHaptic)()
}
// Recalculate translation using focal point to always return to the same position when image is overshooting the max scale
const newTranslation = zoomToFocalPoint({
focalPointX: focalX.value,
focalPointY: focalY.value,
targetScale: finalScale,
currentSavedScale: savedScale.value,
currentSavedTranslateX: savedTranslateX.value,
currentSavedTranslateY: savedTranslateY.value,
})
// Ensure image edges remain within window boundaries
const clampedTranslate = clampTranslation({
x: newTranslation.translateX,
y: newTranslation.translateY,
currentScale: finalScale,
})
// Animate to position and scale limits with a spring
scale.value = withSpring(finalScale, RESET_SPRING_CONFIG)
translateX.value = withSpring(clampedTranslate.x, RESET_SPRING_CONFIG)
translateY.value = withSpring(clampedTranslate.y, RESET_SPRING_CONFIG)
// Save state for next gesture
savedScale.value = finalScale
savedTranslateX.value = clampedTranslate.x
savedTranslateY.value = clampedTranslate.y
// Disable FlatList scroll since image is still zoomed
runOnJS(enableFlatListScroll)(false)
})
const panGesture = Gesture.Pan()
.minDistance(MIN_DISTANCE_FOR_PAN)
.minPointers(SINGLE_FINGER_POINTER)
.maxPointers(SINGLE_FINGER_POINTER)
.onStart(() => {
// Only start pan if image is zoomed in - prevents conflicts with FlatList
if (scale.value <= DEFAULT_SCALE) return
// Update saved position to current animated position
// This ensures smooth continuation if decay is running
savedTranslateX.value = translateX.value
savedTranslateY.value = translateY.value
})
.onUpdate(e => {
// Only pan if image is zoomed in - prevents conflicts with FlatList
if (scale.value <= DEFAULT_SCALE) return
const newTranslateX = savedTranslateX.value + e.translationX
const newTranslateY = savedTranslateY.value + e.translationY
// Ensure image edges remain within window boundaries
const clampedTranslate = clampTranslation({
x: newTranslateX,
y: newTranslateY,
currentScale: scale.value,
})
translateX.value = clampedTranslate.x
translateY.value = clampedTranslate.y
})
.onEnd(e => {
// Prevents saving pan position if image is zoomed out while panning
if (scale.value <= DEFAULT_SCALE) return
const clampDecayBounds = clampDecay(scale.value)
translateX.value = withDecay(
{
velocity: e.velocityX * DECAY_VELOCITY_FACTOR,
clamp: clampDecayBounds.x,
deceleration: 0.996,
},
finished => {
if (finished) {
savedTranslateX.value = translateX.value
}
}
)
translateY.value = withDecay(
{
velocity: e.velocityY * DECAY_VELOCITY_FACTOR,
clamp: clampDecayBounds.y,
deceleration: 0.996,
},
finished => {
if (finished) {
savedTranslateY.value = translateY.value
}
}
)
})
const panToDismissModalGesture = Gesture.Pan()
.onUpdate(e => {
const atDefaultScale = scale.value === DEFAULT_SCALE
// Calculate zoom conditions for dismissing modal
const atVerticalBoundry = isImageAtVerticalBoundry(scale.value)
const panDirectionIsPrimarilyVertical = Math.abs(e.translationY) > Math.abs(e.translationX)
const canDismissWhileZoomed = atVerticalBoundry && panDirectionIsPrimarilyVertical
if (!(atDefaultScale || canDismissWhileZoomed)) return
// Fade image if its been panned past 50% of the dismiss threshold
const panDistance = Math.abs(e.translationY)
const halfThreshold = DISMISS_PAN_THRESHOLD / 2
const fadeDistance = Math.max(0, panDistance - halfThreshold)
const fadeProgress = fadeDistance / halfThreshold
// Check if we just crossed the dismiss threshold
const previousDistance = Math.abs(dismissY.value)
const crossedDismissThreshold =
previousDistance < DISMISS_PAN_THRESHOLD && panDistance >= DISMISS_PAN_THRESHOLD
if (crossedDismissThreshold) {
runOnJS(triggerDismissHaptic)()
}
opacity.value = Math.max(0, DEFAULT_OPACITY - fadeProgress)
dismissY.value = e.translationY
})
.onEnd(() => {
const exceededDismissThreshold = Math.abs(dismissY.value) > DISMISS_PAN_THRESHOLD
if (exceededDismissThreshold) {
runOnJS(handleCloseModal)()
} else {
runOnJS(resetDismissGestures)()
}
})
/* ==============================
COMPOSE GESTURES
================================= */
// Race between pinch and pan ensures only one is active at a time, preserving focal point logic
const pinchOrPanGestures = Gesture.Race(pinchGesture, panGesture)
// Exclusive race ensures single tap doesn't interfere with double tap
const tapGestures = Gesture.Exclusive(doubleTapGesture, singleTapGesture)
// Race between tap gestures and pinch/pan
const transformImageGestures = Gesture.Race(tapGestures, pinchOrPanGestures)
// Dismiss can work simultaneously with all gestures
const composedGesture = Gesture.Simultaneous(transformImageGestures, panToDismissModalGesture)
return (
<>
(
)}
keyExtractor={(item, index) => `${item.id}-${index}`}
horizontal
pagingEnabled
scrollEnabled={flatListScrollEnabled}
showsHorizontalScrollIndicator={false}
initialScrollIndex={initialImageIndex}
getItemLayout={getItemLayout}
onMomentumScrollEnd={onMomentumScrollEnd}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={{
itemVisiblePercentThreshold: 50, // 50% of the image must be visible in the window to be considered viewable
}}
style={styles.gallery}
contentContainerStyle={styles.galleryContentContainer}
/>
{imageAttachments.length > 1 && (
)}
>
)
}
interface GestureImageProps {
item: DenormalizedMessageAttachmentResource
gesturesEnabled: boolean
scale: SharedValue
translateX: SharedValue
translateY: SharedValue
dismissY: SharedValue
opacity: SharedValue
}
const GestureImage = ({
item,
gesturesEnabled,
scale,
translateX,
translateY,
dismissY,
opacity,
}: GestureImageProps) => {
const styles = useStyles()
const { url: itemUrl, urlMedium: itemUrlMedium } = item.attributes
const animatedImageStyles = useAnimatedStyle(() => {
return {
transform: [
{ translateX: gesturesEnabled ? translateX.value : DEFAULT_TRANSLATE_X },
{ translateY: gesturesEnabled ? translateY.value + dismissY.value : DEFAULT_TRANSLATE_Y },
{ scale: gesturesEnabled ? scale.value : DEFAULT_SCALE },
],
opacity: opacity.value,
}
})
return (
)
}
const TouchEventIsolator = ({ children }: { children: React.ReactNode }) => {
const styles = useStyles()
return (
true}>
{children}
)
}
interface LightboxToolbarHeaderProps {
metaProps: MetaProps
lightboxToolbarVisible: SharedValue
isStatusBarHidden: boolean
handleOpenInBrowser: () => void
handleCloseModal: () => void
}
const LightboxToolbarHeader = ({
metaProps,
lightboxToolbarVisible,
isStatusBarHidden,
handleOpenInBrowser,
handleCloseModal,
}: LightboxToolbarHeaderProps) => {
const styles = useStyles()
const { authorName, createdAt } = metaProps
const animatedHeaderStyles = useAnimatedStyle(() => ({
opacity: lightboxToolbarVisible.value,
transform: [
{ translateY: (1 - lightboxToolbarVisible.value) * -20 }, // slide up when hiding
],
}))
return (
{authorName}
{formatDatePreview(createdAt)}
)
}
interface LightboxToolbarFooterProps {
lightboxToolbarVisible: SharedValue
totalImages: number
currentImageIndex: number
}
const LightboxToolbarFooter = ({
lightboxToolbarVisible,
totalImages,
currentImageIndex,
}: LightboxToolbarFooterProps) => {
const styles = useStyles()
const animatedFooterStyles = useAnimatedStyle(() => ({
opacity: lightboxToolbarVisible.value,
transform: [
{ translateY: (1 - lightboxToolbarVisible.value) * 20 }, // slide down when showing
],
}))
return (
{currentImageIndex + 1} of {totalImages}
)
}
interface UseStylesProps {
imageWidth?: number
imageHeight?: number
}
const useStyles = ({ imageWidth = 100, imageHeight = 100 }: UseStylesProps = {}) => {
const { top, bottom } = useSafeAreaInsets()
const backgroundColor = tokens.colorNeutral7
const transparentBackgroundColor = useMemo(
() => colorFunction(backgroundColor).alpha(0.8).toString(),
[backgroundColor]
)
return StyleSheet.create({
container: {
maxWidth: '100%',
position: 'relative',
},
touchEventIsolator: {
flex: 1,
},
attachmentImageWrapper: {
width: '100%',
minWidth: 200,
aspectRatio: imageWidth / imageHeight,
},
image: {
borderRadius: 8,
},
gallery: {
backgroundColor,
},
galleryContentContainer: {
paddingTop: top,
paddingBottom: bottom,
},
gestureImage: {
height: '100%',
width: WINDOW_WIDTH,
backgroundColor: 'transparent',
},
gestureImageLoading: {
backgroundColor,
},
lightboxToolbar: {
width: '100%',
position: 'absolute',
flexDirection: 'row',
alignItems: 'center',
gap: 20,
paddingHorizontal: 16,
backgroundColor: transparentBackgroundColor,
},
lightboxToolbarButton: {
backgroundColor,
height: 40,
width: 40,
borderRadius: 50,
borderWidth: 1,
borderColor: tokens.colorNeutral24,
},
lightboxToolbarButtonIcon: {
color: tokens.colorNeutral88,
},
lightboxToolbarHeader: {
top: 0,
paddingTop: top + 16,
paddingBottom: 8,
},
lightboxToolbarHeaderMetaContainer: {
flex: 1,
},
lightboxToolbarHeaderTitle: {
marginRight: 'auto',
flexShrink: 1,
color: tokens.colorNeutral88,
},
lightboxToolbarHeaderSubtitle: {
color: tokens.colorNeutral68,
},
lightboxToolbarFooter: {
justifyContent: 'center',
bottom: 0,
paddingTop: 8,
paddingBottom: bottom + 16,
},
lightboxToolbarFooterText: {
color: tokens.colorNeutral88,
fontWeight: platformFontWeightMedium,
},
})
}