Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 65d0f270ad test(app): name the wait for a file-link lookup's delivery 2026-11-14 17:22:39 +01:00
Mohamed Boudra a331a945ee fix(app): retry a reopened failed attachment preview from loading
The hook kept a failed result after its lookup was torn down, so reopening the same attachment showed "Couldn't load image" until the retry landed. Every cleanup now drops the result for its key.
2026-11-14 17:22:39 +01:00
Mohamed Boudra 88a2a84f65 fix(app): show images the browser reports no natural size for
ZoomableImage waits for the image's size before painting it. React Native Web's Image.getSize never answers for an image that loads with a natural size of 0 (an SVG without width and height in some browsers), so the preview stayed blank with no error. The web build now measures the image itself and falls back to the browser's default replaced-element size, 300x150.
2026-11-14 17:22:38 +01:00
Mohamed Boudra 83708e21de test(app): wait for the stale file-link lookup to land before asserting
The test resolved the lookup and ended. React Query delivers the result on a timer, which could fire after jsdom was torn down ("window is not defined" failed app-tests in CI), and the no-open assertion could run before the stale result arrived at all.
2026-11-14 17:12:25 +01:00
Mohamed Boudra 090a1282c0 fix(app): keep a reopened attachment preview from reading its released URL
Closing a preview releases its URL (revoked on web and desktop), but the hook kept the result for that attachment. Reopening the same attachment returned the revoked URL until the new lookup landed, so the image failed to load and "Couldn't load image" showed for a frame. The result is now dropped when its URL is released. The frame test also counts an image as visible only once it is painted, so a preview that never appears fails.
2026-11-14 17:02:26 +01:00
Mohamed Boudra c431dd49c6 fix(app): open the image preview without a flash
Opening an image preview showed "Couldn't load image", then the image
stretched to the whole window, then a blank frame, before the fitted image.

- The attachment preview URL hook returned null both while resolving and on
  failure, so the lightbox showed the error while the URL resolved. It now
  reports loading, ready, or failed.
- ZoomableImage rendered the image unfitted until its natural size arrived.
  It now renders nothing until it can fit the image, and keeps it hidden until
  it has loaded, so the image appears with its toolbar instead of after it.
- On Android, React Native lays a modal out at the screen minus the system
  bars for its first frames, then grows it, so the backdrop snapped down; the
  Modal wrapper sizes its content to the window from the first frame. The
  lightbox's own SafeAreaProvider measured the dialog window, whose bottom
  inset flips 24, 0, 24; it now reads the window insets via WindowSafeArea.
2026-11-14 16:36:31 +01:00
17 changed files with 499 additions and 90 deletions
+9 -3
View File
@@ -65,6 +65,10 @@ Two escape hatches in the codebase:
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
Use `@/components/ui/modal`, not React Native's `Modal`. On Android, React Native lays out a
modal's first frames at the screen size minus the status and navigation bars, then grows it to
the full edge-to-edge window, so a full-screen backdrop stops short of the bottom and snaps
down. The wrapper sizes the content to the window from the first frame.
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point whose bounds cover the
screen. Same window, same IME, hit-test works because the new parent is
@@ -422,9 +426,11 @@ gets that 0 and draws over the navigation bar.
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
inside it and read the inset below that. The modal is its own window, and the provider measures
it. See `attachment-lightbox.tsx`.
- An RN `Modal` passes the call site's context straight through, so wrap its content in
`WindowSafeArea` from `@/window-layout` and read the inset below that. It provides the insets of
the whole window, published by `WindowLayoutProvider`. Do not put a `SafeAreaProvider` inside the
modal: on Android it measures the dialog window, whose bottom inset reads 24, then 0, then 24
again over the first frames, and everything padded by it jumps. See `attachment-lightbox.tsx`.
## Recipe for a new anchored panel
@@ -39,6 +39,21 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
/**
* Resolves a pending lookup and waits until React Query has delivered the result. It delivers on a
* timer, which would otherwise fire after the test, when jsdom is gone.
*/
async function resolveLookupAndDeliver(
lookup: ReturnType<typeof createDeferred<DirectorySuggestionResult>>,
result: DirectorySuggestionResult,
): Promise<void> {
await act(async () => {
lookup.resolve(result);
await lookup.promise;
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
@@ -322,11 +337,12 @@ describe("useFileLink", () => {
act(() => {
result.current.setWorkspaceRoot("/Users/test/other");
});
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await resolveLookupAndDeliver(
deferred,
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
);
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
expect(openedFiles).toEqual([]);
});
});
@@ -0,0 +1,71 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { persistAttachmentFromBlob } from "@/attachments/service";
import type { AttachmentMetadata } from "@/attachments/types";
import { type AttachmentPreviewUrl, useAttachmentPreviewUrl } from "./use-attachment-preview-url";
let root: Root;
let container: HTMLDivElement;
let latest: AttachmentPreviewUrl | null = null;
function PreviewProbe({ attachment }: { attachment: AttachmentMetadata | null }) {
latest = useAttachmentPreviewUrl(attachment);
return null;
}
async function render(attachment: AttachmentMetadata | null) {
await act(async () => root.render(<PreviewProbe attachment={attachment} />));
}
async function waitForReady(): Promise<string> {
await vi.waitFor(() => expect(latest?.status).toBe("ready"));
if (latest?.status !== "ready") throw new Error("preview never resolved");
return latest.url;
}
beforeEach(() => {
vi.stubGlobal("React", React);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
latest = null;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("never hands back a URL it already released when the same attachment returns", async () => {
const attachment = await persistAttachmentFromBlob({
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
mimeType: "image/png",
fileName: "shot.png",
});
await render(attachment);
const released = await waitForReady();
await render(null);
await render(attachment);
expect(latest).not.toEqual({ status: "ready", url: released });
expect(await waitForReady()).not.toBe(released);
});
it("retries a failed attachment from loading when it returns", async () => {
const stored = await persistAttachmentFromBlob({
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
mimeType: "image/png",
fileName: "shot.png",
});
const missing = { ...stored, id: "missing-attachment", storageKey: "missing-attachment" };
await render(missing);
await vi.waitFor(() => expect(latest?.status).toBe("failed"));
await render(null);
await render(missing);
expect(latest).toEqual({ status: "loading" });
});
@@ -2,62 +2,75 @@ import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
export type AttachmentPreviewUrl =
| { status: "loading" }
| { status: "ready"; url: string }
| { status: "failed" };
const LOADING: AttachmentPreviewUrl = { status: "loading" };
const FAILED: AttachmentPreviewUrl = { status: "failed" };
function attachmentKey(attachment: AttachmentMetadata | null | undefined): string | null {
if (!attachment) return null;
return [attachment.id, attachment.storageType, attachment.storageKey, attachment.mimeType].join(
"\u0000",
);
}
/**
* Resolves a displayable URL for a stored attachment. Stays `loading` until the URL for the
* current attachment resolves, so a caller never shows a failure, or the previous attachment's
* URL, while the next one is still resolving. A missing attachment stays `loading`.
*/
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined,
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
): AttachmentPreviewUrl {
const [resolved, setResolved] = useState<{
key: string;
preview: AttachmentPreviewUrl;
} | null>(null);
const attachmentRef = useRef(attachment);
attachmentRef.current = attachment;
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
const key = attachmentKey(attachment);
useEffect(() => {
let disposed = false;
let currentUrl: string | null = null;
const current = attachmentRef.current;
activeAttachmentRef.current = current ?? null;
if (!current) {
setUrl(null);
if (!current || key === null) {
return;
}
let disposed = false;
let currentUrl: string | null = null;
void (async () => {
try {
const resolved = await resolveAttachmentPreviewUrl(current);
const url = await resolveAttachmentPreviewUrl(current);
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
await releaseAttachmentPreviewUrl({ attachment: current, url });
return;
}
currentUrl = resolved;
setUrl(resolved);
currentUrl = url;
setResolved({ key, preview: { status: "ready", url } });
} catch (error) {
console.error("[attachments] Failed to resolve preview URL", {
attachmentId: current.id,
error,
});
if (!disposed) {
setUrl(null);
setResolved({ key, preview: FAILED });
}
}
})();
return () => {
disposed = true;
const activeAttachment = activeAttachmentRef.current;
if (!currentUrl || !activeAttachment) {
return;
if (currentUrl) {
void releaseAttachmentPreviewUrl({ attachment: current, url: currentUrl });
}
void releaseAttachmentPreviewUrl({
attachment: activeAttachment,
url: currentUrl,
});
// A result lives only as long as its lookup. Kept, the same attachment coming back would
// read a released URL, or an old failure, until the next lookup lands.
setResolved((previous) => (previous?.key === key ? null : previous));
};
}, [id, storageType, storageKey, mimeType]);
}, [key]);
return url;
return resolved !== null && resolved.key === key ? resolved.preview : LOADING;
}
@@ -0,0 +1,149 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { persistAttachmentFromBlob } from "@/attachments/service";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { i18n } from "@/i18n/i18next";
import { WindowLayoutProvider } from "@/window-layout";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
interface FrameSample {
image: { x: number; y: number; width: number; height: number } | null;
error: boolean;
}
function nextFrame(): Promise<number> {
return new Promise((resolve) => requestAnimationFrame(resolve));
}
function createPng(width: number, height: number): Promise<Blob> {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D context unavailable");
context.fillStyle = "#3a7";
context.fillRect(0, 0, width, height);
return canvas.convertToBlob({ type: "image/png" });
}
function noop() {}
function isPainted(element: Element): boolean {
for (let node: Element | null = element; node; node = node.parentElement) {
if (Number(getComputedStyle(node).opacity) === 0) return false;
}
return true;
}
/** What the next paint will show: the visible lightbox image box, and whether the error is up. */
function sampleFrame(): FrameSample {
const image = document.querySelector('[data-testid="attachment-lightbox-image"]');
const rect = image?.getBoundingClientRect();
const visible = image && rect && rect.width > 0 && rect.height > 0 && isPainted(image);
return {
image: visible
? {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
}
: null,
error: (document.body.textContent ?? "").includes("Couldn't load image"),
};
}
/** Samples every frame from the open until the image has held one box for 20 frames. */
async function recordOpening(open: () => void): Promise<FrameSample[]> {
const samples: FrameSample[] = [];
open();
let stableFrames = 0;
for (let frame = 0; frame < 300 && stableFrames < 20; frame += 1) {
await nextFrame();
const sample = sampleFrame();
const previous = samples.at(-1);
stableFrames =
sample.image && JSON.stringify(sample.image) === JSON.stringify(previous?.image)
? stableFrames + 1
: 0;
samples.push(sample);
}
return samples;
}
let root: Root;
let container: HTMLDivElement;
beforeEach(async () => {
vi.stubGlobal("React", React);
await i18n.changeLanguage("en");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
document.body.replaceChildren();
vi.unstubAllGlobals();
});
function renderLightbox(source: ImageLightboxSource | null) {
root.render(
<SafeAreaProvider>
<WindowLayoutProvider>
<AttachmentLightbox source={source} onClose={noop} />
</WindowLayoutProvider>
</SafeAreaProvider>,
);
}
async function expectOpeningPaintsOnlyTheFittedImage(source: ImageLightboxSource) {
const samples = await recordOpening(() => renderLightbox(source));
const fitted = samples.at(-1)?.image;
expect(fitted, "the image never settled").toBeTruthy();
expect(samples.filter((sample) => sample.error)).toEqual([]);
expect(
samples.filter(
(sample) => sample.image && JSON.stringify(sample.image) !== JSON.stringify(fitted),
),
).toEqual([]);
}
describe("AttachmentLightbox opening in the browser", () => {
it("paints the fitted attachment image as its first image frame", async () => {
const metadata = await persistAttachmentFromBlob({
blob: await createPng(2400, 1600),
mimeType: "image/png",
fileName: "screenshot.png",
});
await expectOpeningPaintsOnlyTheFittedImage({ type: "attachment", metadata });
});
it("paints only the fitted image when the same attachment opens again", async () => {
const metadata = await persistAttachmentFromBlob({
blob: await createPng(2400, 1600),
mimeType: "image/png",
fileName: "screenshot.png",
});
const source: ImageLightboxSource = { type: "attachment", metadata };
await expectOpeningPaintsOnlyTheFittedImage(source);
await act(async () => renderLightbox(null));
await expectOpeningPaintsOnlyTheFittedImage(source);
});
it("shows an image the browser reports no natural size for", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>';
const uri = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
});
it("paints the fitted timeline image as its first image frame", async () => {
const uri = URL.createObjectURL(await createPng(2400, 1600));
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
});
});
@@ -4,6 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import type { AttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
@@ -38,9 +39,9 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
return {
theme: hoistedTheme,
imageMetadata: hoistedImageMetadata,
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
() => "blob:preview",
),
useAttachmentPreviewUrlMock: vi.fn<
(metadata: AttachmentMetadata | null) => AttachmentPreviewUrl
>(() => ({ status: "ready", url: "blob:preview" })),
};
});
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
@@ -73,10 +74,13 @@ vi.mock("react-i18next", () => ({
}));
vi.mock("react-native-safe-area-context", () => ({
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/window-layout", () => ({
WindowSafeArea: ({ children }: { children?: React.ReactNode }) => children,
}));
vi.mock("react-native-gesture-handler", () => ({
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
@@ -151,7 +155,7 @@ beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
useAttachmentPreviewUrlMock.mockReturnValue({ status: "ready", url: "blob:preview" });
});
afterEach(() => {
@@ -229,14 +233,22 @@ describe("AttachmentLightbox", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows error text when the preview URL resolves to null", () => {
useAttachmentPreviewUrlMock.mockReturnValue(null);
it("shows error text when the preview URL fails to resolve", () => {
useAttachmentPreviewUrlMock.mockReturnValue({ status: "failed" });
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").toContain("Couldn't load image");
});
it("shows neither the image nor an error while the preview URL resolves", () => {
useAttachmentPreviewUrlMock.mockReturnValue({ status: "loading" });
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").not.toContain("Couldn't load image");
});
it("closes on Escape key on web", () => {
const onClose = vi.fn();
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
@@ -4,17 +4,21 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react
import { Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
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 {
type AttachmentPreviewUrl,
useAttachmentPreviewUrl,
} from "@/attachments/use-attachment-preview-url";
import { isNative } from "@/constants/platform";
import { SPACING } from "@/styles/theme";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
import { WindowSafeArea } from "@/window-layout";
export type ImageLightboxSource =
| { type: "attachment"; metadata: AttachmentMetadata }
@@ -31,10 +35,13 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
const { t } = useTranslation();
const metadata = source?.type === "attachment" ? source.metadata : null;
const attachmentUrl = useAttachmentPreviewUrl(metadata);
const url = source?.type === "uri" ? source.uri : attachmentUrl;
const attachmentPreview = useAttachmentPreviewUrl(metadata);
const image: AttachmentPreviewUrl =
source?.type === "uri" ? { status: "ready", url: source.uri } : attachmentPreview;
const url = image.status === "ready" ? image.url : null;
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
const [errored, setErrored] = useState(false);
const failed = errored || image.status === "failed";
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
useEffect(() => {
@@ -76,14 +83,12 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
return null;
}
const hasError = errored || !url;
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<ModalRoot style={styles.root}>
{/* The modal is its own window: its insets are the window's, not the ones left where the
image was opened (inside a sheet tab, the bottom one is already spent). */}
<SafeAreaProvider>
{/* The modal draws over the whole window: its insets are the window's, not the ones left
where the image was opened (inside a sheet tab, the bottom one is already spent). */}
<WindowSafeArea>
<WindowChromeRootRegion corners="both">
<View ref={setOverlayScope} style={styles.root}>
<Pressable
@@ -94,9 +99,10 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
style={styles.backdrop}
/>
<LightboxImageArea>
{hasError ? (
{failed ? (
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
) : (
) : null}
{!failed && url ? (
<ZoomableImage
accessibilityLabel={t("composer.attachments.openImage")}
actions={actions}
@@ -108,11 +114,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
testID="attachment-lightbox"
uri={url}
/>
)}
) : null}
</LightboxImageArea>
</View>
</WindowChromeRootRegion>
</SafeAreaProvider>
</WindowSafeArea>
</ModalRoot>
</Modal>
);
@@ -133,9 +133,10 @@ export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps)
/** Square image preview pill body. */
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
const uri = useAttachmentPreviewUrl(metadata);
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
if (!uri) {
const preview = useAttachmentPreviewUrl(metadata);
const uri = preview.status === "ready" ? preview.url : null;
const source = useMemo(() => (uri ? { uri } : null), [uri]);
if (!source) {
return <View style={styles.thumbnailPlaceholder} />;
}
return <Image source={source} style={styles.thumbnail} />;
+25 -3
View File
@@ -1,4 +1,10 @@
import { Modal as NativeModal, type ModalProps as NativeModalProps } from "react-native";
import { useMemo } from "react";
import {
Modal as NativeModal,
Platform,
useWindowDimensions,
type ModalProps as NativeModalProps,
} from "react-native";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
@@ -17,7 +23,23 @@ type ModalProps = Omit<NativeModalProps, "onRequestClose"> & {
function noClose() {}
export function Modal(props: ModalProps) {
const { visible = true, onRequestClose } = props;
const { visible = true, onRequestClose, style } = props;
useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
return <NativeModal supportedOrientations={orientations} {...props} />;
const windowFrame = useAndroidWindowFrame();
const containerStyle = useMemo(() => [windowFrame, style], [windowFrame, style]);
return <NativeModal supportedOrientations={orientations} {...props} style={containerStyle} />;
}
/**
* On Android, React Native lays out a modal's first frames at the screen size minus the status and
* navigation bars, then grows it to the full window once the dialog reports its size. The app draws
* edge-to-edge, so the dialog always covers the whole window: size the content to the window from
* the first frame, or a full-screen backdrop stops short of the bottom and then snaps down.
*/
function useAndroidWindowFrame() {
const { width, height } = useWindowDimensions();
return useMemo(
() => (Platform.OS === "android" ? { flex: 0, width, height } : null),
[width, height],
);
}
@@ -0,0 +1,16 @@
import { Image } from "react-native";
import type { ViewportSize } from "./geometry";
/** Resolves the image's natural size; rejects when the image cannot load or has no size. */
export function measureImage(uri: string): Promise<ViewportSize> {
return new Promise((resolve, reject) => {
Image.getSize(
uri,
(width, height) => {
if (width > 0 && height > 0) resolve({ width, height });
else reject(new Error("Image has no size"));
},
reject,
);
});
}
@@ -0,0 +1,24 @@
import type { ViewportSize } from "./geometry";
// The size a browser gives a replaced element that has no intrinsic size of its own.
const NO_INTRINSIC_SIZE: ViewportSize = { width: 300, height: 150 };
/**
* Resolves the image's natural size; rejects when the image cannot load. React Native Web's
* `Image.getSize` never answers for an image that loads with no natural size (an SVG without
* width and height, in some browsers), so the image would never show and never fail.
*/
export function measureImage(uri: string): Promise<ViewportSize> {
return new Promise((resolve, reject) => {
const image = new window.Image();
image.addEventListener("load", () =>
resolve(
image.naturalWidth > 0 && image.naturalHeight > 0
? { width: image.naturalWidth, height: image.naturalHeight }
: NO_INTRINSIC_SIZE,
),
);
image.addEventListener("error", () => reject(new Error("Image failed to load")));
image.src = uri;
});
}
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { ViewportFitOptions, ViewportSize } from "./geometry";
import { measureImage } from "./image-size";
import { ZoomableViewport } from "./index";
import type { ZoomableViewportAction } from "./types";
@@ -34,42 +35,33 @@ export function ZoomableImage({
testID = "zoomable-image",
wheelActivation = "always",
}: ZoomableImageProps) {
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
const resolvedSize = contentSize ?? loadedSize;
const [loaded, setLoaded] = useState<{ uri: string; size: ViewportSize } | null>(null);
const resolvedSize = contentSize ?? (loaded?.uri === uri ? loaded.size : null);
const source = useMemo(() => ({ uri }), [uri]);
const [paintedUri, setPaintedUri] = useState<string | null>(null);
const handleLoad = useCallback(() => setPaintedUri(uri), [uri]);
useEffect(() => {
if (contentSize) return;
let active = true;
setLoadedSize(null);
Image.getSize(
uri,
(width, height) => {
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
},
() => {
void (async () => {
try {
const size = await measureImage(uri);
if (active) setLoaded({ uri, size });
} catch {
if (active) onError?.();
},
);
}
})();
return () => {
active = false;
};
}, [contentSize, onError, uri]);
// Paint nothing until the image can be fitted: an unfitted image fills the whole area for the
// frames before the size arrives, then jumps to its fitted box. Then keep the viewport, toolbar
// included, hidden until the image itself has loaded, so they appear together.
if (!resolvedSize) {
return (
<View style={[styles.root, style]} testID={testID}>
<Image
accessibilityLabel={accessibilityLabel}
accessibilityRole="image"
onError={onError}
resizeMode="contain"
source={source}
style={styles.image}
testID={`${testID}-image`}
/>
</View>
);
return <View style={[styles.root, style]} testID={testID} />;
}
return (
@@ -81,12 +73,15 @@ export function ZoomableImage({
maxScale={maxScale}
minScale={minScale}
onPressOutsideContent={onPressOutsideContent}
style={style}
style={[style, paintedUri !== uri && styles.loading]}
testID={testID}
wheelActivation={wheelActivation}
>
<Image
// Android fades a freshly decoded image in over 300ms; it appears with its toolbar instead.
fadeDuration={0}
onError={onError}
onLoad={handleLoad}
resizeMode="contain"
source={source}
style={styles.image}
@@ -99,4 +94,5 @@ export function ZoomableImage({
const styles = StyleSheet.create(() => ({
root: { flex: 1, minHeight: 0 },
image: { width: "100%", height: "100%" },
loading: { opacity: 0 },
}));
@@ -1,5 +1,6 @@
import { createContext, useContext } from "react";
import { useWindowDimensions } from "react-native";
import type { EdgeInsets } from "react-native-safe-area-context";
import type { TopBand, WindowLayout } from "./model";
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
@@ -7,6 +8,12 @@ export const WindowLayoutContext = createContext<WindowLayout | null>(null);
/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */
export const TopBandContext = createContext<TopBand | null>(null);
/**
* The insets of the whole window, published by `WindowLayoutProvider` before any surface (a
* sheet tab, for one) narrows the insets its content reads.
*/
export const WindowInsetsContext = createContext<EdgeInsets | null>(null);
export function useWindowLayout(): WindowLayout | null {
const layout = useContext(WindowLayoutContext);
const { width, height } = useWindowDimensions();
+10 -2
View File
@@ -2,13 +2,19 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
import { requireNativeViewManager } from "expo-modules-core";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
import {
TopBandContext,
WindowInsetsContext,
WindowLayoutContext,
useWindowLayout,
} from "./context";
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export { WindowSafeArea } from "./window-safe-area";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
@@ -56,7 +62,9 @@ function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
);
return (
<TopBandContext.Provider value={topBand}>
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
<WindowInsetsContext.Provider value={insets}>
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
</WindowInsetsContext.Provider>
</TopBandContext.Provider>
);
}
+5 -1
View File
@@ -1,9 +1,12 @@
import type { ReactNode } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { WindowInsetsContext } from "./context";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export { WindowSafeArea } from "./window-safe-area";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
@@ -12,5 +15,6 @@ export {
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
return children;
const insets = useSafeAreaInsets();
return <WindowInsetsContext.Provider value={insets}>{children}</WindowInsetsContext.Provider>;
}
@@ -0,0 +1,43 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { Text } from "react-native";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { afterEach, expect, it, vi } from "vitest";
import { WindowLayoutProvider, WindowSafeArea } from "@/window-layout";
vi.stubGlobal("React", React);
const windowInsets = { top: 52, right: 0, bottom: 24, left: 0 };
const sheetTabInsets = { ...windowInsets, bottom: 0 };
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
afterEach(() => {
act(() => root.render(null));
});
function InsetsProbe({ testID }: { testID: string }) {
const insets = useSafeAreaInsets();
return <Text testID={testID}>{JSON.stringify(insets)}</Text>;
}
it("gives an overlay opened inside a sheet tab the window's insets", () => {
act(() =>
root.render(
<SafeAreaInsetsContext.Provider value={windowInsets}>
<WindowLayoutProvider>
<SafeAreaInsetsContext.Provider value={sheetTabInsets}>
<InsetsProbe testID="sheet-tab" />
<WindowSafeArea>
<InsetsProbe testID="overlay" />
</WindowSafeArea>
</SafeAreaInsetsContext.Provider>
</WindowLayoutProvider>
</SafeAreaInsetsContext.Provider>,
),
);
const insetsOf = (testID: string) =>
JSON.parse(container.querySelector(`[data-testid="${testID}"]`)?.textContent ?? "null");
expect(insetsOf("sheet-tab")).toEqual(sheetTabInsets);
expect(insetsOf("overlay")).toEqual(windowInsets);
});
@@ -0,0 +1,15 @@
import { useContext, type ReactNode } from "react";
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
import { WindowInsetsContext } from "./context";
/**
* Gives an overlay that draws over the whole window, such as an RN `Modal`, the window's insets
* instead of the ones its call site was given. Read them below it with `useSafeAreaInsets()`.
*/
export function WindowSafeArea({ children }: { children: ReactNode }) {
const insets = useContext(WindowInsetsContext);
if (!insets) {
throw new Error("WindowSafeArea must render inside WindowLayoutProvider");
}
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
}