vojo/src/app/hooks/useShareTargetReceiver.ts

139 lines
5.4 KiB
TypeScript

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<File | null> => {
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<PendingShareValue | null> => {
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]);
};