mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
fix(app): make image lightbox gestures work on Android (#4049)
Chat image previews render in a native Modal outside the app gesture root, so their controls worked while pinch gestures never activated. Give native lightboxes their own gesture root, keep the web modal on a normal view root, and ignore Android's terminal one-pointer pinch update to prevent release jumps.
This commit is contained in:
@@ -75,6 +75,11 @@ vi.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-gesture-handler", () => ({
|
||||
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
@@ -85,11 +90,32 @@ vi.mock("lucide-react-native", () => {
|
||||
|
||||
vi.mock("@/components/zoomable-viewport/image", () => ({
|
||||
ZoomableImage: (props: Record<string, unknown>) => {
|
||||
return React.createElement("div", {
|
||||
"data-testid": `${String(props.testID)}-image`,
|
||||
"data-source": props.uri,
|
||||
role: "img",
|
||||
});
|
||||
const actions = props.actions as Array<{
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}>;
|
||||
return React.createElement(
|
||||
"div",
|
||||
{
|
||||
"data-testid": `${String(props.testID)}-image`,
|
||||
"data-source": props.uri,
|
||||
role: "img",
|
||||
},
|
||||
actions.map((action) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
"aria-label": action.label,
|
||||
"data-testid": action.testID,
|
||||
key: action.label,
|
||||
onClick: action.onPress,
|
||||
type: "button",
|
||||
},
|
||||
action.label,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { WindowChromeRootRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
|
||||
@@ -20,8 +21,9 @@ interface AttachmentLightboxProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ModalRoot = isNative ? GestureHandlerRootView : View;
|
||||
|
||||
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const metadata = source?.type === "attachment" ? source.metadata : null;
|
||||
@@ -47,18 +49,28 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
};
|
||||
}, [onClose, source]);
|
||||
|
||||
const closeButtonRowStyle = useMemo(
|
||||
const contentLayerStyle = useMemo(
|
||||
() => [
|
||||
styles.closeButtonRow,
|
||||
styles.contentLayer,
|
||||
{
|
||||
top: insets.top + theme.spacing[3],
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
],
|
||||
[insets.top, theme.spacing],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
);
|
||||
const closeButtonStyle = useMemo(
|
||||
() => [styles.closeButton, { marginRight: insets.right + theme.spacing[3] }],
|
||||
[insets.right, theme.spacing],
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: X,
|
||||
label: t("message.attachments.closeImage"),
|
||||
onPress: onClose,
|
||||
testID: "attachment-lightbox-close",
|
||||
},
|
||||
],
|
||||
[onClose, t],
|
||||
);
|
||||
|
||||
const handleImageError = useCallback(() => setErrored(true), []);
|
||||
@@ -71,46 +83,36 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View pointerEvents="box-none" style={styles.contentLayer}>
|
||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<View style={styles.imageViewport}>
|
||||
<ModalRoot style={styles.root}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View pointerEvents="box-none" style={contentLayerStyle}>
|
||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<ZoomableImage
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
actions={actions}
|
||||
contentSize={contentSize}
|
||||
onError={handleImageError}
|
||||
style={styles.imageViewport}
|
||||
testID="attachment-lightbox"
|
||||
uri={url}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<WindowChromeSafeArea placement="inline" style={closeButtonRowStyle}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-close"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.closeImage")}
|
||||
hitSlop={8}
|
||||
onPress={onClose}
|
||||
style={closeButtonStyle}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</WindowChromeSafeArea>
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</WindowChromeRootRegion>
|
||||
</ModalRoot>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +120,8 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
backdrop: {
|
||||
position: "absolute",
|
||||
@@ -134,12 +138,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
closeButtonRow: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
imageArea: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
@@ -157,15 +155,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Image, View } from "react-native";
|
||||
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { ViewportSize } from "./geometry";
|
||||
import { ZoomableViewport } from "./index";
|
||||
@@ -13,6 +13,7 @@ interface ZoomableImageProps {
|
||||
maxScale?: number;
|
||||
minScale?: number;
|
||||
onError?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
wheelActivation?: "always" | "modifier";
|
||||
}
|
||||
@@ -25,6 +26,7 @@ export function ZoomableImage({
|
||||
maxScale,
|
||||
minScale = 1,
|
||||
onError,
|
||||
style,
|
||||
testID = "zoomable-image",
|
||||
wheelActivation = "always",
|
||||
}: ZoomableImageProps) {
|
||||
@@ -52,7 +54,7 @@ export function ZoomableImage({
|
||||
|
||||
if (!resolvedSize) {
|
||||
return (
|
||||
<View style={styles.root} testID={testID}>
|
||||
<View style={[styles.root, style]} testID={testID}>
|
||||
<Image
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="image"
|
||||
@@ -73,6 +75,7 @@ export function ZoomableImage({
|
||||
contentSize={resolvedSize}
|
||||
maxScale={maxScale}
|
||||
minScale={minScale}
|
||||
style={style}
|
||||
testID={testID}
|
||||
wheelActivation={wheelActivation}
|
||||
>
|
||||
|
||||
@@ -74,6 +74,9 @@ export function ZoomableViewport({
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (!fittedContent || !viewport) return;
|
||||
// Android emits one final pinch update after a finger lifts. Its focal point belongs to
|
||||
// the remaining pointer, so applying it makes the content jump as the gesture ends.
|
||||
if (event.numberOfPointers < 2) return;
|
||||
if (!pinchReady.value) {
|
||||
pinchFocalX.value = event.focalX;
|
||||
pinchFocalY.value = event.focalY;
|
||||
|
||||
@@ -3,6 +3,11 @@ import { Pressable, View } from "react-native";
|
||||
import { Scan, ZoomIn, ZoomOut } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
iconButtonChromeGlyphSize,
|
||||
iconButtonChromeStyle,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import type { ZoomableViewportAction } from "./types";
|
||||
|
||||
interface ViewportToolbarProps {
|
||||
@@ -77,20 +82,31 @@ function ViewportToolbarButton({
|
||||
visible: boolean;
|
||||
}) {
|
||||
const Icon = action.icon;
|
||||
const buttonStyle = visible ? styles.button : styles.buttonHidden;
|
||||
const resolvedStyle = disabled ? [buttonStyle, styles.disabled] : buttonStyle;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const iconSize = iconButtonChromeGlyphSize("small", isCompact);
|
||||
const buttonStyle = React.useCallback(
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
|
||||
iconButtonChromeStyle({
|
||||
size: "small",
|
||||
compact: isCompact,
|
||||
state: { hovered, pressed },
|
||||
disabled,
|
||||
style: visible ? styles.button : styles.buttonHidden,
|
||||
}),
|
||||
[disabled, isCompact, visible],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityLabel={action.label}
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
hitSlop={4}
|
||||
hitSlop={isCompact ? 6 : 4}
|
||||
onPress={action.onPress}
|
||||
style={resolvedStyle}
|
||||
style={buttonStyle}
|
||||
testID={action.testID}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon size={14} color={hovered ? styles.iconHovered.color : styles.icon.color} />
|
||||
<Icon size={iconSize} color={hovered ? styles.iconHovered.color : styles.icon.color} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -107,20 +123,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
button: {
|
||||
padding: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
opacity: 1,
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
buttonHidden: {
|
||||
padding: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
disabled: { opacity: theme.opacity[50] },
|
||||
icon: { color: theme.colors.foregroundMuted },
|
||||
iconHovered: { color: theme.colors.foreground },
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user