Skip to content
Source
Sign inSign up

IncomingShareProvider.tsx

374 lines12.4 KB

1import * as Dialog from "../../components/ui/Dialog";
2import Constants from "expo-constants";
3import * as Crypto from "expo-crypto";
4import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts";
5import {
6 clearSharedPayloads,
7 getResolvedSharedPayloadsAsync,
8 getSharedPayloads,
9 type ResolvedSharePayload,
10 type SharePayload,
11} from "expo-sharing";
12import { useSyncExternalStore } from "react";
13import { Platform } from "react-native";
14import { CommonActions } from "@react-navigation/native";
15​
16import {
17 buildIncomingShareDraft,
18 isShareFileUriUnderOwnedRoots,
19 selectIncomingShareAttachmentsForServer,
20 type IncomingShareDestination,
21 type IncomingShareDraft,
22} from "./incoming-share-model";
23import { createIncomingSharePayloadReader } from "./incoming-share-native";
24import { IncomingShareInbox } from "./incoming-share-inbox";
25import { persistComposerAttachmentFile } from "../../lib/composerImages";
26import { subscribeAppActive } from "../../lib/appState";
27import { coalescedRun } from "../../lib/coalescedRun";
28import { rootNavigationRef } from "../../lib/rootNavigation";
29import { selectWorkspaceDetail } from "../layout/workspace/workspace-detail-selection";
30import { isDraftThreadRoute } from "../threads/thread-route-state";
31import { makeIncomingShareImports } from "../threads/incomingShareImport";
32import { liveNewTaskFlowKeys } from "../threads/new-task-flow-state";
33import { appAtomRegistry } from "../../state/atom-registry";
34import { serverEnvironment } from "../../state/server";
35import {
36 getComposerDraftSnapshot,
37 mergeComposerDraftContent,
38 scheduleUnusedComposerAttachmentCleanup,
39 undoComposerDraftMerge,
40} from "../../state/use-composer-drafts";
41import {
42 EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
43 transitionIncomingSharePresentation,
44} from "./incoming-share-presentation";
45import {
46 loadIncomingShareDrafts,
47 removeIncomingShareDraft,
48 writeIncomingShareDraft,
49} from "./incoming-share-storage";
50​
51function receiveSharingEnabled(): boolean {
52 if (Platform.OS === "android") {
53 return true;
54 }
55 if (Platform.OS !== "ios") {
56 return false;
57 }
58 return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true;
60​
61const getIncomingSharePayloads = createIncomingSharePayloadReader({
62 platform: Platform.OS,
63 readPayloads: getSharedPayloads,
64});
65​
66async function resolvedPayloadsForFiles(): Promise<ReadonlyArray<ResolvedSharePayload>> {
67 try {
68 return await getResolvedSharedPayloadsAsync();
69 } catch (error) {
70 // Raw iOS copied file URLs remain usable; Android's File API can read content URIs when resolution fails.
71 console.warn("[incoming-share] could not resolve shared file metadata", error);
72 return [];
73 }
75​
76async function incomingShareIdForPayloads(payloads: ReadonlyArray<SharePayload>): Promise<string> {
77 const fingerprint = JSON.stringify(
78 payloads.map((payload) => ({
79 shareType: payload.shareType,
80 mimeType: payload.mimeType ?? null,
81 value: payload.value,
82 })),
83 );
84 const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, fingerprint);
85 return `share-${digest}`;
87​
88async function readBase64(uri: string): Promise<string> {
89 const { File } = await import("expo-file-system");
90 return new File(uri).base64();
92​
93async function readFileSize(uri: string): Promise<number | null> {
94 const { File } = await import("expo-file-system");
95 return new File(uri).size ?? null;
97​
98async function removeOwnedFile(uri: string): Promise<void> {
99 if (!uri.startsWith("file:")) {
100 return;
101 }
102 try {
103 const { File, Paths } = await import("expo-file-system");
104 // Delete only app-owned files; iOS open-in-place shares can reference the sender's original.
105 const ownedRootUris = [
106 Paths.document.uri,
107 Paths.cache.uri,
108 ...Object.values(Paths.appleSharedContainers ?? {}).map((directory) => directory.uri),
109 ];
110 if (!isShareFileUriUnderOwnedRoots(uri, ownedRootUris)) {
111 return;
112 }
113 const file = new File(uri);
114 if (file.exists) {
115 file.delete();
116 }
117 } catch (error) {
118 console.warn("[incoming-share] could not remove temporary shared file", error);
119 }
121​
122async function removeReplayedPayloadFiles(payloads: ReadonlyArray<SharePayload>): Promise<void> {
123 const uris = new Set<string>();
124 for (const payload of payloads) {
125 if (["image", "file", "audio", "video"].includes(payload.shareType)) {
126 uris.add(payload.value);
127 }
128 }
129 if (uris.size === 0) {
130 return;
131 }
132 const resolvedPayloads = payloads.some((payload) =>
133 ["file", "audio", "video"].includes(payload.shareType),
134 )
135 ? []
136 : await resolvedPayloadsForFiles();
137 for (const payload of resolvedPayloads) {
138 if (["image", "file", "audio", "video"].includes(payload.shareType) && payload.contentUri) {
139 uris.add(payload.contentUri);
140 }
141 }
142 await Promise.all([...uris].map(removeOwnedFile));
144​
145// Share the queue across remounts so lifecycle notifications cannot ingest one handoff twice.
146const incomingShareInbox = new IncomingShareInbox({
147 loadDrafts: loadIncomingShareDrafts,
148 writeDraft: writeIncomingShareDraft,
149 removeDraft: removeIncomingShareDraft,
150 getPayloads: getIncomingSharePayloads,
151 clearPayloads: clearSharedPayloads,
152 buildDraft: async ({ payloads, id, createdAt }) => {
153 const cleanupUris = new Set<string>();
154 const persistedUris = new Set<string>();
155 const hasGenericFilePayload = payloads.some((payload) =>
156 ["file", "audio", "video"].includes(payload.shareType),
157 );
158 const resolvedPayloads =
159 !hasGenericFilePayload && payloads.some((payload) => payload.shareType === "image")
160 ? await resolvedPayloadsForFiles()
161 : [];
162 const draft = await buildIncomingShareDraft({
163 payloads,
164 resolvedPayloads,
165 fileReader: {
166 readBase64,
167 persistFile: async (uri, name) => {
168 const persistedUri = await persistComposerAttachmentFile(
169 uri,
170 name,
171 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
172 );
173 persistedUris.add(persistedUri);
174 return persistedUri;
175 },
176 readSize: readFileSize,
177 removeOwnedFile: (uri) => {
178 cleanupUris.add(uri);
179 },
180 },
181 id,
182 createdAt,
183 });
184 return {
185 draft,
186 cleanup: async () => {
187 await Promise.all([...cleanupUris].map(removeOwnedFile));
188 },
189 rollback: async () => {
190 await Promise.all([...persistedUris].map(removeOwnedFile));
191 },
192 };
193 },
194 cleanupReplayedPayloads: removeReplayedPayloadFiles,
195 idForPayloads: incomingShareIdForPayloads,
196 now: () => new Date().toISOString(),
197 onClearError: (error) => {
198 console.warn("[incoming-share] could not acknowledge native payload", error);
199 },
200 onCleanupError: (error) => {
201 console.warn("[incoming-share] could not remove temporary shared file", error);
202 },
203});
204​
205interface IncomingShareSnapshot {
206 readonly drafts: ReadonlyArray<IncomingShareDraft>;
207 readonly isLoading: boolean;
209​
210let snapshot: IncomingShareSnapshot = { drafts: [], isLoading: true };
211const snapshotListeners = new Set<() => void>();
212let presentation = EMPTY_INCOMING_SHARE_PRESENTATION_STATE;
213​
214function publish(drafts: ReadonlyArray<IncomingShareDraft>) {
215 snapshot = { drafts, isLoading: false };
216 for (const listener of snapshotListeners) listener();
217 presentPendingIncomingShare();
219​
220function subscribeIncomingShares(onChange: () => void) {
221 snapshotListeners.add(onChange);
222 return () => {
223 snapshotListeners.delete(onChange);
224 };
226​
227function getIncomingShareSnapshot() {
228 return snapshot;
230​
231function reportRefreshFailure(cause: unknown) {
232 console.warn(
233 "[incoming-share] refresh failed",
234 cause,
235 cause instanceof Error ? cause.cause : null,
236 );
237 const error = cause instanceof Error ? cause : new Error("Could not import shared content.");
238 Dialog.alert("Could not import shared content", error.message, [
239 { text: "Dismiss", style: "cancel" },
240 { text: "Retry", onPress: () => void refreshIncomingShares() },
241 ]);
243​
244// A resume while a refresh runs may carry a native handoff that refresh already
245// passed, so it queues one more refresh instead of joining the running one.
246const refreshIncomingShares = coalescedRun(async () => {
247 const enabled = receiveSharingEnabled();
248 try {
249 publish(await incomingShareInbox.refresh({ ingestNative: enabled }));
250 } catch (cause) {
251 const persisted = await incomingShareInbox.refresh({ ingestNative: false }).catch(() => null);
252 publish(persisted ?? snapshot.drafts);
253 reportRefreshFailure(cause);
254 }
255});
256​
257/**
258 * Opens the oldest pending share as a new-task draft unless the user already
259 * dismissed it. Runs whenever the inbox or the root navigation state changes.
260 */
261export function presentPendingIncomingShare(): void {
262 if (!rootNavigationRef.isReady()) return;
263 const state = rootNavigationRef.getRootState();
264 if (!state) return;
265 const transition = transitionIncomingSharePresentation(presentation, {
266 isShareSheetPresented: isDraftThreadRoute(state.routes[state.index]),
267 pendingShareId: snapshot.drafts[0]?.id ?? null,
268 });
269 presentation = transition.state;
270 if (!transition.shareIdToPresent) return;
271 rootNavigationRef.dispatch(
272 selectWorkspaceDetail({
273 name: "Thread",
274 params: { draft: { incomingShareId: transition.shareIdToPresent } },
275 }),
276 );
278​
279/** App-lifetime: loads the inbox and re-ingests native handoffs on every resume. */
280export function startIncomingShareInbox(): () => void {
281 void refreshIncomingShares();
282 return receiveSharingEnabled()
283 ? subscribeAppActive(() => void refreshIncomingShares())
284 : () => {};
286​
287async function consumeShare(shareId: string) {
288 publish(await incomingShareInbox.consume(shareId));
290​
291async function reserveShare(shareId: string, destination: IncomingShareDestination) {
292 publish(await incomingShareInbox.reserve(shareId, destination));
294​
295async function releaseShareReservation(
296 shareId: string,
297 expectedDestination: IncomingShareDestination,
298) {
299 publish(await incomingShareInbox.releaseReservation(shareId, expectedDestination));
301​
302/** Moves shares into new-task drafts; see makeIncomingShareImports. */
303export const incomingShareImports = makeIncomingShareImports({
304 registry: appAtomRegistry,
305 getShare: (shareId) => snapshot.drafts.find((draft) => draft.id === shareId) ?? null,
306 reserve: reserveShare,
307 release: releaseShareReservation,
308 consume: consumeShare,
309 readDraft: getComposerDraftSnapshot,
310 undoMerge: undoComposerDraftMerge,
311 mergeIntoDraft: mergeComposerDraftContent,
312 selectAttachments: (share, environmentId) =>
313 selectIncomingShareAttachmentsForServer({
314 attachments: share.attachments,
315 serverConfig: appAtomRegistry.get(
316 serverEnvironment.configValueAtom(EnvironmentId.make(environmentId)),
317 ),
318 }),
319 releaseUnusedAttachments: scheduleUnusedComposerAttachmentCleanup,
320 onWarnings: (warnings) => {
321 Dialog.alert("Some shared content was skipped", warnings.join("\n"));
322 },
323 onImportFailure: (target, error) => {
324 Dialog.alert(
325 "Could not import shared content",
326 error instanceof Error ? error.message : "The shared content could not be saved.",
327 [
328 {
329 text: "Cancel import",
330 style: "cancel",
331 onPress: () => void incomingShareImports.cancel(target),
332 },
333 { text: "Retry", onPress: () => incomingShareImports.retry(target) },
334 ],
335 { cancelable: false },
336 );
337 },
338 onCancelFailure: (target, error) => {
339 Dialog.alert(
340 "Could not cancel import",
341 error instanceof Error ? error.message : "The shared content could not be restored safely.",
342 [
343 { text: "Retry import", onPress: () => incomingShareImports.retry(target) },
344 { text: "Retry cancel", onPress: () => void incomingShareImports.cancel(target) },
345 ],
346 { cancelable: false },
347 );
348 },
349 leave: (target) => {
350 rootNavigationRef.dispatch({
351 ...CommonActions.goBack(),
352 source: target.routeKey,
353 target: rootNavigationRef.getRootState()?.key,
354 });
355 },
356});
357​
358/** Abandons the share imports of draft routes that left the stack. */
359export function pruneIncomingShareImports(state: Parameters<typeof liveNewTaskFlowKeys>[0]) {
360 void incomingShareImports.prune(liveNewTaskFlowKeys(state));
362​
363export function useIncomingShare() {
364 const { drafts, isLoading } = useSyncExternalStore(
365 subscribeIncomingShares,
366 getIncomingShareSnapshot,
367 );
368 return {
369 pendingShare: drafts[0] ?? null,
370 isLoading,
371 getShare: (shareId: string) => drafts.find((draft) => draft.id === shareId) ?? null,
372 releaseShareReservation,
373 };