public/manifest.json declares share_target (POST multipart to /share-target: title/text/url + image/video/audio/pdf/text files). The service worker answers that POST itself: it stashes the form in a Cache API bucket and 303s to the in-app /share page, which lists what arrived, offers a room search, and on pick writes the files into that room's upload-board atom (encrypting first for E2EE rooms via the composer's shared filesToUploadItems) and the title/text/url into its draft, then opens the room — the user still presses Send. The stash is cleared once placed; reopening /share afterwards says so. nginx/caddy examples and the prod image config gain a 303 for /share-target so a POST that reaches the origin before the worker controls the page lands on /share instead of a 405. iOS has no share target support and ignores the manifest entry. Verified headless against the built preview: SW-controlled page → POST /share-target (two PNGs + title + text) → /share lists both files and the text → pick the DM → composer shows both files on the upload board and the text in the draft → /share reports nothing pending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
89 lines
3.3 KiB
TypeScript
89 lines
3.3 KiB
TypeScript
/**
|
|
* [Gitea #155] Web Share Target plumbing shared by the service worker and the
|
|
* app. Android's share sheet POSTs a multipart form to /share-target (see
|
|
* public/manifest.json); the worker stashes the payload in the Cache API and
|
|
* redirects to the in-app /share page, which reads it back, lets the user
|
|
* pick a room and pre-attaches the files to that room's composer.
|
|
*/
|
|
|
|
export const SHARE_TARGET_PATH = '/share-target';
|
|
export const SHARE_PAGE_PATH = '/share';
|
|
export const SHARE_CACHE = 'lotus-share-target';
|
|
export const SHARE_PAYLOAD_KEY = '/share-target/payload';
|
|
export const shareFileKey = (index: number) => `/share-target/file/${index}`;
|
|
|
|
export type SharePayload = {
|
|
title?: string;
|
|
text?: string;
|
|
url?: string;
|
|
files: { name: string; type: string; size: number }[];
|
|
receivedAt: number;
|
|
};
|
|
|
|
/** Worker side: stash the POSTed form and answer with a redirect to /share. */
|
|
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
|
|
const form = await request.formData();
|
|
const files = form.getAll('files').filter((f): f is File => f instanceof File);
|
|
const str = (k: string) => {
|
|
const v = form.get(k);
|
|
return typeof v === 'string' && v.trim() ? v : undefined;
|
|
};
|
|
const payload: SharePayload = {
|
|
title: str('title'),
|
|
text: str('text'),
|
|
url: str('url'),
|
|
files: files.map((f) => ({ name: f.name, type: f.type, size: f.size })),
|
|
receivedAt: Date.now(),
|
|
};
|
|
const cache = await caches.open(SHARE_CACHE);
|
|
await Promise.all([...(await cache.keys()).map((k) => cache.delete(k))]);
|
|
await cache.put(
|
|
SHARE_PAYLOAD_KEY,
|
|
new Response(JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' } }),
|
|
);
|
|
await Promise.all(
|
|
files.map((f, i) =>
|
|
cache.put(
|
|
shareFileKey(i),
|
|
new Response(f, {
|
|
headers: { 'Content-Type': f.type || 'application/octet-stream' },
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
return Response.redirect(`${base}${SHARE_PAGE_PATH}`, 303);
|
|
}
|
|
|
|
/** The share sheet's title/text/url as one draft; title and url are dropped when the text already carries them. */
|
|
export const shareDraftText = (p: SharePayload): string => {
|
|
const text = p.text?.trim() ?? '';
|
|
const keep = (s: string | undefined) => !!s && s.trim() !== '' && !text.includes(s.trim());
|
|
return [keep(p.title) ? p.title : '', text, keep(p.url) ? p.url : ''].filter(Boolean).join('\n');
|
|
};
|
|
|
|
/** App side: read what the worker stashed (undefined when there is nothing). */
|
|
export async function readSharedPayload(): Promise<
|
|
{ payload: SharePayload; files: File[] } | undefined
|
|
> {
|
|
if (typeof caches === 'undefined') return undefined;
|
|
const cache = await caches.open(SHARE_CACHE);
|
|
const res = await cache.match(SHARE_PAYLOAD_KEY);
|
|
if (!res) return undefined;
|
|
const payload = (await res.json()) as SharePayload;
|
|
const files = await Promise.all(
|
|
payload.files.map(async (meta, i) => {
|
|
const r = await cache.match(shareFileKey(i));
|
|
const blob = r ? await r.blob() : new Blob();
|
|
return new File([blob], meta.name || `shared-${i + 1}`, {
|
|
type: meta.type || blob.type,
|
|
});
|
|
}),
|
|
);
|
|
return { payload, files };
|
|
}
|
|
|
|
export async function clearSharedPayload(): Promise<void> {
|
|
if (typeof caches === 'undefined') return;
|
|
await caches.delete(SHARE_CACHE);
|
|
}
|