Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
177 lines
4.9 KiB
TypeScript
177 lines
4.9 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Modal, Pressable, Text, View } from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { Image as ExpoImage } from "expo-image";
|
|
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";
|
|
|
|
interface AttachmentLightboxProps {
|
|
metadata: AttachmentMetadata | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const insets = useSafeAreaInsets();
|
|
const url = useAttachmentPreviewUrl(metadata);
|
|
const [errored, setErrored] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setErrored(false);
|
|
}, [metadata?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!isWeb || !metadata) return;
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === "Escape") {
|
|
onClose();
|
|
}
|
|
}
|
|
window.addEventListener("keydown", handleKeydown);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeydown);
|
|
};
|
|
}, [metadata, onClose]);
|
|
|
|
const closeButtonRowStyle = useMemo(
|
|
() => [
|
|
styles.closeButtonRow,
|
|
{
|
|
top: insets.top + theme.spacing[3],
|
|
},
|
|
],
|
|
[insets.top, theme.spacing],
|
|
);
|
|
const closeButtonStyle = useMemo(
|
|
() => [styles.closeButton, { marginRight: insets.right + theme.spacing[3] }],
|
|
[insets.right, theme.spacing],
|
|
);
|
|
|
|
const handleImageError = useCallback(() => setErrored(true), []);
|
|
const noopPress = useCallback(() => {}, []);
|
|
const imageSource = useMemo(() => ({ uri: url ?? "" }), [url]);
|
|
|
|
if (!metadata) {
|
|
return null;
|
|
}
|
|
|
|
const hasError = errored || !url;
|
|
|
|
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 style={styles.contentLayer}>
|
|
<View style={styles.imageArea}>
|
|
{hasError ? (
|
|
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
|
) : (
|
|
<Pressable onPress={noopPress} style={styles.imagePressable}>
|
|
<ExpoImage
|
|
testID="attachment-lightbox-image"
|
|
source={imageSource}
|
|
contentFit="contain"
|
|
onError={handleImageError}
|
|
style={imageFillStyle}
|
|
/>
|
|
</Pressable>
|
|
)}
|
|
</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>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const imageFillStyle = {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
} as const;
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
root: {
|
|
flex: 1,
|
|
},
|
|
backdrop: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: "rgba(0,0,0,0.9)",
|
|
},
|
|
contentLayer: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
pointerEvents: "box-none",
|
|
},
|
|
closeButtonRow: {
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
alignItems: "flex-end",
|
|
pointerEvents: "box-none",
|
|
},
|
|
imageArea: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: theme.spacing[4],
|
|
pointerEvents: "box-none",
|
|
},
|
|
imagePressable: {
|
|
flex: 1,
|
|
width: "100%",
|
|
alignSelf: "center",
|
|
maxWidth: 960,
|
|
maxHeight: 640,
|
|
},
|
|
errorText: {
|
|
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,
|
|
},
|
|
}));
|