139 lines
5.4 KiB
TypeScript
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]);
|
|
};
|