Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 10 additions & 25 deletions apps/mobile/src/components/ComposerContextAttachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import * as Dialog from "./ui/Dialog";
import type { ComposerContextRecord, EnvironmentId } from "@t3tools/contracts";
import { Pressable, View } from "react-native";
import { Image } from "expo-image";
import { useEffect, useId, useState } from "react";
import { useId, useState } from "react";
import type { DraftComposerAttachment } from "../lib/composerImages";
import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages";
import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
import {
loadLocalAttachmentPreview,
useLocalAttachmentPreview,
} from "../lib/localAttachmentPreview";
import { downloadAndShareAttachment } from "../lib/attachmentDownload";
import { assetImageSource } from "../lib/assetImageSource";
import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
Expand All @@ -35,31 +38,13 @@ export function ComposerContextAttachment(props: {
const remoteEnvironmentId = local || inlineUri ? null : (props.environmentId ?? null);
const asset = useAssetUrlState(remoteEnvironmentId, resource);
const refresh = useRefreshAssetUrl(remoteEnvironmentId, resource);
const [localUri, setLocalUri] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const localPreview = useLocalAttachmentPreview(local ?? null);
const error = localPreview.error ? "The local file is unavailable. Attach it again." : null;
const [sharing, setSharing] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
useEffect(() => {
if (!local) return;
const controller = new AbortController();
let dispose: (() => void) | undefined;
void loadLocalAttachmentPreview(local, controller.signal)
.then((preview) => {
if (!preview) return;
if (controller.signal.aborted) return preview.dispose();
dispose = preview.dispose;
setLocalUri(preview.uri);
setError(null);
})
.catch(() => {
if (!controller.signal.aborted) setError("The local file is unavailable. Attach it again.");
});
return () => {
controller.abort();
dispose?.();
};
}, [local]);
const uri = local ? localUri : (inlineUri ?? (asset._tag === "Success" ? asset.url : null));
const uri = local
? localPreview.uri
: (inlineUri ?? (asset._tag === "Success" ? asset.url : null));
const share = async () => {
if (sharing) return;
setSharing(true);
Expand Down
251 changes: 103 additions & 148 deletions apps/mobile/src/components/FilePreviewModal.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
import * as Dialog from "./ui/Dialog";
import { useNavigation } from "@react-navigation/native";
import {
useCallback,
useContext,
useEffect,
useEffectEvent,
useState,
useSyncExternalStore,
useMemo,
} from "react";
import { Keyboard } from "react-native";
import { useContext, useEffect, useEffectEvent, useState } from "react";

import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
import {
downloadAndShareAttachment,
nativeViewerErrorMessage,
shareLocalAttachment,
} from "../lib/attachmentDownload";
import { useRefreshAssetUrl } from "../state/assets";
import { usePreviewPresentation } from "../lib/previewPresentation";
import { refreshAssetUrl } from "../state/assets";
import { filePreviewGallery, filePreviewKey, filePreviewName } from "../lib/filePreviewGallery";
import { FilePreviewGallery } from "./FilePreviewGallery";
import { FilePreview } from "./FilePreview";
Expand All @@ -33,77 +24,47 @@ export type {
ResolvedFilePreviewSource,
} from "./FilePreviewModal.types";

function PreviewItemResolver(props: {
readonly source: FilePreviewItem;
readonly onResolved: (source: ResolvedFilePreviewSource) => void;
readonly onRequestClose: () => void;
readonly onOpenError?: (error: unknown) => void;
}) {
const { source } = props;
const environmentId = "environmentId" in source ? source.environmentId : null;
const refreshAssetUrl = useRefreshAssetUrl(
environmentId,
"resource" in source ? source.resource : null,
);
// Resolve once per presentation; background URL refreshes must not reopen the native viewer.
const [uri, setUri] = useState<string | null>("uri" in source ? source.uri : null);
const onRequestClose = useEffectEvent(props.onRequestClose);
const onResolutionError = useEffectEvent((error: unknown, fallbackMessage: string) => {
if (props.onOpenError) props.onOpenError(error);
else Dialog.alert("Could not open preview", fallbackMessage);
onRequestClose();
});
useEffect(() => Keyboard.dismiss(), []);
useEffect(() => {
if (environmentId === null || uri !== null) return;
let cancelled = false;
// Refresh expired URLs before handing them to Quick Look or ACTION_VIEW, which retain them.
void refreshAssetUrl()
.then((url) => {
if (cancelled) return;
if (!url) throw new Error("Reconnect to this environment and try again.");
setUri(url + (source.srcFragment ?? ""));
})
.catch((error: unknown) => {
if (cancelled) return;
onResolutionError(
error,
"Reconnect to this environment and try again. The file may have been moved or deleted.",
);
});
return () => {
cancelled = true;
};
}, [environmentId, uri, refreshAssetUrl, source.srcFragment]);
useEffect(() => {
if (!("attachment" in source)) return;
const controller = new AbortController();
let release: (() => void) | undefined;
void loadLocalAttachmentPreview(source.attachment, controller.signal)
.then((file) => {
if (!file) return;
if (controller.signal.aborted) {
file.dispose();
return;
}
release = file.dispose;
setUri(file.uri);
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
onResolutionError(error, "Attach the file again and retry.");
});
return () => {
controller.abort();
release?.();
};
}, [source]);
class PreviewResolutionError extends Error {
constructor(
override readonly cause: unknown,
readonly fallbackMessage: string,
) {
super(fallbackMessage, { cause });
}
}

const onResolved = useEffectEvent(props.onResolved);
useEffect(() => {
if (uri !== null) onResolved({ ...source, name: filePreviewName(source), uri });
}, [uri, source]);
return null;
interface ResolvedPreviewItem {
readonly source: ResolvedFilePreviewSource;
readonly dispose?: () => void;
}

async function resolvePreviewItem(
item: FilePreviewItem,
signal: AbortSignal,
): Promise<ResolvedPreviewItem | null> {
const name = filePreviewName(item);
if ("attachment" in item) {
const file = await loadLocalAttachmentPreview(item.attachment, signal).catch(
(error: unknown) => {
throw new PreviewResolutionError(error, "Attach the file again and retry.");
},
);
return file && { source: { ...item, name, uri: file.uri }, dispose: file.dispose };
}
if (!("environmentId" in item)) return { source: { ...item, name } };
// Refresh expired URLs before handing them to Quick Look or ACTION_VIEW, which retain them.
const url = await refreshAssetUrl(item.environmentId, item.resource, signal)
.then((value) => {
if (!value) throw new Error("Reconnect to this environment and try again.");
return value;
})
.catch((error: unknown) => {
throw new PreviewResolutionError(
error,
"Reconnect to this environment and try again. The file may have been moved or deleted.",
);
});
return { source: { ...item, name, uri: url + (item.srcFragment ?? "") } };
}

function ResolvedFilePreview(props: {
Expand All @@ -113,25 +74,60 @@ function ResolvedFilePreview(props: {
}) {
// Freeze the group at opening; streaming messages and URL refreshes must not reopen the viewer.
const [items] = useState(() => filePreviewGallery(props.source, []));
const [resolved, setResolved] = useState<Record<string, ResolvedFilePreviewSource>>({});
const onResolved = useCallback((item: ResolvedFilePreviewSource, key: string) => {
setResolved((current) => ({ ...current, [key]: item }));
}, []);
const selected = resolved[filePreviewKey(props.source)];
const gallery = useMemo(
() =>
items.flatMap((item) => {
const value = resolved[filePreviewKey(item)];
return value ? [value] : [];
}),
[items, resolved],
const selectedKey = filePreviewKey(props.source);
const [gallery, setGallery] = useState<readonly ResolvedFilePreviewSource[] | null>(() =>
items.every((item) => "uri" in item)
? items.map((item) => ({ ...item, name: filePreviewName(item) }))
: null,
);
const ready = selected && gallery.length === items.length;
const onResolutionError = useEffectEvent((error: PreviewResolutionError) => {
if (props.onOpenError) props.onOpenError(error.cause);
else Dialog.alert("Could not open preview", error.fallbackMessage);
props.onRequestClose();
});
// Resolves once per presentation and holds local file leases until it closes.
useEffect(() => {
if (items.every((item) => "uri" in item)) return;
const controller = new AbortController();
// The first failure closes the preview at once and cancels the rest of the gallery.
const fail = (error: unknown) => {
if (controller.signal.aborted) return;
controller.abort();
onResolutionError(
error instanceof PreviewResolutionError
? error
: new PreviewResolutionError(error, "Try again."),
);
};
const resolving = Promise.allSettled(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
items.map((item) =>
resolvePreviewItem(item, controller.signal).catch((error: unknown) => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fail(error);
throw error;
}),
),
);
void resolving.then((settled) => {
if (controller.signal.aborted) return;
const sources = settled.flatMap((item) =>
item.status === "fulfilled" && item.value ? [item.value.source] : [],
);
if (sources.length === items.length) setGallery(sources);
});
return () => {
controller.abort();
void resolving.then((settled) => {
for (const item of settled) if (item.status === "fulfilled") item.value?.dispose?.();
});
};
}, [items]);
const selected = gallery?.[items.findIndex((item) => filePreviewKey(item) === selectedKey)];
if (!gallery || !selected) return null;
const onOpenError = (error: unknown) => {
if (props.onOpenError) return props.onOpenError(error);
Dialog.alert("Could not open preview", nativeViewerErrorMessage(error), [
{ text: "Cancel", style: "cancel" },
...(selected?.kind === "document"
...(selected.kind === "document"
? [
{
text: "Save or share",
Expand All @@ -154,24 +150,11 @@ function ResolvedFilePreview(props: {
]);
};
return (
<>
{items.map((item) => (
<PreviewItemResolver
key={filePreviewKey(item)}
source={item}
onResolved={(value) => onResolved(value, filePreviewKey(item))}
onRequestClose={props.onRequestClose}
onOpenError={props.onOpenError}
/>
))}
{ready ? (
<FilePreview
source={{ ...selected, gallery }}
onRequestClose={props.onRequestClose}
onOpenError={onOpenError}
/>
) : null}
</>
<FilePreview
source={{ ...selected, gallery }}
onRequestClose={props.onRequestClose}
onOpenError={onOpenError}
/>
);
}

Expand All @@ -182,41 +165,13 @@ export function FilePreviewModal(props: {
readonly onOpenError?: (error: unknown) => void;
}) {
const group = useContext(FilePreviewGallery);
const source = useMemo(
() =>
props.source === null
? null
: {
...props.source,
gallery: filePreviewGallery(props.source, group),
},
[props.source, group],
);
const navigation = useNavigation();
const subscribeToFocus = useCallback(
(listener: () => void) => {
const offFocus = navigation.addListener("focus", listener);
const offBlur = navigation.addListener("blur", listener);
return () => {
offFocus();
offBlur();
};
},
[navigation],
);
// The legacy envelope's focus context may describe a different route than this v5 card.
const isFocused = useSyncExternalStore(
subscribeToFocus,
navigation.isFocused,
navigation.isFocused,
);
const hasSource = props.source !== null;
const onRequestClose = useEffectEvent(props.onRequestClose);
useEffect(() => {
if (!isFocused && hasSource) onRequestClose();
}, [isFocused, hasSource]);
const source =
props.source === null
? null
: { ...props.source, gallery: filePreviewGallery(props.source, group) };
usePreviewPresentation(source && filePreviewKey(source), props.onRequestClose);

if (!source || !isFocused) return null;
if (!source) return null;
return (
<ResolvedFilePreview
key={filePreviewKey(source)}
Expand Down
Loading
Loading