import { useEffect } from 'react'; import { useSetAtom } from 'jotai'; import type { PluginListenerHandle } from '@capacitor/core'; import { shareTarget, sharedFilePathToUrl, type PendingShare, type SharedFile, } from '../plugins/shareTarget'; import { pendingShareAtom, type PendingShareValue } from '../state/pendingShare'; import { isAndroidPlatform } from '../utils/capacitor'; // Convert a SharedFile (cached path + metadata) into a real File so it slots // straight into RoomInput.handleFiles. The fetch() goes through the // capacitor:// scheme handler which translates back to a direct cacheDir // read — no network round-trip. const hydrateSharedFile = async (item: SharedFile): Promise => { try { const url = sharedFilePathToUrl(item.path); const res = await fetch(url); if (!res.ok) return null; const blob = await res.blob(); // ContentResolver-reported mimeType is more accurate than the blob's // sniffed type for ambiguous cases (no extension, octet-stream). return new File([blob], item.name, { type: item.mimeType || blob.type || 'application/octet-stream', }); } catch { return null; } }; // Merge subject and text into a single composer string. ACTION_SEND sources // like Gmail "Share thread" put the email subject in EXTRA_SUBJECT and the // body in EXTRA_TEXT; sources like Chrome "Share page" sometimes send only // EXTRA_SUBJECT (the page title) with no body. We treat them as one stream // of text the user will edit before sending — no need to model them as two // independent fields downstream. const mergeSubjectAndText = ( text: string | undefined, subject: string | undefined ): string | undefined => { if (text && subject && text !== subject) return `${subject}\n\n${text}`; return text || subject; }; const hydrateShare = async (share: PendingShare): Promise => { if (share.empty) return null; const fileResults = await Promise.all((share.items ?? []).map(hydrateSharedFile)); const files = fileResults.filter((f): f is File => f !== null); const text = mergeSubjectAndText(share.text, share.subject); // Nothing useful to act on — drop. Possible when text+subject were both // empty AND every file failed to hydrate (cache eviction between native // capture and JS pickup, or a permission-denied content URI we never // managed to read). if (!text && files.length === 0) return null; return { text, files, rawItems: share.items ?? [], }; }; /** * Drains the native share-target slot on mount and on every warm * `shareReceived` event, writing the hydrated payload into * {@link pendingShareAtom}. Two consumers subscribe to that atom: * - `ShareTargetStrip` shows the banner above the app shell while it's * non-null, * - `RoomInput.useEffect` drains the atom into the first chat the user * opens, dropping files into the upload board and any text into the * composer (see RoomInput.tsx). * * Mounted once inside the authenticated tree (see Router.tsx) — before that * point Matrix client / room list aren't available, so even if a cold-start * intent landed we couldn't route it anywhere yet. The native plugin keeps * the payload in its slot until JS finally drains via pickPendingShare(true), * so deferring is safe: cold-start path comes via BridgeActivity.load() → * handleOnNewIntent (Capacitor lifecycle), warm path via Activity.onNewIntent * → handleOnNewIntent + a `shareReceived` event. */ export const useShareTargetReceiver = (): void => { const setPendingShare = useSetAtom(pendingShareAtom); useEffect(() => { if (!isAndroidPlatform()) return undefined; let cancelled = false; let handle: PluginListenerHandle | undefined; // Monotonic drain id: every drain() bumps a counter, captures its own id, // and only commits its hydrated result if it's still the latest. Without // this, an in-flight hydration of a large (slow) payload can land after // a subsequent quick payload's hydration and overwrite the picker with // stale state. let drainSeq = 0; let latestDrain = 0; const drain = async () => { drainSeq += 1; const mySeq = drainSeq; latestDrain = mySeq; const share = await shareTarget.pickPendingShare(true); if (cancelled || mySeq !== latestDrain) return; const hydrated = await hydrateShare(share); if (cancelled || mySeq !== latestDrain || !hydrated) return; setPendingShare(hydrated); }; // Register the warm listener FIRST so a `shareReceived` that lands while // initial drain is awaiting the slot read doesn't slip through. The // native plugin emits the event after stashing into the slot, so even // if our drain races a warm capture, the listener will fire a second // drain that re-reads the slot. Without this ordering, a warm share // arriving in the gap between `drain()` and `addListener` would leave // its payload stuck in the native slot with no JS-side trigger. shareTarget .addShareReceivedListener(() => { drain(); }) .then((h) => { if (cancelled) { h.remove(); } else { handle = h; } }) .catch(() => undefined); // Initial drain: covers cold-start (user launched Vojo straight from the // share-sheet) and warm app-mount after re-login. drain(); return () => { cancelled = true; handle?.remove(); }; }, [setPendingShare]); };