diff --git a/packages/app/src/components/attachment-lightbox.test.tsx b/packages/app/src/components/attachment-lightbox.test.tsx index 35a4e62db6..1495c07b6f 100644 --- a/packages/app/src/components/attachment-lightbox.test.tsx +++ b/packages/app/src/components/attachment-lightbox.test.tsx @@ -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) => 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) => { - 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, + ), + ), + ); }, })); diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index 2425136fa1..5b00fa82cd 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -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 ( - - - - - - {hasError ? ( - {t("message.attachments.imageLoadFailed")} - ) : ( - + + + + + + + {hasError ? ( + {t("message.attachments.imageLoadFailed")} + ) : ( - - )} + )} + - - - - - - - + + ); } @@ -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, - }, })); diff --git a/packages/app/src/components/zoomable-viewport/image.tsx b/packages/app/src/components/zoomable-viewport/image.tsx index d933449308..e1597deb57 100644 --- a/packages/app/src/components/zoomable-viewport/image.tsx +++ b/packages/app/src/components/zoomable-viewport/image.tsx @@ -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; 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 ( - + diff --git a/packages/app/src/components/zoomable-viewport/index.tsx b/packages/app/src/components/zoomable-viewport/index.tsx index 416ad83980..946d266d18 100644 --- a/packages/app/src/components/zoomable-viewport/index.tsx +++ b/packages/app/src/components/zoomable-viewport/index.tsx @@ -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; diff --git a/packages/app/src/components/zoomable-viewport/toolbar.tsx b/packages/app/src/components/zoomable-viewport/toolbar.tsx index 9f25bf4733..7f95106b27 100644 --- a/packages/app/src/components/zoomable-viewport/toolbar.tsx +++ b/packages/app/src/components/zoomable-viewport/toolbar.tsx @@ -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 ( {({ hovered }) => ( - + )} ); @@ -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 }, }));