/*
* Collecting a share the operating system sent us.
*
* The other end of `share_target` in the manifest: the system POSTs a form at
* `/share`, the service worker takes the body and stashes it, and this
* is the tab picking it up. See the note on `stashShare` in sw.js for why the
* worker answers that request rather than the app or the server.
*
* The handoff goes through the cache rather than postMessage because a share
* usually launches the app: there is no tab to message at the moment it
* arrives, and the one that appears a second later is a different context that
* has to find the payload lying somewhere.
*/
import { withBase } from "./basePath";
import { SW_CACHE_NAME } from "./sw/swCache";
export interface SharedContent {
title: string;
text: string;
url: string;
files: File[];
}
/** The worker writes here; both sides name it absolutely. */
const SHARE_KEY = "/ihasmail-share";
/*
* How long a share is worth acting on.
*
* It is collected on every app start rather than only when the launch URL says
* so, because the launch may not survive the trip: a share to a signed-out
* ihasmail lands on the sign-in page, and the composer can only open once
* there is an account to open it in. Waiting for that means the payload has to
* outlive a redirect and a login, which the query string does not.
*
* What that costs is the possibility of a stash nobody ever came back for, so
* it expires. Ten minutes is long enough for signing in -- password manager,
* app password, a second device -- and short enough that a share abandoned
* this morning does not open a composer full of a forgotten photo tonight.
*/
export const SHARE_MAX_AGE_MS = 10 * 60_000;
interface StashedFile {
key: string;
name: string;
type: string;
}
/**
* Take whatever the worker left, and leave nothing behind.
*
* Returns null when there is nothing waiting, which is almost every start.
* The entries are deleted whether or not the share is still worth opening: a
* stash that stayed would be collected on the next start instead, which is the
* expiry doing nothing.
*/
export async function collectShare(): Promise {
if (typeof caches === "undefined") return null;
try {
const cache = await caches.open(SW_CACHE_NAME);
const key = withBase(SHARE_KEY);
const hit = await cache.match(key);
if (!hit) return null;
const meta = (await hit.json()) as Partial & { at?: number; files?: StashedFile[] };
await cache.delete(key);
const files: File[] = [];
for (const f of meta.files ?? []) {
const res = await cache.match(f.key);
await cache.delete(f.key);
if (!res) continue;
/*
* The bytes, rather than the Blob holding them.
*
* `new File([blob], …)` is correct and works in a browser, but a Blob
* only counts as a part where the File constructor recognizes it as one
* -- and where it does not, it is stringified instead, producing a file
* containing the thirteen characters "[object Blob]" and no error
* anywhere. That is exactly what CI caught on Node 22 while it passed
* here on 26. An ArrayBuffer is a part on any implementation, and this
* has the whole file in memory a moment later regardless: it is about to
* be uploaded as an attachment.
*/
files.push(new File([await res.arrayBuffer()], f.name, { type: f.type }));
}
if (typeof meta.at === "number" && Date.now() - meta.at > SHARE_MAX_AGE_MS) return null;
const share: SharedContent = {
title: meta.title ?? "",
text: meta.text ?? "",
url: meta.url ?? "",
files,
};
// A share with nothing in it is a share that went wrong upstream. Opening
// an empty composer over the inbox would be a worse account of that than
// opening nothing.
return share.title || share.text || share.url || files.length ? share : null;
} catch {
/* no cache, or nothing waiting: not a failure */
return null;
}
}
/**
* The shared text and the shared link as one body.
*
* What arrives in which field is up to whatever did the sharing, and they do
* not agree: a link from Chrome comes as a title and a `url`, from other apps
* as `text` that already *is* the link, and from a few as both. Appending it
* unconditionally would put the same URL in twice as often as not.
*/
export function shareBody(share: Pick): string {
const text = share.text.trim();
const url = share.url.trim();
if (!url || text.includes(url)) return text;
return text ? `${text}\n\n${url}` : url;
}
/**
* What a share holds, in the few words the confirmation shows.
*
* Only what the reader needs to recognize it as theirs: the title, the start
* of the text or link, and the names of the files. It is shown before any of
* it goes near a message, because the page cannot tell a share the reader
* made from one a website posted at the same address.
*/
export function shareSummary(share: SharedContent): { title: string; preview: string; files: string[] } {
const body = [share.text, share.url].map((s) => s.trim()).filter(Boolean).join(" ");
const preview = body.length > 160 ? `${body.slice(0, 157).trimEnd()}…` : body;
return { title: share.title.trim(), preview, files: share.files.map((f) => f.name) };
}