* fix(app): preserve desktop layout in half-screen windows Treat native window controls as physical corner obstructions instead of deriving clearance from compact layout state. Keep 751px windows in desktop mode while preserving usable content when persisted sidebars are wide. * fix(app): retain sidebar width across window resizes * fix(app): yield app sidebar to narrow settings * test(app): wait for mobile sidebar motion to settle * fix(app): yield navigation to narrow workspace explorer * fix(app): mark sidebar sizing helpers as worklets * fix(app): hide app navigation when space is constrained
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.sm,
|
|
},
|
|
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,
|
|
},
|
|
}));
|