feat(mobile): lightbox (#3882)

* feat(mobile): lightbox

* chore: auto-fix linting and formatting issues

* feat: enhance back handler to close lightbox when active

* feat: integrate Lightbox component and move SafeAreaProvider to RootProviders

* refactor: update renderItem function to use destructured props

* feat: add Android manifest plugin to enable large heap size

* fix: add rounded corners to AspectRatioImage

* feat: set priority to high for image loading in ImageItem component

* chore: remove unused zIndex from ImageViewing

* feat: update ImageDefaultHeader and LightboxFooter for improved layout

* chore: simplify renderItem function signature in PagerList and Subscriptions components

* refactor: update galeria

* chore: tweak component

* feat: integrate lightbox functionality for media previews
This commit is contained in:
Whitewater 2025-06-12 13:52:07 +08:00 committed by GitHub
parent 8d6489f605
commit 547a4fdc96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 2081 additions and 65 deletions

View File

@ -131,6 +131,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
],
require("./plugins/with-gradle-jvm-heap-size-increase.js"),
require("./plugins/with-android-manifest-plugin.js"),
"expo-secure-store",
"@react-native-firebase/app",
"@react-native-firebase/crashlytics",

View File

@ -0,0 +1,13 @@
const { withAndroidManifest } = require("expo/config-plugins")
// Ported from https://github.com/bluesky-social/social-app/blob/a5e25a7a16cdcde64628e942c073a119bc1d7a1e/plugins/withAndroidManifestPlugin.js
module.exports = function withAndroidManifestPlugin(appConfig) {
return withAndroidManifest(appConfig, (decoratedAppConfig) => {
try {
decoratedAppConfig.modResults.manifest.application[0].$["android:largeHeap"] = "true"
} catch (e) {
console.error(`withAndroidManifestPlugin failed`, e)
}
return decoratedAppConfig
})
}

View File

@ -0,0 +1,32 @@
/**
* 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 type { TransformsStyle } from "react-native"
import type { MeasuredDimensions } from "react-native-reanimated"
export type Dimensions = {
width: number
height: number
}
export type Position = {
x: number
y: number
}
export type ImageSource = {
uri: string
dimensions: Dimensions | null
thumbUri: string
thumbDimensions: Dimensions | null
thumbRect: MeasuredDimensions | null
alt?: string
type: "image" | "circle-avi" | "rect-avi"
}
export type Transform = Exclude<TransformsStyle["transform"], string | undefined>

View File

@ -0,0 +1,58 @@
/**
* 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 type { ViewStyle } from "react-native"
import { StyleSheet, TouchableOpacity, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { CloseCuteReIcon } from "@/src/icons/close_cute_re"
type Props = {
onRequestClose: () => void
}
const ImageDefaultHeader = ({ onRequestClose }: Props) => {
const insets = useSafeAreaInsets()
return (
<View style={[styles.root, { marginTop: insets.top, marginRight: insets.right }]}>
<TouchableOpacity
style={[styles.closeButton, styles.blurredBackground]}
onPress={onRequestClose}
hitSlop={16}
accessibilityRole="button"
accessibilityLabel={`Close image`}
accessibilityHint={`Closes viewer for header image`}
onAccessibilityEscape={onRequestClose}
>
<CloseCuteReIcon color="#fff" />
</TouchableOpacity>
</View>
)
}
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

View File

@ -0,0 +1,426 @@
import { Image } from "expo-image"
import * as React from "react"
import { useState } from "react"
import { ActivityIndicator, StyleSheet } from "react-native"
import type { PanGesture } from "react-native-gesture-handler"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import type { SharedValue } from "react-native-reanimated"
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated"
import type { Dimensions as ImageDimensions, ImageSource, Transform } from "../../@types"
import type { TransformMatrix } from "../../transforms"
import {
applyRounding,
createTransform,
prependPan,
prependPinch,
prependTransform,
readTransform,
} from "../../transforms"
const MIN_SCREEN_ZOOM = 2
const MAX_ORIGINAL_IMAGE_ZOOM = 2
const initialTransform = createTransform()
type Props = {
imageSrc: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (isZoomed: boolean) => void
onLoad: (dims: ImageDimensions) => void
isScrollViewBeingDragged: boolean
showControls: boolean
measureSafeArea: () => {
x: number
y: number
width: number
height: number
}
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
}
const ImageItem = ({
imageSrc,
onTap,
onZoom,
onLoad,
isScrollViewBeingDragged,
measureSafeArea,
imageAspect,
imageDimensions,
dismissSwipePan,
transforms,
}: Props) => {
const [isScaled, setIsScaled] = useState(false)
const committedTransform = useSharedValue(initialTransform)
const panTranslation = useSharedValue({ x: 0, y: 0 })
const pinchOrigin = useSharedValue({ x: 0, y: 0 })
const pinchScale = useSharedValue(1)
const pinchTranslation = useSharedValue({ x: 0, y: 0 })
const containerRef = useAnimatedRef()
// Keep track of when we're entering or leaving scaled rendering.
// Note: DO NOT move any logic reading animated values outside this function.
useAnimatedReaction(
() => {
if (pinchScale.get() !== 1) {
// We're currently pinching.
return true
}
const [, , committedScale] = readTransform(committedTransform.get())
if (committedScale !== 1) {
// We started from a pinched in state.
return true
}
// We're at rest.
return false
},
(nextIsScaled, prevIsScaled) => {
if (nextIsScaled !== prevIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
},
)
function handleZoom(nextIsScaled: boolean) {
setIsScaled(nextIsScaled)
onZoom(nextIsScaled)
}
// On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
function getExtraTranslationToStayInBounds(
candidateTransform: TransformMatrix,
screenSize: { width: number; height: number },
) {
"worklet"
if (!imageAspect) {
return [0, 0] as const
}
const [nextTranslateX, nextTranslateY, nextScale] = readTransform(candidateTransform)
const scaledDimensions = getScaledDimensions(imageAspect, nextScale, screenSize)
const clampedTranslateX = clampTranslation(
nextTranslateX,
scaledDimensions.width,
screenSize.width,
)
const clampedTranslateY = clampTranslation(
nextTranslateY,
scaledDimensions.height,
screenSize.height,
)
const dx = clampedTranslateX - nextTranslateX
const dy = clampedTranslateY - nextTranslateY
return [dx, dy] as const
}
const pinch = Gesture.Pinch()
.onStart((e) => {
"worklet"
const screenSize = measureSafeArea()
pinchOrigin.set({
x: e.focalX - screenSize.width / 2,
y: e.focalY - screenSize.height / 2,
})
})
.onChange((e) => {
"worklet"
const screenSize = measureSafeArea()
if (!imageDimensions) {
return
}
// Don't let the picture zoom in so close that it gets blurry.
// Also, like in stock Android apps, don't let the user zoom out further than 1:1.
const [, , committedScale] = readTransform(committedTransform.get())
const maxCommittedScale = Math.max(
MIN_SCREEN_ZOOM,
(imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
)
const minPinchScale = 1 / committedScale
const maxPinchScale = maxCommittedScale / committedScale
const nextPinchScale = Math.min(Math.max(minPinchScale, e.scale), maxPinchScale)
pinchScale.set(nextPinchScale)
// Zooming out close to the corner could push us out of bounds, which we don't want on Android.
// Calculate where we'll end up so we know how much to translate back to stay in bounds.
const t = createTransform()
prependPan(t, panTranslation.get())
prependPinch(t, nextPinchScale, pinchOrigin.get(), pinchTranslation.get())
prependTransform(t, committedTransform.get())
const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
if (dx !== 0 || dy !== 0) {
const pt = pinchTranslation.get()
pinchTranslation.set({
x: pt.x + dx,
y: pt.y + dy,
})
}
})
.onEnd(() => {
"worklet"
// Commit just the pinch.
const t = createTransform()
prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get())
prependTransform(t, committedTransform.get())
applyRounding(t)
committedTransform.set(t)
// Reset just the pinch.
pinchScale.set(1)
pinchOrigin.set({ x: 0, y: 0 })
pinchTranslation.set({ x: 0, y: 0 })
})
const pan = Gesture.Pan()
.averageTouches(true)
// Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
.minPointers(isScaled ? 1 : 2)
.onChange((e) => {
"worklet"
const screenSize = measureSafeArea()
if (!imageDimensions) {
return
}
const nextPanTranslation = { x: e.translationX, y: e.translationY }
const t = createTransform()
prependPan(t, nextPanTranslation)
prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get())
prependTransform(t, committedTransform.get())
// Prevent panning from going out of bounds.
const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
nextPanTranslation.x += dx
nextPanTranslation.y += dy
panTranslation.set(nextPanTranslation)
})
.onEnd(() => {
"worklet"
// Commit just the pan.
const t = createTransform()
prependPan(t, panTranslation.get())
prependTransform(t, committedTransform.get())
applyRounding(t)
committedTransform.set(t)
// Reset just the pan.
panTranslation.set({ x: 0, y: 0 })
})
const singleTap = Gesture.Tap().onEnd(() => {
"worklet"
runOnJS(onTap)()
})
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd((e) => {
"worklet"
const screenSize = measureSafeArea()
if (!imageDimensions || !imageAspect) {
return
}
const [, , committedScale] = readTransform(committedTransform.get())
if (committedScale !== 1) {
// Go back to 1:1 using the identity vector.
const t = createTransform()
committedTransform.set(withClampedSpring(t))
return
}
// Try to zoom in so that we get rid of the black bars (whatever the orientation was).
const screenAspect = screenSize.width / screenSize.height
const candidateScale = Math.max(
imageAspect / screenAspect,
screenAspect / imageAspect,
MIN_SCREEN_ZOOM,
)
// But don't zoom in so close that the picture gets blurry.
const maxScale = Math.max(
MIN_SCREEN_ZOOM,
(imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
)
const scale = Math.min(candidateScale, maxScale)
// Calculate where we would be if the user pinched into the double tapped point.
// We won't use this transform directly because it may go out of bounds.
const candidateTransform = createTransform()
const origin = {
x: e.absoluteX - screenSize.width / 2,
y: e.absoluteY - screenSize.height / 2,
}
prependPinch(candidateTransform, scale, origin, { x: 0, y: 0 })
// Now we know how much we went out of bounds, so we can shoot correctly.
const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform, screenSize)
const finalTransform = createTransform()
prependPinch(finalTransform, scale, origin, { x: dx, y: dy })
committedTransform.set(withClampedSpring(finalTransform))
})
const composedGesture = isScrollViewBeingDragged
? // If the parent is not at rest, provide a no-op gesture.
Gesture.Manual()
: Gesture.Exclusive(dismissSwipePan, Gesture.Simultaneous(pinch, pan), doubleTap, singleTap)
const containerStyle = useAnimatedStyle(() => {
const { scaleAndMoveTransform, isHidden } = transforms.get()
// Apply the active adjustments on top of the committed transform before the gestures.
// This is matrix multiplication, so operations are applied in the reverse order.
const t = createTransform()
prependPan(t, panTranslation.get())
prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get())
prependTransform(t, committedTransform.get())
const [translateX, translateY, scale] = readTransform(t)
const manipulationTransform = [{ translateX }, { translateY }, { scale }]
const screenSize = measureSafeArea()
return {
opacity: isHidden ? 0 : 1,
transform: scaleAndMoveTransform.concat(manipulationTransform),
width: screenSize.width,
maxHeight: screenSize.height,
alignSelf: "center",
aspectRatio: imageAspect ?? 1 /* force onLoad */,
}
})
const imageCropStyle = useAnimatedStyle(() => {
const { cropFrameTransform } = transforms.get()
return {
flex: 1,
overflow: "hidden",
transform: cropFrameTransform,
}
})
const imageStyle = useAnimatedStyle(() => {
const { cropContentTransform } = transforms.get()
return {
flex: 1,
transform: cropContentTransform,
opacity: imageAspect === undefined ? 0 : 1,
}
})
const [showLoader, setShowLoader] = useState(false)
const [hasLoaded, setHasLoaded] = useState(false)
useAnimatedReaction(
() => {
return transforms.get().isResting && !hasLoaded
},
(show, prevShow) => {
if (!prevShow && show) {
runOnJS(setShowLoader)(true)
} else if (prevShow && !show) {
runOnJS(setShowLoader)(false)
}
},
)
const { type } = imageSrc
const borderRadius = type === "circle-avi" ? 1e5 : type === "rect-avi" ? 20 : 0
return (
<GestureDetector gesture={composedGesture}>
<Animated.View ref={containerRef} style={[styles.container]} renderToHardwareTextureAndroid>
<Animated.View style={containerStyle}>
{showLoader && <ActivityIndicator size="small" color="#FFF" style={styles.loading} />}
<Animated.View style={imageCropStyle}>
<Animated.View style={imageStyle}>
<Image
contentFit="contain"
source={{ uri: imageSrc.uri }}
placeholderContentFit="contain"
placeholder={{ uri: imageSrc.thumbUri }}
accessibilityLabel={imageSrc.alt}
onLoad={
hasLoaded
? undefined
: (e) => {
setHasLoaded(true)
onLoad({ width: e.source.width, height: e.source.height })
}
}
style={{ flex: 1, borderRadius }}
accessibilityHint=""
accessibilityIgnoresInvertColors
cachePolicy="memory"
priority="high"
/>
</Animated.View>
</Animated.View>
</Animated.View>
</Animated.View>
</GestureDetector>
)
}
const styles = StyleSheet.create({
container: {
height: "100%",
overflow: "hidden",
justifyContent: "center",
},
loading: {
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
justifyContent: "center",
},
})
function getScaledDimensions(
imageAspect: number,
scale: number,
screenSize: { width: number; height: number },
): ImageDimensions {
"worklet"
const screenAspect = screenSize.width / screenSize.height
const isLandscape = imageAspect > screenAspect
if (isLandscape) {
return {
width: scale * screenSize.width,
height: (scale * screenSize.width) / imageAspect,
}
} else {
return {
width: scale * screenSize.height * imageAspect,
height: scale * screenSize.height,
}
}
}
function clampTranslation(value: number, scaledSize: number, screenSize: number): number {
"worklet"
// Figure out how much the user should be allowed to pan, and constrain the translation.
const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
return clampedValue
}
function withClampedSpring(value: any) {
"worklet"
return withSpring(value, { overshootClamping: true })
}
export default React.memo(ImageItem)

View File

@ -0,0 +1,339 @@
/**
* 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 { Image } from "expo-image"
import * as React from "react"
import { useState } from "react"
import { ActivityIndicator, StyleSheet } from "react-native"
import type { PanGesture } from "react-native-gesture-handler"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import type { SharedValue } from "react-native-reanimated"
import Animated, {
runOnJS,
useAnimatedProps,
useAnimatedReaction,
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated"
import { useSafeAreaFrame } from "react-native-safe-area-context"
import type { Dimensions as ImageDimensions, ImageSource, Transform } from "../../@types"
const MAX_ORIGINAL_IMAGE_ZOOM = 2
const MIN_SCREEN_ZOOM = 2
type Props = {
imageSrc: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
onLoad: (dims: ImageDimensions) => void
isScrollViewBeingDragged: boolean
showControls: boolean
measureSafeArea: () => {
x: number
y: number
width: number
height: number
}
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
}
const ImageItem = ({
imageSrc,
onTap,
onZoom,
onLoad,
showControls,
measureSafeArea,
imageAspect,
imageDimensions,
dismissSwipePan,
transforms,
}: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
const [scaled, setScaled] = useState(false)
const isDragging = useSharedValue(false)
const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()
const maxZoomScale = Math.max(
MIN_SCREEN_ZOOM,
imageDimensions
? (imageDimensions.width / screenSizeDelayedForJSThreadOnly.width) * MAX_ORIGINAL_IMAGE_ZOOM
: 1,
)
const scrollHandler = useAnimatedScrollHandler({
onScroll(e) {
"worklet"
const nextIsScaled = e.zoomScale > 1
if (scaled !== nextIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
},
onBeginDrag() {
"worklet"
isDragging.value = true
},
onEndDrag() {
"worklet"
isDragging.value = false
},
})
function handleZoom(nextIsScaled: boolean) {
onZoom(nextIsScaled)
setScaled(nextIsScaled)
}
function zoomTo(nextZoomRect: { x: number; y: number; width: number; height: number }) {
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
// @ts-ignore
scrollResponderRef?.scrollResponderZoomTo({
...nextZoomRect, // This rect is in screen coordinates
animated: true,
})
}
const singleTap = Gesture.Tap().onEnd(() => {
"worklet"
runOnJS(onTap)()
})
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd((e) => {
"worklet"
const screenSize = measureSafeArea()
const { absoluteX, absoluteY } = e
let nextZoomRect = {
x: 0,
y: 0,
width: screenSize.width,
height: screenSize.height,
}
const willZoom = !scaled
if (willZoom) {
nextZoomRect = getZoomRectAfterDoubleTap(imageAspect, absoluteX, absoluteY, screenSize)
}
runOnJS(zoomTo)(nextZoomRect)
})
const composedGesture = Gesture.Exclusive(dismissSwipePan, doubleTap, singleTap)
const containerStyle = useAnimatedStyle(() => {
const { scaleAndMoveTransform, isHidden } = transforms.get()
return {
flex: 1,
transform: scaleAndMoveTransform,
opacity: isHidden ? 0 : 1,
}
})
const imageCropStyle = useAnimatedStyle(() => {
const screenSize = measureSafeArea()
const { cropFrameTransform } = transforms.get()
return {
overflow: "hidden",
transform: cropFrameTransform,
width: screenSize.width,
maxHeight: screenSize.height,
alignSelf: "center",
aspectRatio: imageAspect ?? 1 /* force onLoad */,
opacity: imageAspect === undefined ? 0 : 1,
}
})
const imageStyle = useAnimatedStyle(() => {
const { cropContentTransform } = transforms.get()
return {
transform: cropContentTransform,
width: "100%",
aspectRatio: imageAspect ?? 1 /* force onLoad */,
opacity: imageAspect === undefined ? 0 : 1,
}
})
const [showLoader, setShowLoader] = useState(false)
const [hasLoaded, setHasLoaded] = useState(false)
useAnimatedReaction(
() => {
return transforms.get().isResting && !hasLoaded
},
(show, prevShow) => {
if (!prevShow && show) {
runOnJS(setShowLoader)(true)
} else if (prevShow && !show) {
runOnJS(setShowLoader)(false)
}
},
)
const { type } = imageSrc
const borderRadius = type === "circle-avi" ? 1e5 : type === "rect-avi" ? 20 : 0
const scrollViewProps = useAnimatedProps(() => ({
// Don't allow bounce at 1:1 rest so it can be swiped away.
bounces: scaled || isDragging.value,
}))
return (
<GestureDetector gesture={composedGesture}>
<Animated.ScrollView
// @ts-ignore Something's up with the types here
ref={scrollViewRef}
pinchGestureEnabled
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
maximumZoomScale={maxZoomScale}
onScroll={scrollHandler}
style={containerStyle}
animatedProps={scrollViewProps}
centerContent
>
{showLoader && <ActivityIndicator size="small" color="#FFF" style={styles.loading} />}
<Animated.View style={imageCropStyle}>
<Animated.View style={imageStyle}>
<Image
contentFit="contain"
source={{ uri: imageSrc.uri }}
placeholderContentFit="contain"
placeholder={{ uri: imageSrc.thumbUri }}
style={{ flex: 1, borderRadius }}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors
priority="high"
onLoad={
hasLoaded
? undefined
: (e) => {
setHasLoaded(true)
onLoad({ width: e.source.width, height: e.source.height })
}
}
/>
</Animated.View>
</Animated.View>
</Animated.ScrollView>
</GestureDetector>
)
}
const styles = StyleSheet.create({
loading: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
image: {
flex: 1,
},
})
const getZoomRectAfterDoubleTap = (
imageAspect: number | undefined,
touchX: number,
touchY: number,
screenSize: { width: number; height: number },
): {
x: number
y: number
width: number
height: number
} => {
"worklet"
if (!imageAspect) {
return {
x: 0,
y: 0,
width: screenSize.width,
height: screenSize.height,
}
}
// First, let's figure out how much we want to zoom in.
// We want to try to zoom in at least close enough to get rid of black bars.
const screenAspect = screenSize.width / screenSize.height
const zoom = Math.max(imageAspect / screenAspect, screenAspect / imageAspect, MIN_SCREEN_ZOOM)
// Unlike in the Android version, we don't constrain the *max* zoom level here.
// Instead, this is done in the ScrollView props so that it constraints pinch too.
// Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
// We already know the zoom level, so this gives us the rectangle size.
const rectWidth = screenSize.width / zoom
const rectHeight = screenSize.height / zoom
// Before we settle on the zoomed rect, figure out the safe area it has to be inside.
// We don't want to introduce new black bars or make existing black bars unbalanced.
let minX = 0
let minY = 0
let maxX = screenSize.width - rectWidth
let maxY = screenSize.height - rectHeight
if (imageAspect >= screenAspect) {
// The image has horizontal black bars. Exclude them from the safe area.
const renderedHeight = screenSize.width / imageAspect
const horizontalBarHeight = (screenSize.height - renderedHeight) / 2
minY += horizontalBarHeight
maxY -= horizontalBarHeight
} else {
// The image has vertical black bars. Exclude them from the safe area.
const renderedWidth = screenSize.height * imageAspect
const verticalBarWidth = (screenSize.width - renderedWidth) / 2
minX += verticalBarWidth
maxX -= verticalBarWidth
}
// Finally, we can position the rect according to its size and the safe area.
let rectX
if (maxX >= minX) {
// Content fills the screen horizontally so we have horizontal wiggle room.
// Try to keep the tapped point under the finger after zoom.
rectX = touchX - touchX / zoom
rectX = Math.min(rectX, maxX)
rectX = Math.max(rectX, minX)
} else {
// Keep the rect centered on the screen so that black bars are balanced.
rectX = screenSize.width / 2 - rectWidth / 2
}
let rectY
if (maxY >= minY) {
// Content fills the screen vertically so we have vertical wiggle room.
// Try to keep the tapped point under the finger after zoom.
rectY = touchY - touchY / zoom
rectY = Math.min(rectY, maxY)
rectY = Math.max(rectY, minY)
} else {
// Keep the rect centered on the screen so that black bars are balanced.
rectY = screenSize.height / 2 - rectHeight / 2
}
return {
x: rectX,
y: rectY,
height: rectHeight,
width: rectWidth,
}
}
export default React.memo(ImageItem)

View File

@ -0,0 +1,47 @@
// default implementation fallback for web
import * as React from "react"
import { View } from "react-native"
import type { PanGesture } from "react-native-gesture-handler"
import type { SharedValue } from "react-native-reanimated"
import type {
Dimensions,
Dimensions as ImageDimensions,
ImageSource,
Transform,
} from "../../@types"
type Props = {
imageSrc: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
onLoad: (dims: Dimensions) => void
isScrollViewBeingDragged: boolean
showControls: boolean
measureSafeArea: () => {
x: number
y: number
width: number
height: number
}
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
}
const ImageItem = (_props: Props) => {
return <View />
}
export default React.memo(ImageItem)

View File

@ -0,0 +1,778 @@
/**
* 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.
*
*/
// 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 * as ScreenOrientation from "expo-screen-orientation"
import * as React from "react"
import { useCallback, useMemo, useState } from "react"
import {
LayoutAnimation,
PixelRatio,
Pressable,
ScrollView,
StyleSheet,
Text,
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 type { AnimatedRef, SharedValue, WithSpringConfig } from "react-native-reanimated"
import Animated, {
cancelAnimation,
interpolate,
measure,
runOnJS,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useDerivedValue,
useReducedMotion,
useSharedValue,
withDecay,
withSpring,
} from "react-native-reanimated"
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
import { Download2CuteReIcon } from "@/src/icons/download_2_cute_re"
import { ShareForwardCuteReIcon } from "@/src/icons/share_forward_cute_re"
import { isIOS } from "@/src/lib/platform"
import type { Lightbox } from "../lightboxState"
import type { Dimensions, ImageSource, Transform } from "./@types"
import ImageDefaultHeader from "./components/ImageDefaultHeader"
import ImageItem from "./components/ImageItem/ImageItem"
type Rect = { x: number; y: number; width: number; height: number }
// const { PORTRAIT_UP } = ScreenOrientation.OrientationLock
const PIXEL_RATIO = PixelRatio.get()
const SLOW_SPRING: WithSpringConfig = {
mass: isIOS ? 1.25 : 0.75,
damping: 300,
stiffness: 800,
restDisplacementThreshold: 0.01,
}
const FAST_SPRING: WithSpringConfig = {
mass: isIOS ? 1.25 : 0.75,
damping: 150,
stiffness: 900,
restDisplacementThreshold: 0.01,
}
function canAnimate(lightbox: Lightbox): boolean {
return (
// !PlatformInfo.getIsReducedMotionEnabled() &&
lightbox.images.every((img) => img.thumbRect && (img.dimensions || img.thumbDimensions))
)
}
export default function ImageViewRoot({
lightbox: nextLightbox,
onRequestClose,
onPressSave,
onPressShare,
}: {
lightbox: Lightbox | null
onRequestClose: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
"use no memo"
const ref = useAnimatedRef<View>()
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait")
const openProgress = useSharedValue(0)
if (!activeLightbox && nextLightbox) {
setActiveLightbox(nextLightbox)
}
const reduceMotion = useReducedMotion()
React.useEffect(() => {
if (!nextLightbox) {
return
}
const isAnimated = canAnimate(nextLightbox) && !reduceMotion
// https://github.com/software-mansion/react-native-reanimated/issues/6677
rAF_FIXED(() => {
openProgress.set(() => (isAnimated ? withClampedSpring(1, SLOW_SPRING) : 1))
})
return () => {
// https://github.com/software-mansion/react-native-reanimated/issues/6677
rAF_FIXED(() => {
openProgress.set(() => (isAnimated ? withClampedSpring(0, SLOW_SPRING) : 0))
})
}
}, [nextLightbox, openProgress, reduceMotion])
useAnimatedReaction(
() => openProgress.get() === 0,
(isGone, wasGone) => {
if (isGone && !wasGone) {
runOnJS(setActiveLightbox)(null)
}
},
)
// Delay the unlock until after we've finished the scale up animation.
// It's complicated to do the same for locking it back so we don't attempt that.
// useAnimatedReaction(
// () => openProgress.get() === 1,
// (isOpen, wasOpen) => {
// if (isOpen && !wasOpen) {
// runOnJS(ScreenOrientation.unlockAsync)()
// } else if (!isOpen && wasOpen) {
// // default is PORTRAIT_UP - set via config plugin in app.config.js -sfn
// runOnJS(ScreenOrientation.lockAsync)(PORTRAIT_UP)
// }
// },
// )
const onFlyAway = React.useCallback(() => {
"worklet"
openProgress.set(0)
runOnJS(onRequestClose)()
}, [onRequestClose, openProgress])
return (
// Keep it always mounted to avoid flicker on the first frame.
<View
style={[styles.screen, !activeLightbox && styles.screenHidden]}
aria-modal
accessibilityViewIsModal
aria-hidden={!activeLightbox}
>
<Animated.View
ref={ref}
style={{ flex: 1 }}
collapsable={false}
onLayout={(e) => {
const { layout } = e.nativeEvent
setOrientation(layout.height > layout.width ? "portrait" : "landscape")
}}
>
{activeLightbox && (
<ImageView
key={`${activeLightbox.id}-${orientation}`}
lightbox={activeLightbox}
orientation={orientation}
onRequestClose={onRequestClose}
onPressSave={onPressSave}
onPressShare={onPressShare}
onFlyAway={onFlyAway}
safeAreaRef={ref}
openProgress={openProgress}
/>
)}
</Animated.View>
</View>
)
}
function ImageView({
lightbox,
orientation,
onRequestClose,
onPressSave,
onPressShare,
onFlyAway,
safeAreaRef,
openProgress,
}: {
lightbox: Lightbox
orientation: "portrait" | "landscape"
onRequestClose: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
onFlyAway: () => void
safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number>
}) {
const { images, index: initialImageIndex } = lightbox
const reduceMotion = useReducedMotion()
const isAnimated = useMemo(() => canAnimate(lightbox) && !reduceMotion, [lightbox, reduceMotion])
const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [imageIndex, setImageIndex] = useState(initialImageIndex)
const [showControls, setShowControls] = useState(true)
const [isAltExpanded, setAltExpanded] = React.useState(false)
const dismissSwipeTranslateY = useSharedValue(0)
const isFlyingAway = useSharedValue(false)
const containerStyle = useAnimatedStyle(() => {
if (openProgress.get() < 1) {
return {
pointerEvents: "none",
opacity: isAnimated ? 1 : 0,
}
}
if (isFlyingAway.get()) {
return {
pointerEvents: "none",
opacity: 1,
}
}
return { pointerEvents: "auto", opacity: 1 }
})
const backdropStyle = useAnimatedStyle(() => {
const screenSize = measure(safeAreaRef)
let opacity = 1
const openProgressValue = openProgress.get()
if (openProgressValue < 1) {
opacity = Math.sqrt(openProgressValue)
} else if (screenSize && orientation === "portrait") {
const dragProgress = Math.min(
Math.abs(dismissSwipeTranslateY.get()) / (screenSize.height / 2),
1,
)
opacity -= dragProgress
}
const factor = isIOS ? 100 : 50
return {
opacity: Math.round(opacity * factor) / factor,
}
})
const animatedHeaderStyle = useAnimatedStyle(() => {
const show = showControls && dismissSwipeTranslateY.get() === 0
return {
pointerEvents: show ? "box-none" : "none",
opacity: withClampedSpring(show && openProgress.get() === 1 ? 1 : 0, FAST_SPRING),
transform: [
{
translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING),
},
],
}
})
const animatedFooterStyle = useAnimatedStyle(() => {
const show = showControls && dismissSwipeTranslateY.get() === 0
return {
flexGrow: 1,
pointerEvents: show ? "box-none" : "none",
opacity: withClampedSpring(show && openProgress.get() === 1 ? 1 : 0, FAST_SPRING),
transform: [
{
translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING),
},
],
}
})
const onTap = useCallback(() => {
setShowControls((show) => !show)
}, [])
const onZoom = useCallback((nextIsScaled: boolean) => {
setIsScaled(nextIsScaled)
if (nextIsScaled) {
setShowControls(false)
}
}, [])
useAnimatedReaction(
() => {
const screenSize = measure(safeAreaRef)
return !screenSize || Math.abs(dismissSwipeTranslateY.get()) > screenSize.height
},
(isOut, wasOut) => {
if (isOut && !wasOut) {
// Stop the animation from blocking the screen forever.
cancelAnimation(dismissSwipeTranslateY)
onFlyAway()
}
},
)
// style system ui on android
// const t = useTheme()
// useEffect(() => {
// setSystemUITheme("lightbox", t)
// return () => {
// setSystemUITheme("theme", t)
// }
// }, [t])
return (
<View style={[styles.container, containerStyle]}>
<SystemBars
style={{ statusBar: "light", navigationBar: "light" }}
hidden={{
statusBar: isScaled || !showControls,
navigationBar: false,
}}
/>
<View style={[styles.backdrop, backdropStyle]} renderToHardwareTextureAndroid />
<PagerView
scrollEnabled={!isScaled}
initialPage={initialImageIndex}
onPageSelected={(e) => {
setImageIndex(e.nativeEvent.position)
setIsScaled(false)
}}
onPageScrollStateChanged={(e) => {
setIsDragging(e.nativeEvent.pageScrollState !== "idle")
}}
overdrag={true}
style={styles.pager}
>
{images.map((imageSrc, i) => (
<View key={imageSrc.uri}>
<LightboxImage
onTap={onTap}
onZoom={onZoom}
imageSrc={imageSrc}
onRequestClose={onRequestClose}
isScrollViewBeingDragged={isDragging}
showControls={showControls}
safeAreaRef={safeAreaRef}
isScaled={isScaled}
isFlyingAway={isFlyingAway}
isActive={i === imageIndex}
dismissSwipeTranslateY={dismissSwipeTranslateY}
openProgress={openProgress}
/>
</View>
))}
</PagerView>
<View style={styles.controls}>
<Animated.View style={animatedHeaderStyle} renderToHardwareTextureAndroid>
<ImageDefaultHeader onRequestClose={onRequestClose} />
</Animated.View>
<Animated.View style={animatedFooterStyle} renderToHardwareTextureAndroid={!isAltExpanded}>
<LightboxFooter
images={images}
index={imageIndex}
isAltExpanded={isAltExpanded}
toggleAltExpanded={() => setAltExpanded((e) => !e)}
onPressSave={onPressSave}
onPressShare={onPressShare}
/>
</Animated.View>
</View>
</View>
)
}
function LightboxImage({
imageSrc,
onTap,
onZoom,
onRequestClose,
isScrollViewBeingDragged,
isScaled,
isFlyingAway,
isActive,
showControls,
safeAreaRef,
openProgress,
dismissSwipeTranslateY,
}: {
imageSrc: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
isScrollViewBeingDragged: boolean
isScaled: boolean
isActive: boolean
isFlyingAway: SharedValue<boolean>
showControls: boolean
safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number>
dismissSwipeTranslateY: SharedValue<number>
}) {
const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(null)
const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions
let imageAspect: number | undefined
if (dims) {
imageAspect = dims.width / dims.height
if (Number.isNaN(imageAspect)) {
imageAspect = undefined
}
}
const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame()
const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets()
const measureSafeArea = React.useCallback(() => {
"worklet"
let safeArea: Rect | null = measure(safeAreaRef)
if (!safeArea) {
if (_WORKLET) {
console.error("Expected to always be able to measure safe area.")
}
const frame = safeFrameDelayedForJSThreadOnly
const insets = safeInsetsDelayedForJSThreadOnly
safeArea = {
x: frame.x + insets.left,
y: frame.y + insets.top,
width: frame.width - insets.left - insets.right,
height: frame.height - insets.top - insets.bottom,
}
}
return safeArea
}, [safeFrameDelayedForJSThreadOnly, safeInsetsDelayedForJSThreadOnly, safeAreaRef])
const { thumbRect } = imageSrc
const transforms = useDerivedValue(() => {
"worklet"
const safeArea = measureSafeArea()
const openProgressValue = openProgress.get()
const dismissTranslateY = isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0
if (openProgressValue === 0 && isFlyingAway.get()) {
return {
isHidden: true,
isResting: false,
scaleAndMoveTransform: [],
cropFrameTransform: [],
cropContentTransform: [],
}
}
if (isActive && thumbRect && imageAspect && openProgressValue < 1) {
return interpolateTransform(openProgressValue, thumbRect, safeArea, imageAspect)
}
return {
isHidden: false,
isResting: dismissTranslateY === 0,
scaleAndMoveTransform: [{ translateY: dismissTranslateY }],
cropFrameTransform: [],
cropContentTransform: [],
}
})
const dismissSwipePan = Gesture.Pan()
.enabled(isActive && !isScaled)
.activeOffsetY([-10, 10])
.failOffsetX([-10, 10])
.maxPointers(1)
.onUpdate((e) => {
"worklet"
if (openProgress.get() !== 1 || isFlyingAway.get()) {
return
}
dismissSwipeTranslateY.set(e.translationY)
})
.onEnd((e) => {
"worklet"
if (openProgress.get() !== 1 || isFlyingAway.get()) {
return
}
if (Math.abs(e.velocityY) > 200) {
isFlyingAway.set(true)
if (dismissSwipeTranslateY.get() === 0) {
// HACK: If the initial value is 0, withDecay() animation doesn't start.
// This is a bug in Reanimated, but for now we'll work around it like this.
dismissSwipeTranslateY.set(1)
}
dismissSwipeTranslateY.set(() => {
"worklet"
return withDecay({
velocity: e.velocityY,
velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
deceleration: 1, // Danger! This relies on the reaction below stopping it.
})
})
} else {
dismissSwipeTranslateY.set(() => {
"worklet"
return withSpring(0, {
stiffness: 700,
damping: 50,
})
})
}
})
return (
<ImageItem
imageSrc={imageSrc}
onTap={onTap}
onZoom={onZoom}
onRequestClose={onRequestClose}
onLoad={setFetchedDims}
isScrollViewBeingDragged={isScrollViewBeingDragged}
showControls={showControls}
measureSafeArea={measureSafeArea}
imageAspect={imageAspect}
imageDimensions={dims ?? undefined}
dismissSwipePan={dismissSwipePan}
transforms={transforms}
/>
)
}
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 insets = useSafeAreaInsets()
const image = images.at(index)
const altText = image?.alt
const uri = image?.uri
const isMomentumScrolling = React.useRef(false)
if (!image || !uri) {
// If the image is not available, we don't render the footer.
return null
}
return (
<ScrollView
style={styles.footerScrollView}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={{
paddingVertical: 12,
paddingHorizontal: 24,
}}
>
<View style={{ marginBottom: insets.bottom }}>
{altText ? (
<View accessibilityRole="button" style={styles.footerText}>
<Text
className="text-gray-3"
numberOfLines={isAltExpanded ? undefined : 3}
selectable
onPress={() => {
if (isMomentumScrolling.current) {
return
}
LayoutAnimation.configureNext({
duration: 450,
update: { type: "spring", springDamping: 1 },
})
toggleAltExpanded()
}}
onLongPress={() => {}}
>
{altText}
</Text>
</View>
) : null}
<View style={styles.footerBtns}>
<Pressable
className="rounded-3xl border border-white px-4 py-2"
style={styles.footerBtn}
onPress={() => onPressSave(uri)}
>
<Download2CuteReIcon color="#fff" />
<Text className="text-xl text-white">Save</Text>
</Pressable>
<Pressable
className="rounded-3xl border border-white px-4 py-2"
style={styles.footerBtn}
onPress={() => onPressShare(uri)}
>
<ShareForwardCuteReIcon color="#fff" />
<Text className="text-xl text-white">Share</Text>
</Pressable>
</View>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
screen: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
},
screenHidden: {
opacity: 0,
pointerEvents: "none",
},
container: {
flex: 1,
},
backdrop: {
backgroundColor: "#000",
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
controls: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
gap: 20,
zIndex: 1,
pointerEvents: "box-none",
},
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: isIOS ? 20 : 16,
},
footerBtns: {
flexDirection: "row",
justifyContent: "center",
gap: 8,
},
footerBtn: {
flexDirection: "row",
alignItems: "center",
gap: 8,
backgroundColor: "transparent",
},
})
function interpolatePx(px: number, inputRange: readonly number[], outputRange: readonly number[]) {
"worklet"
const value = interpolate(px, inputRange, outputRange)
return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO
}
function interpolateTransform(
progress: number,
thumbnailDims: {
pageX: number
width: number
pageY: number
height: number
},
safeArea: { width: number; height: number; x: number; y: number },
imageAspect: number,
): {
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
} {
"worklet"
const thumbAspect = thumbnailDims.width / thumbnailDims.height
let uncroppedInitialWidth
let uncroppedInitialHeight
if (imageAspect > thumbAspect) {
uncroppedInitialWidth = thumbnailDims.height * imageAspect
uncroppedInitialHeight = thumbnailDims.height
} else {
uncroppedInitialWidth = thumbnailDims.width
uncroppedInitialHeight = thumbnailDims.width / imageAspect
}
const safeAreaAspect = safeArea.width / safeArea.height
let finalWidth
let finalHeight
if (safeAreaAspect > imageAspect) {
finalWidth = safeArea.height * imageAspect
finalHeight = safeArea.height
} else {
finalWidth = safeArea.width
finalHeight = safeArea.width / imageAspect
}
const initialScale = Math.min(
uncroppedInitialWidth / finalWidth,
uncroppedInitialHeight / finalHeight,
)
const croppedFinalWidth = thumbnailDims.width / initialScale
const croppedFinalHeight = thumbnailDims.height / initialScale
const screenCenterX = safeArea.width / 2
const screenCenterY = safeArea.height / 2
const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x
const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y
const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2
const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2
const initialTranslateX = thumbnailCenterX - screenCenterX
const initialTranslateY = thumbnailCenterY - screenCenterY
const scale = interpolate(progress, [0, 1], [initialScale, 1])
const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0])
const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0])
const cropScaleX = interpolate(progress, [0, 1], [croppedFinalWidth / finalWidth, 1])
const cropScaleY = interpolate(progress, [0, 1], [croppedFinalHeight / finalHeight, 1])
return {
isHidden: false,
isResting: progress === 1,
scaleAndMoveTransform: [{ translateX }, { translateY }, { scale }],
cropFrameTransform: [{ scaleX: cropScaleX }, { scaleY: cropScaleY }],
cropContentTransform: [{ scaleX: 1 / cropScaleX }, { scaleY: 1 / cropScaleY }],
}
}
function withClampedSpring(value: any, config: WithSpringConfig) {
"worklet"
return withSpring(value, { ...config, overshootClamping: true })
}
// We have to do this because we can't trust RN's rAF to fire in order.
// https://github.com/facebook/react-native/issues/48005
let isFrameScheduled = false
let pendingFrameCallbacks: Array<() => void> = []
function rAF_FIXED(callback: () => void) {
pendingFrameCallbacks.push(callback)
if (!isFrameScheduled) {
isFrameScheduled = true
requestAnimationFrame(() => {
const callbacks = pendingFrameCallbacks.slice()
isFrameScheduled = false
pendingFrameCallbacks = []
let hasError = false
let error
for (const callback_ of callbacks) {
try {
callback_()
} catch (e) {
hasError = true
error = e
}
}
if (hasError) {
throw error
}
})
}
}

View File

@ -0,0 +1,98 @@
import type { Position } from "./@types"
export type TransformMatrix = [
number,
number,
number,
number,
number,
number,
number,
number,
number,
]
// These are affine transforms. See explanation of every cell here:
// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg
export function createTransform(): TransformMatrix {
"worklet"
return [1, 0, 0, 0, 1, 0, 0, 0, 1]
}
export function applyRounding(t: TransformMatrix) {
"worklet"
t[2] = Math.round(t[2])
t[5] = Math.round(t[5])
// For example: 0.985, 0.99, 0.995, then 1:
t[0] = Math.round(t[0] * 200) / 200
t[4] = Math.round(t[0] * 200) / 200
}
// We're using a limited subset (always scaling and translating while keeping aspect ratio) so
// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching.
// All write operations are applied in-place to avoid unnecessary allocations.
export function readTransform(t: TransformMatrix): [number, number, number] {
"worklet"
const scale = t[0]
const translateX = t[2]
const translateY = t[5]
return [translateX, translateY, scale]
}
export function prependTranslate(t: TransformMatrix, x: number, y: number) {
"worklet"
t[2] += t[0] * x + t[1] * y
t[5] += t[3] * x + t[4] * y
}
export function prependScale(t: TransformMatrix, value: number) {
"worklet"
t[0] *= value
t[1] *= value
t[3] *= value
t[4] *= value
}
export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) {
"worklet"
// In-place matrix multiplication.
const a00 = ta[0],
a01 = ta[1],
a02 = ta[2]
const a10 = ta[3],
a11 = ta[4],
a12 = ta[5]
const a20 = ta[6],
a21 = ta[7],
a22 = ta[8]
ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6]
ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7]
ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8]
ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6]
ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7]
ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8]
ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6]
ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7]
ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8]
}
export function prependPan(t: TransformMatrix, translation: Position) {
"worklet"
prependTranslate(t, translation.x, translation.y)
}
export function prependPinch(
t: TransformMatrix,
scale: number,
origin: Position,
translation: Position,
) {
"worklet"
prependTranslate(t, translation.x, translation.y)
prependTranslate(t, origin.x, origin.y)
prependScale(t, scale)
prependTranslate(t, -origin.x, -origin.y)
}

View File

@ -0,0 +1,27 @@
import { useCallback } from "react"
import { shareImage, useSaveImageToMediaLibrary } from "../ui/image/utils"
import ImageView from "./ImageViewing"
import { useLightbox, useLightboxControls } from "./lightboxState"
export function Lightbox() {
const { activeLightbox } = useLightbox()
const { closeLightbox } = useLightboxControls()
const onClose = useCallback(() => {
closeLightbox()
}, [closeLightbox])
const saveImageToAlbum = useSaveImageToMediaLibrary()
return (
<ImageView
lightbox={activeLightbox}
onRequestClose={onClose}
onPressSave={saveImageToAlbum}
onPressShare={(uri) => {
shareImage({ uri })
}}
/>
)
}

View File

@ -0,0 +1,81 @@
import { nanoid } from "nanoid/non-secure"
import type { PropsWithChildren } from "react"
import { createContext, use, useCallback, useMemo, useState } from "react"
import type { ImageSource } from "./ImageViewing/@types"
export type Lightbox = {
id: string
images: ImageSource[]
index: number
}
const LightboxContext = createContext<{
activeLightbox: Lightbox | null
}>({
activeLightbox: null,
})
const LightboxControlContext = createContext<{
openLightbox: (lightbox: Omit<Lightbox, "id">) => void
closeLightbox: () => boolean
}>({
openLightbox: () => {
console.error("LightboxControlContext: openLightbox called without provider")
},
closeLightbox: () => {
console.error("LightboxControlContext: closeLightbox called without provider")
return false
},
})
export function LightboxStateProvider({ children }: PropsWithChildren) {
const [activeLightbox, setActiveLightbox] = useState<Lightbox | null>(null)
const openLightbox = useCallback((lightbox: Omit<Lightbox, "id">) => {
setActiveLightbox((prevLightbox) => {
if (prevLightbox) {
// Ignore duplicate open requests. If it's already open,
// the user has to explicitly close the previous one first.
return prevLightbox
} else {
return { ...lightbox, id: nanoid() }
}
})
}, [])
const closeLightbox = useCallback(() => {
const wasActive = !!activeLightbox
setActiveLightbox(null)
return wasActive
}, [activeLightbox])
const state = useMemo(
() => ({
activeLightbox,
}),
[activeLightbox],
)
const methods = useMemo(
() => ({
openLightbox,
closeLightbox,
}),
[openLightbox, closeLightbox],
)
return (
<LightboxContext value={state}>
<LightboxControlContext value={methods}>{children}</LightboxControlContext>
</LightboxContext>
)
}
export function useLightbox() {
return use(LightboxContext)
}
export function useLightboxControls() {
return use(LightboxControlContext)
}

View File

@ -4,11 +4,13 @@ import type * as React from "react"
import type { RefObject } from "react"
import { useCallback, useRef } from "react"
import type { ViewProps } from "react-native"
import { runOnJS, runOnUI } from "react-native-reanimated"
import type { WebViewNavigation } from "react-native-webview"
import WebView from "react-native-webview"
import { openLink } from "@/src/lib/native"
import { useLightboxControls } from "../../lightbox/lightboxState"
import { htmlUrl } from "./constants"
import { atEnd, atStart } from "./injected-js"
@ -27,6 +29,10 @@ export const injectJavaScript = (js: string) => {
return webview.injectJavaScript(js)
}
const onLoadEnd = () => {
injectJavaScript(atEnd)
}
export const NativeWebView: React.ComponentType<
ViewProps & {
onContentHeightChange?: (e: { nativeEvent: { height: number } }) => void
@ -35,6 +41,7 @@ export const NativeWebView: React.ComponentType<
> = ({ onContentHeightChange }) => {
const webViewRef = useRef<WebView | null>(null)
const { onNavigationStateChange } = useWebViewNavigation({ webViewRef })
const { openLightbox } = useLightboxControls()
return (
<WebView
@ -55,9 +62,7 @@ export const NativeWebView: React.ComponentType<
allowsFullscreenVideo
injectedJavaScriptBeforeContentLoaded={atStart}
onNavigationStateChange={onNavigationStateChange}
onLoadEnd={useCallback(() => {
injectJavaScript(atEnd)
}, [])}
onLoadEnd={onLoadEnd}
onMessage={(e) => {
const message = e.nativeEvent.data
const parsed = JSON.parse(message)
@ -66,6 +71,24 @@ export const NativeWebView: React.ComponentType<
nativeEvent: { height: parsed.payload },
})
return
} else if (parsed.type === "previewImage") {
const { imageUrls, index } = parsed.payload
runOnUI(() => {
"worklet"
// const rect = measureHandle(aviHandle)
runOnJS(openLightbox)({
images: (imageUrls as string[]).map((url: string) => ({
uri: url,
dimensions: null,
thumbUri: url,
thumbDimensions: null,
thumbRect: null,
type: "image",
})),
index,
})
})()
return
}
}}
/>

View File

@ -91,7 +91,7 @@ export const MediaCarousel = ({
if (m.type === "photo") {
return (
<View
key={index}
key={imageUrl}
className="relative"
style={{ width: containerWidth, height: containerHeight }}
>
@ -102,7 +102,12 @@ export const MediaCarousel = ({
)
} else if (m.type === "video") {
return (
<ImageContextMenu key={index} entryId={entryId} imageUrl={imageUrl} view={view}>
<ImageContextMenu
key={imageUrl}
entryId={entryId}
imageUrl={imageUrl}
view={view}
>
<VideoPlayer
source={m.url}
height={containerHeight}

View File

@ -1,5 +1,6 @@
import type { FeedViewType } from "@follow/constants"
import type { FeedSchema } from "@follow/database/schemas/types"
import { cn } from "@follow/utils"
import type { ReactNode } from "react"
import { useCallback, useMemo, useState } from "react"
@ -48,7 +49,9 @@ export function FeedIcon({
const handleError = useCallback(() => setIsError(true), [])
if (!src || isError) {
return <FallbackIcon title={feed?.title ?? ""} size={size} />
return (
<FallbackIcon title={feed?.title ?? ""} size={size} className={cn("rounded", className)} />
)
}
return (
<Image
@ -56,7 +59,7 @@ export function FeedIcon({
width: size,
height: size,
}}
className="rounded"
className={cn("rounded", className)}
style={{ height: size, width: size }}
source={{ uri: src }}
onError={handleError}

View File

@ -1,4 +1,5 @@
import { useMemo } from "react"
import { useCallback, useMemo } from "react"
import { TouchableOpacity } from "react-native"
import { GaleriaContext } from "./context"
import type { Galeria as GaleriaInterface } from "./index.ios"
@ -36,7 +37,17 @@ const Galeria: typeof GaleriaInterface = Object.assign(
},
{
Image(props: GaleriaViewProps) {
return props.children
// const { urls, initialIndex } = use(GaleriaContext)
return (
<TouchableOpacity
onPress={useCallback(() => {
props.onPreview?.({ nativeEvent: { index: props.index ?? 0 } })
}, [props])}
>
{props.children}
</TouchableOpacity>
)
},
},
) as unknown as typeof GaleriaInterface

View File

@ -1,18 +1,24 @@
import { useEffect } from "react"
import { BackHandler } from "react-native"
import { useLightboxControls } from "../components/lightbox/lightboxState"
import { useCanDismiss, useNavigation } from "../lib/navigation/hooks"
import { isAndroid } from "../lib/platform"
export const useBackHandler = () => {
const navigation = useNavigation()
const canDismiss = useCanDismiss()
const { closeLightbox } = useLightboxControls()
useEffect(() => {
if (!isAndroid) return
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener -- listener.remove() handles cleanup
const listener = BackHandler.addEventListener("hardwareBackPress", () => {
const lightboxWasActive = closeLightbox()
if (lightboxWasActive) {
return true
}
if (canDismiss) {
navigation.dismiss()
return true
@ -26,5 +32,5 @@ export const useBackHandler = () => {
return () => {
listener.remove()
}
}, [canDismiss, navigation])
}, [canDismiss, closeLightbox, navigation])
}

View File

@ -8,7 +8,6 @@ import { StyleSheet } from "react-native"
import {
SafeAreaFrameContext,
SafeAreaInsetsContext,
SafeAreaProvider,
useSafeAreaFrame,
useSafeAreaInsets,
} from "react-native-safe-area-context"
@ -39,24 +38,22 @@ interface RootStackNavigationProps {
}
export const RootStackNavigation = ({ children, headerConfig }: RootStackNavigationProps) => {
return (
<SafeAreaProvider>
<AttachNavigationScrollViewProvider>
<ScreenNameContext value={useMemo(() => atom(""), [])}>
<ChainNavigationContext value={Navigation.rootNavigation.__dangerous_getCtxValue()}>
<NavigationInstanceContext value={Navigation.rootNavigation}>
<ScreenStack style={StyleSheet.absoluteFill}>
<WrappedScreenItem headerConfig={headerConfig} screenId="root">
{children}
</WrappedScreenItem>
<AttachNavigationScrollViewProvider>
<ScreenNameContext value={useMemo(() => atom(""), [])}>
<ChainNavigationContext value={Navigation.rootNavigation.__dangerous_getCtxValue()}>
<NavigationInstanceContext value={Navigation.rootNavigation}>
<ScreenStack style={StyleSheet.absoluteFill}>
<WrappedScreenItem headerConfig={headerConfig} screenId="root">
{children}
</WrappedScreenItem>
<ScreenItemsMapper />
<StateHandler />
</ScreenStack>
</NavigationInstanceContext>
</ChainNavigationContext>
</ScreenNameContext>
</AttachNavigationScrollViewProvider>
</SafeAreaProvider>
<ScreenItemsMapper />
<StateHandler />
</ScreenStack>
</NavigationInstanceContext>
</ChainNavigationContext>
</ScreenNameContext>
</AttachNavigationScrollViewProvider>
)
}

View File

@ -11,6 +11,7 @@ import { enableFreeze } from "react-native-screens"
import { App } from "./App"
import { BottomTabProvider } from "./components/layouts/tabbar/BottomTabProvider"
import { BottomTabs } from "./components/layouts/tabbar/BottomTabs"
import { Lightbox } from "./components/lightbox/Lightbox"
import { initializeApp } from "./initialize"
import { apiClient } from "./lib/api-fetch"
import { authClient } from "./lib/auth"
@ -76,6 +77,7 @@ function RootComponent() {
</TabRoot>
</App>
</RootStackNavigation>
<Lightbox />
</BottomTabProvider>
</RootProviders>
)

View File

@ -341,6 +341,7 @@ const AspectRatioImage = ({
source={{
uri: image,
}}
className="rounded-lg"
style={{
width: scaledWidth,
height: scaledHeight,

View File

@ -8,13 +8,18 @@ import { tracker } from "@follow/tracker"
import { uniqBy } from "es-toolkit/compat"
import { useMemo } from "react"
import { Text, View } from "react-native"
import { runOnJS, runOnUI } from "react-native-reanimated"
import { useLightboxControls } from "@/src/components/lightbox/lightboxState"
import { showEntryGaleriaAccessory } from "@/src/components/native/GaleriaAccessory/EntryGaleriaAccessory"
import { preloadWebViewEntry } from "@/src/components/native/webview/EntryContentWebView"
import { MediaCarousel } from "@/src/components/ui/carousel/MediaCarousel"
import { getFeedIconSource } from "@/src/lib/image"
import { isIOS } from "@/src/lib/platform"
export function EntryPictureItem({ id }: { id: string }) {
const { openLightbox } = useLightboxControls()
const item = useEntry(id, (state) => ({
media: state.media,
feedId: state.feedId,
@ -51,11 +56,29 @@ export function EntryPictureItem({ id }: { id: string }) {
entryId: id,
})
showEntryGaleriaAccessory({
author: item.author || "",
avatarUrl: getFeedIconSource(feed, "") ?? "",
publishedAt: item.publishedAt.toISOString(),
})
if (isIOS) {
showEntryGaleriaAccessory({
author: item.author || "",
avatarUrl: getFeedIconSource(feed, "") ?? "",
publishedAt: item.publishedAt.toISOString(),
})
} else {
runOnUI(() => {
"worklet"
// const rect = measureHandle(aviHandle)
runOnJS(openLightbox)({
images: (item.media ?? []).map((media) => ({
uri: media.url,
dimensions: null,
thumbUri: media.url,
thumbDimensions: null,
thumbRect: null,
type: "image",
})),
index: 0,
})
})()
}
const fullEntry = getEntry(id)
preloadWebViewEntry(fullEntry)
unreadSyncService.markEntryAsRead(id)

View File

@ -4,14 +4,15 @@ import { useFeedById } from "@follow/store/feed/hooks"
import { useEntryTranslation } from "@follow/store/translation/hooks"
import { unreadSyncService } from "@follow/store/unread/store"
import { tracker } from "@follow/tracker"
import { memo, useCallback, useMemo } from "react"
import { memo, useCallback } from "react"
import { Pressable, Text, View } from "react-native"
import { runOnJS, runOnUI } from "react-native-reanimated"
import { useActionLanguage, useGeneralSettingKey } from "@/src/atoms/settings/general"
import { useLightboxControls } from "@/src/components/lightbox/lightboxState"
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { Galeria } from "@/src/components/ui/image/galeria"
import { Image } from "@/src/components/ui/image/Image"
import { ItemPressableStyle } from "@/src/components/ui/pressable/enum"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
@ -40,6 +41,7 @@ export const EntrySocialItem = memo(
}))
const actionLanguage = useActionLanguage()
const translation = useEntryTranslation(entryId, actionLanguage)
const { openLightbox } = useLightboxControls()
const feed = useFeedById(entry?.feedId || "")
@ -59,21 +61,47 @@ export const EntrySocialItem = memo(
const autoExpandLongSocialMedia = useGeneralSettingKey("autoExpandLongSocialMedia")
const memoedMediaUrlList = useMemo(() => {
return (entry?.media
?.map((i) =>
i.type === "video" ? i.preview_image_url : i.type === "photo" ? i.url : undefined,
)
.filter(Boolean) || []) as string[]
}, [entry])
const navigationToFeedEntryList = useCallback(() => {
if (!entry) return
if (!entry.feedId) return
navigation.pushControllerView(FeedScreen, {
feedId: entry.feedId,
})
}, [entry?.feedId, navigation])
}, [entry, navigation])
const onPreviewImage = useCallback(
(index: number) => {
runOnUI(() => {
"worklet"
// const rect = measureHandle(aviHandle)
runOnJS(openLightbox)({
images: (entry?.media ?? [])
.map((mediaItem) => {
const imageUrl =
mediaItem.type === "video"
? mediaItem.preview_image_url
: mediaItem.type === "photo"
? mediaItem.url
: undefined
return {
uri: imageUrl ?? "",
dimensions: {
width: mediaItem.width ?? 0,
height: mediaItem.height ?? 0,
},
thumbUri: imageUrl ?? "",
thumbDimensions: null,
thumbRect: null,
type: "image" as const,
}
})
.filter((i) => !!i.uri),
index,
})
})()
},
[entry?.media, openLightbox],
)
if (!entry) return <EntryItemSkeleton />
@ -127,7 +155,7 @@ export const EntrySocialItem = memo(
{media && media.length > 0 && (
<View className="ml-10 flex flex-row flex-wrap justify-between">
<Galeria urls={memoedMediaUrlList}>
<>
{media.map((mediaItem, index) => {
const imageUrl =
mediaItem.type === "video"
@ -139,7 +167,11 @@ export const EntrySocialItem = memo(
if (!imageUrl) return null
const ImageItem = (
<Galeria.Image index={index}>
<NativePressable
onPress={() => {
onPreviewImage(index)
}}
>
<Image
proxy={{
width: fullWidth ? 400 : 200,
@ -153,7 +185,7 @@ export const EntrySocialItem = memo(
: 1
}
/>
</Galeria.Image>
</NativePressable>
)
if (mediaItem.type === "video") {
@ -180,7 +212,7 @@ export const EntrySocialItem = memo(
</Pressable>
)
})}
</Galeria>
</>
</View>
)}
</ItemPressable>

View File

@ -2,9 +2,9 @@ import type { FeedViewType } from "@follow/constants"
import { useViewWithSubscription } from "@follow/store/subscription/hooks"
import { EventBus } from "@follow/utils/event-bus"
import * as Haptics from "expo-haptics"
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useId, useMemo, useRef } from "react"
import type { StyleProp, ViewStyle } from "react-native"
import { Animated, StyleSheet } from "react-native"
import { Animated, StyleSheet, View } from "react-native"
import PagerView from "react-native-pager-view"
import { useSharedValue } from "react-native-reanimated"
@ -43,7 +43,7 @@ export function PagerList({
}, [activeViews, pagerRef, rid])
const userInitiatedDragRef = useSharedValue(false)
const [dragging, setDragging] = useState(false)
// const [dragging, setDragging] = useState(false)
const pageScrollHandler = useCallback(
(e: {
nativeEvent: {
@ -51,6 +51,8 @@ export function PagerList({
offset: number
}
}) => {
"worklet"
const { position, offset } = e.nativeEvent
if (!userInitiatedDragRef.value) {
@ -84,16 +86,16 @@ export function PagerList({
style={[styles.PagerView, style]}
initialPage={activeViewIndex}
layoutDirection="ltr"
offscreenPageLimit={3}
offscreenPageLimit={1}
overdrag
onPageScroll={pageScrollHandler}
onPageScrollStateChanged={(e) => {
const { pageScrollState } = e.nativeEvent
if (pageScrollState === "dragging") {
setDragging(true)
// setDragging(true)
userInitiatedDragRef.value = true
} else if (pageScrollState === "idle") {
setDragging(false)
// setDragging(false)
}
if (pageScrollState === "settling") {
@ -105,16 +107,21 @@ export function PagerList({
>
{useMemo(
() =>
activeViews.map((view, index) => (
<PagerListVisibleContext value={index === activeViewIndex} key={view}>
<PagerListWillVisibleContext
value={(index === activeViewIndex + 1 || index === activeViewIndex - 1) && dragging}
>
{renderItem(view, index === activeViewIndex)}
</PagerListWillVisibleContext>
</PagerListVisibleContext>
)),
[activeViews, activeViewIndex, dragging, renderItem],
activeViews.map((view, index) => {
const isActive = index === activeViewIndex
const willVisible = index === activeViewIndex + 1 || index === activeViewIndex - 1
if (!isActive && !willVisible) {
return <View key={view} />
}
return (
<PagerListVisibleContext value={isActive} key={view}>
<PagerListWillVisibleContext value={willVisible}>
{renderItem(view, isActive)}
</PagerListWillVisibleContext>
</PagerListVisibleContext>
)
}),
[activeViews, activeViewIndex, renderItem],
)}
</AnimatedPagerView>
)

View File

@ -9,11 +9,13 @@ import type { ReactNode } from "react"
import { StyleSheet, View } from "react-native"
import { GestureHandlerRootView } from "react-native-gesture-handler"
import { KeyboardProvider } from "react-native-keyboard-controller"
import { SafeAreaProvider } from "react-native-safe-area-context"
import { SheetProvider } from "react-native-sheet-transitions"
import { useCurrentColorsVariants } from "react-native-uikit-colors"
import { ErrorBoundary } from "../components/common/ErrorBoundary"
import { GlobalErrorScreen } from "../components/errors/GlobalErrorScreen"
import { LightboxStateProvider } from "../components/lightbox/lightboxState"
import { queryClient } from "../lib/query-client"
import { MigrationProvider } from "./migration"
import { ServerConfigsProvider } from "./ServerConfigsProvider"
@ -33,7 +35,11 @@ export const RootProviders = ({ children }: { children: ReactNode }) => {
<GestureHandlerRootView>
<SheetProvider>
<ActionSheetProvider>
<PortalProvider>{children}</PortalProvider>
<LightboxStateProvider>
<PortalProvider>
<SafeAreaProvider>{children}</SafeAreaProvider>
</PortalProvider>
</LightboxStateProvider>
</ActionSheetProvider>
<ServerConfigsProvider />
</SheetProvider>