diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 5df754bd19..a4e2c0db8f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -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", diff --git a/apps/mobile/plugins/with-android-manifest-plugin.js b/apps/mobile/plugins/with-android-manifest-plugin.js new file mode 100644 index 0000000000..86f82ee67f --- /dev/null +++ b/apps/mobile/plugins/with-android-manifest-plugin.js @@ -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 + }) +} diff --git a/apps/mobile/src/components/lightbox/ImageViewing/@types/index.ts b/apps/mobile/src/components/lightbox/ImageViewing/@types/index.ts new file mode 100644 index 0000000000..a960cb0dcf --- /dev/null +++ b/apps/mobile/src/components/lightbox/ImageViewing/@types/index.ts @@ -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 diff --git a/apps/mobile/src/components/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/apps/mobile/src/components/lightbox/ImageViewing/components/ImageDefaultHeader.tsx new file mode 100644 index 0000000000..1944cfd5ec --- /dev/null +++ b/apps/mobile/src/components/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -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 ( + + + + + + ) +} + +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/apps/mobile/src/components/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/apps/mobile/src/components/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx new file mode 100644 index 0000000000..dc0c57c5e7 --- /dev/null +++ b/apps/mobile/src/components/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -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 ( + + + + {showLoader && } + + + { + setHasLoaded(true) + onLoad({ width: e.source.width, height: e.source.height }) + } + } + style={{ flex: 1, borderRadius }} + accessibilityHint="" + accessibilityIgnoresInvertColors + cachePolicy="memory" + priority="high" + /> + + + + + + ) +} + +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) diff --git a/apps/mobile/src/components/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/apps/mobile/src/components/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx new file mode 100644 index 0000000000..9765ac0d10 --- /dev/null +++ b/apps/mobile/src/components/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -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() + 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 ( + + + {showLoader && } + + + { + setHasLoaded(true) + onLoad({ width: e.source.width, height: e.source.height }) + } + } + /> + + + + + ) +} + +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) diff --git a/apps/mobile/src/components/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/apps/mobile/src/components/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx new file mode 100644 index 0000000000..4d0e148189 --- /dev/null +++ b/apps/mobile/src/components/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -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 +} + +export default React.memo(ImageItem) diff --git a/apps/mobile/src/components/lightbox/ImageViewing/index.tsx b/apps/mobile/src/components/lightbox/ImageViewing/index.tsx new file mode 100644 index 0000000000..40ff76da7e --- /dev/null +++ b/apps/mobile/src/components/lightbox/ImageViewing/index.tsx @@ -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() + 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. + + { + const { layout } = e.nativeEvent + setOrientation(layout.height > layout.width ? "portrait" : "landscape") + }} + > + {activeLightbox && ( + + )} + + + ) +} + +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 + openProgress: SharedValue +}) { + 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 ( + +