Files
cinny/src/swShare.ts
T
jaredandClaude Opus 5 96a97a2f86 feat(pwa): register as an Android share target (#155)
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
2026-09-20 13:36:05 -04:00

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);
}