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:
Mohamed Boudra
2026-08-29 23:46:20 +02:00
committed by GitHub
parent 0dad1d7a61
commit 86ef468003
5 changed files with 106 additions and 76 deletions
@@ -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 },
}));