1import { useAtomValue } from "@effect/atom-react";
2import { videoMimeType } from "@t3tools/shared/video";
3import * as Effect from "effect/Effect";
4import * as Equal from "effect/Equal";
5import * as Hash from "effect/Hash";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import { Atom, AsyncResult } from "effect/reactivity";
9
10import type { FileBackedComposerAttachment } from "./composerImages";
11import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
12import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";
13import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention";
14
15export async function loadLocalAttachmentPreview(
16 attachment: FileBackedComposerAttachment,
17 signal: AbortSignal,
18): Promise<AttachmentPreviewFile | null> {
19 if (signal.aborted) return null;
20 const release = retainComposerAttachmentFileForPreview(attachment);
21 try {
22 const { File, Paths } = await import("expo-file-system");
23 if (signal.aborted) {
24 release();
25 return null;
26 }
27 const uri =
28 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
29 attachment.fileUri;
30 const file = new File(uri);
31 if (!file.exists) {
32 throw new Error("The local attachment file is missing.");
33 }
34 let disposed = false;
35 return {
36 uri: file.uri,
37 dispose: () => {
38 if (disposed) return;
39 disposed = true;
40 release();
41 },
42 share: async (shareSignal, sourceIdentifier) => {
43 if (disposed || shareSignal.aborted) return;
44 const releaseShare = retainComposerAttachmentFileForPreview(attachment);
45 try {
46 await shareLocalAttachment({
47 uri: file.uri,
48 attachment: {
49 name: attachment.name,
50 mimeType:
51 attachment.type === "file"
52 ? (videoMimeType(attachment) ?? attachment.mimeType)
53 : attachment.mimeType,
54 },
55 signal: shareSignal,
56 sourceIdentifier,
57 });
58 } finally {
59 releaseShare();
60 }
61 },
62 };
63 } catch (cause) {
64 release();
65 if (signal.aborted) return null;
66 throw new Error("This attachment is no longer available. Attach the file again.", { cause });
67 }
68}
69
70/**
71 * Keyed by file identity only: a metadata update on the same draft must not
72 * dispose the lease of a file a viewer is still showing.
73 */
74class LocalAttachmentPreviewKey implements Equal.Equal {
75 constructor(readonly attachment: FileBackedComposerAttachment) {}
76 [Equal.symbol](that: unknown): boolean {
77 return (
78 that instanceof LocalAttachmentPreviewKey &&
79 that.attachment.id === this.attachment.id &&
80 that.attachment.fileUri === this.attachment.fileUri
81 );
82 }
83 [Hash.symbol](): number {
84 return Hash.string(`${this.attachment.id}\u0000${this.attachment.fileUri}`);
85 }
86}
87
88class LocalAttachmentPreviewLoadError extends Schema.TaggedError<LocalAttachmentPreviewLoadError>()(
89 "LocalAttachmentPreviewLoadError",
90 {
91 message: Schema.String,
92 cause: Schema.Defect(),
93 },
94) {}
95
96const localAttachmentPreviewFamily = Atom.family((key: LocalAttachmentPreviewKey) =>
97 Atom.make(
98 // The lease lives as long as the atom: unmounting the last reader (or a
99 // refresh) closes the scope and releases the file.
100 Effect.acquireRelease(
101 Effect.tryPromise({
102 try: (signal) => loadLocalAttachmentPreview(key.attachment, signal),
103 catch: (cause) =>
104 new LocalAttachmentPreviewLoadError({
105 message: cause instanceof Error ? cause.message : "The local file is unavailable.",
106 cause,
107 }),
108 }).pipe(
109 Effect.filterOrElse(
110 (file) => file !== null,
111 () => Effect.interrupt,
112 ),
113 ),
114 (file) => Effect.sync(file.dispose),
115 ),
116 ).pipe(Atom.withLabel(`local-attachment-preview:${key.attachment.id}`)),
117);
118
119const noLocalAttachmentPreview = Atom.make(
120 AsyncResult.initial<AttachmentPreviewFile, Error>(),
121).pipe(Atom.withLabel("local-attachment-preview:none"));
122
123export function localAttachmentPreviewAtom(
124 attachment: FileBackedComposerAttachment | null,
125): Atom.Atom<AsyncResult.AsyncResult<AttachmentPreviewFile, Error>> {
126 return attachment === null
127 ? noLocalAttachmentPreview
128 : localAttachmentPreviewFamily(new LocalAttachmentPreviewKey(attachment));
129}
130
131export interface LocalAttachmentPreviewState {
132 readonly uri: string | null;
133 readonly error: Error | null;
134}
135
136export function localAttachmentPreviewState(
137 result: AsyncResult.AsyncResult<AttachmentPreviewFile, Error>,
138): LocalAttachmentPreviewState {
139 return {
140 // A refresh has already released the previous lease while it reacquires.
141 uri: result._tag === "Success" && !result.waiting ? result.value.uri : null,
142 error: result._tag === "Failure" ? Option.getOrNull(AsyncResult.error(result)) : null,
143 };
144}
145
146export function useLocalAttachmentPreview(
147 attachment: FileBackedComposerAttachment | null,
148): LocalAttachmentPreviewState {
149 return localAttachmentPreviewState(useAtomValue(localAttachmentPreviewAtom(attachment)));
150}