diff --git a/FEATURES.md b/FEATURES.md index 9655e1d..cd1acc3 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1155,6 +1155,20 @@ needed nothing in either half. an inbox holding forty. The next tab to open writes the real count over it. Unsupported browsers show nothing, as does iOS until notification permission has been granted, which is that platform's condition for a badge. +- **In the share sheet** — share a photo, a link or a file from any other app + and ihasmail is one of the places it can go, opening a draft that holds it. + The subject comes from the shared title, the text and the link become the + body above your signature, and files are attached and start uploading. It + addresses nothing: a share says what to send, never who to. + + A share is a POST, which is not something a client-side router can answer, so + the service worker takes the body, leaves it where a tab can collect it and + redirects to the app. That indirection is also what lets a share to a + signed-out ihasmail work — it waits through the sign-in page and opens after, + which the query string could not have survived. One nobody comes back for + expires after ten minutes rather than opening a composer full of a forgotten + photo the next time you look. Android and Chromium only; iOS does not + implement share targets. - **Share** — a message, or one attachment, handed to the operating system's share sheet instead of to the filesystem. On a phone a download is close to a dead end: the file lands in Downloads and whoever wanted to send it somewhere diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest index cf26eaf..3c3d012 100644 --- a/web/public/manifest.webmanifest +++ b/web/public/manifest.webmanifest @@ -11,6 +11,34 @@ "launch_handler": { "client_mode": "navigate-existing" }, + "_comment_share_target": "Being in the operating system's share sheet, which is the other half of the Share this app now offers. `action` is relative like everything else here, so it follows the mount; it has to sit inside `scope`, and `./` covers it. POST with multipart because a share can carry files, and a POST to a page is not something the app can answer -- the service worker intercepts it, puts the payload where a tab can collect it, and redirects. `accept` names wildcard families AND explicit types and extensions on purpose: a mail client attaches anything, but wildcard support is not in the specification and operating systems differ over which form they match on, so the explicit list is what holds if the families are ignored. Android and Chromium only -- iOS does not implement share targets at all.", + "share_target": { + "action": "share", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": [ + "image/*", "video/*", "audio/*", "text/*", + "application/pdf", "application/zip", "application/json", + "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.text", "application/vnd.oasis.opendocument.spreadsheet", + "message/rfc822", "text/calendar", "text/vcard", + ".pdf", ".zip", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".odt", ".ods", ".csv", ".txt", ".md", ".eml", ".ics", ".vcf", + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".mp4", ".mp3" + ] + } + ] + } + }, "protocol_handlers": [ { "protocol": "mailto", diff --git a/web/public/sw.js b/web/public/sw.js index 002e34a..605cafe 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -30,8 +30,74 @@ self.addEventListener("activate", (event) => { ); }); +/* + * Where a share from the operating system is left for a tab to collect. + * + * Absolute and anchored to the mount, for the same reason the verification key + * below is: a relative key is resolved against the URL of whoever asks, and the + * worker and a tab deep in `/mail/inbox/…` are not at the same place. + * + * The files go in one entry each and the rest in a JSON index beside them, + * because the Cache API stores Responses and a File is already one body. + */ +const SHARE_KEY = `${BASE}/ihasmail-share`; +const SHARE_MAX_FILES = 20; + +/* + * Take delivery of a share. + * + * This is a POST that navigates: the operating system submits a form at the + * app and expects a page back. Nothing in ihasmail can answer it directly -- + * the app is a client-side router with no endpoint at that address, and the + * server behind it would have to grow one that understood the composer. So the + * worker takes the body, puts it where a tab can find it, and redirects to the + * app, which then opens a draft holding it. + * + * The redirect happens whatever went wrong. A share that fails to stash costs + * whatever was being shared, which is bad; a share that fails to *respond* + * costs that and leaves the reader looking at a browser error page where they + * expected their mail, which is worse. + * + * There is one case this cannot cover, and the server is deliberately not + * taught to: an app still installed whose worker has been cleared away. The + * POST then reaches the server, which answers 405, and the share is lost + * either way -- the payload only ever existed in that request body. A server + * route would trade a plain error for a silent nothing, and a share that + * vanishes without saying so is the harder of the two to notice. + */ +async function stashShare(request) { + try { + const form = await request.formData(); + const cache = await caches.open(VERSION); + const meta = { + at: Date.now(), + title: String(form.get("title") ?? ""), + text: String(form.get("text") ?? ""), + url: String(form.get("url") ?? ""), + files: [], + }; + const files = form.getAll("files").filter((f) => f && typeof f === "object" && "name" in f && f.size > 0); + for (const [i, f] of files.slice(0, SHARE_MAX_FILES).entries()) { + const key = `${SHARE_KEY}/${i}`; + await cache.put(key, new Response(f, { headers: { "content-type": f.type || "application/octet-stream" } })); + meta.files.push({ key, name: f.name || `file-${i + 1}`, type: f.type || "application/octet-stream" }); + } + await cache.put(SHARE_KEY, new Response(JSON.stringify(meta), { headers: { "content-type": "application/json" } })); + } catch { + /* nothing to hand on: the app opens on an empty inbox rather than an error */ + } + // Absolute, because `Response.redirect` rejects a bare path outright rather + // than resolving it -- so `${BASE}/mail` would throw here and the share + // would end at a browser error page instead of the inbox. + return Response.redirect(new URL(`${BASE}/mail?share=1`, self.location.origin).href, 303); +} + self.addEventListener("fetch", (event) => { const req = event.request; + if (req.method === "POST" && new URL(req.url).pathname === `${BASE}/share`) { + event.respondWith(stashShare(req)); + return; + } if (req.method !== "GET") return; const url = new URL(req.url); if (url.origin !== self.location.origin) return; diff --git a/web/src/lib/__tests__/shareTarget.test.ts b/web/src/lib/__tests__/shareTarget.test.ts new file mode 100644 index 0000000..acd651d --- /dev/null +++ b/web/src/lib/__tests__/shareTarget.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget"; +import { SW_CACHE_NAME } from "@/lib/swCache"; + +/** + * The handoff, from the tab's side. The worker's half cannot be exercised here + * -- sw.js is copied to the build rather than imported, and there is no service + * worker under a test runner -- so what is stood up below is the cache it + * writes into, keyed and shaped exactly as `stashShare` leaves it. + * + * That shape is the contract between two files that never see each other, and + * it is the thing worth pinning: a drift on either side is silent. Nothing + * errors, a share simply arrives at an empty composer. + */ + +interface Entry { + body: BodyInit; + type: string; +} + +function fakeCaches(entries: Record) { + const store = new Map(Object.entries(entries)); + const cache = { + match: vi.fn(async (key: string) => { + const e = store.get(key); + return e ? new Response(e.body, { headers: { "content-type": e.type } }) : undefined; + }), + delete: vi.fn(async (key: string) => store.delete(key)), + put: vi.fn(async () => undefined), + }; + vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : { match: async () => undefined })) }); + return { cache, store }; +} + +/** What the worker writes, at the keys it writes them under. */ +function stash(meta: Record, files: { name: string; type: string; body: string }[] = []) { + const entries: Record = {}; + const index = files.map((f, i) => ({ key: `/ihasmail-share/${i}`, name: f.name, type: f.type })); + entries["/ihasmail-share"] = { body: JSON.stringify({ at: Date.now(), files: index, ...meta }), type: "application/json" }; + for (const [i, f] of files.entries()) entries[`/ihasmail-share/${i}`] = { body: f.body, type: f.type }; + return entries; +} + +beforeEach(() => vi.unstubAllGlobals()); +afterEach(() => vi.unstubAllGlobals()); + +describe("collecting a share", () => { + it("finds nothing on an ordinary start, which is almost every start", async () => { + fakeCaches({}); + await expect(collectShare()).resolves.toBeNull(); + }); + + it("survives a browser with no cache storage at all", async () => { + vi.stubGlobal("caches", undefined); + await expect(collectShare()).resolves.toBeNull(); + }); + + it("rebuilds the files, with their names and types intact", async () => { + fakeCaches(stash({ title: "Holiday", text: "", url: "" }, [ + { name: "beach.png", type: "image/png", body: "pixels" }, + { name: "notes.txt", type: "text/plain", body: "later" }, + ])); + const share = await collectShare(); + expect(share?.title).toBe("Holiday"); + expect(share?.files.map((f) => [f.name, f.type])).toEqual([["beach.png", "image/png"], ["notes.txt", "text/plain"]]); + // The bytes made the trip, not just the index entry describing them. + expect(share!.files[0]!.size).toBe("pixels".length); + }); + + it("leaves nothing behind, so it cannot be collected twice", async () => { + const { store } = fakeCaches(stash({ text: "hello" }, [{ name: "a.txt", type: "text/plain", body: "x" }])); + await collectShare(); + expect(store.size).toBe(0); + }); + + it("ignores one nobody came back for, and still clears it", async () => { + // A share to a signed-out ihasmail waits through the sign-in page, so it + // cannot expire quickly -- but it must expire, or it opens a composer full + // of a forgotten photo on some unrelated morning. + const { store } = fakeCaches(stash({ at: Date.now() - SHARE_MAX_AGE_MS - 1000, text: "stale" })); + await expect(collectShare()).resolves.toBeNull(); + expect(store.size).toBe(0); + }); + + it("treats an empty share as no share", async () => { + fakeCaches(stash({ title: "", text: "", url: "" })); + await expect(collectShare()).resolves.toBeNull(); + }); + + it("does not throw on a stash it cannot read", async () => { + fakeCaches({ "/ihasmail-share": { body: "not json", type: "application/json" } }); + await expect(collectShare()).resolves.toBeNull(); + }); +}); + +describe("the body a share turns into", () => { + it("keeps the link when the text does not already carry it", () => { + expect(shareBody({ text: "Look at this", url: "https://example.com/a" })).toBe("Look at this\n\nhttps://example.com/a"); + }); + + it("does not repeat a link the sharing app already put in the text", () => { + // Which field a link arrives in is up to whatever shared it, and they do + // not agree. Appending unconditionally would double it more often than not. + expect(shareBody({ text: "https://example.com/a", url: "https://example.com/a" })).toBe("https://example.com/a"); + }); + + it("is just the link when that is all there was", () => { + expect(shareBody({ text: "", url: "https://example.com/a" })).toBe("https://example.com/a"); + }); + + it("is just the text when there was no link", () => { + expect(shareBody({ text: "a thought", url: "" })).toBe("a thought"); + }); +}); diff --git a/web/src/lib/shareTarget.ts b/web/src/lib/shareTarget.ts new file mode 100644 index 0000000..a43f77a --- /dev/null +++ b/web/src/lib/shareTarget.ts @@ -0,0 +1,119 @@ +/* + * 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 "./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 recognises 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; +} diff --git a/web/src/lib/swCache.ts b/web/src/lib/swCache.ts new file mode 100644 index 0000000..3590a43 --- /dev/null +++ b/web/src/lib/swCache.ts @@ -0,0 +1,14 @@ +/** + * The name of the cache the service worker keeps. + * + * It is `VERSION` in `web/public/sw.js`, and the worker is not built from this + * source -- it is copied to `dist` verbatim, so nothing checks that the two + * agree. They have to: the worker uses that cache to leave things for a tab to + * collect when there was no tab to hand them to, and a name that has drifted + * does not fail, it silently finds nothing. A push verification never + * completes; a share arrives at an empty composer. + * + * One copy on this side of the line, so at least the app cannot disagree with + * itself. + */ +export const SW_CACHE_NAME = "ihasmail-v2"; diff --git a/web/src/lib/webpushEnable.ts b/web/src/lib/webpushEnable.ts index 23c42de..6653116 100644 --- a/web/src/lib/webpushEnable.ts +++ b/web/src/lib/webpushEnable.ts @@ -7,6 +7,7 @@ */ import { CAP } from "@/jmap/client"; import { withBase } from "./basePath"; +import { SW_CACHE_NAME } from "./swCache"; import { isDeviceTrusted } from "@/lib/storage"; import { useSession } from "@/store/session"; import { useMail } from "@/store/mail"; @@ -48,7 +49,7 @@ export function listenForVerification(): void { /** Pick up a code that arrived while no tab was open. */ async function collectStoredVerification(): Promise { try { - const cache = await caches.open("ihasmail-v2"); + const cache = await caches.open(SW_CACHE_NAME); // The same absolute key the worker writes. Relative would be resolved // against this document's URL, which is a different place on every route. const key = withBase("/ihasmail-push-verification"); diff --git a/web/src/store/__tests__/compose-from-share.test.ts b/web/src/store/__tests__/compose-from-share.test.ts new file mode 100644 index 0000000..112e9ee --- /dev/null +++ b/web/src/store/__tests__/compose-from-share.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useCompose } from "@/store/compose"; +import { useMail } from "@/store/mail"; +import { client } from "@/jmap/client"; +import type { SharedContent } from "@/lib/shareTarget"; + +/** + * What a share becomes once it reaches the composer. + * + * The signature is the part worth a test. `open()` only fits one when it is + * given no body at all, so the obvious implementation -- pass the shared text + * straight to `open()` -- silently drops the signature from every message that + * started as a share, and nothing about the draft looks wrong. + */ + +const IDENTITY = { + id: "i1", + name: "John", + email: "john@example.org", + replyTo: null, + htmlSignature: "

--
John

", + textSignature: "-- \nJohn", +}; + +function share(over: Partial = {}): SharedContent { + return { title: "", text: "", url: "", files: [], ...over }; +} + +beforeEach(() => { + useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} }); + useMail.setState({ accountId: "a1", identities: [IDENTITY] as never }); + // addFiles uploads as it goes; nothing here is testing the upload, and a + // real one would reach for the network. + vi.spyOn(client, "upload").mockResolvedValue({ blobId: "b1", type: "image/png", size: 6 } as never); +}); + +const draftFor = (key: string) => useCompose.getState().drafts.find((d) => d.key === key)!; + +describe("opening a share as a draft", () => { + it("makes the shared title the subject and addresses nothing", () => { + // A share says what to send, never who to. Anything else would be putting + // a recipient in a field the sharer never filled in. + const d = draftFor(useCompose.getState().openFromShare(share({ title: "Holiday plans" }))); + expect(d.subject).toBe("Holiday plans"); + expect(d.to).toEqual([]); + expect(d.cc).toEqual([]); + }); + + it("puts the shared text above the signature, not instead of it", () => { + const d = draftFor(useCompose.getState().openFromShare(share({ text: "Look at this" }))); + expect(d.text).toContain("Look at this"); + expect(d.text).toContain("John"); + expect(d.html).toContain("Look at this"); + expect(d.html).toContain("-- "); + // Above, not below: the reply goes where the caret lands. + expect(d.html.indexOf("Look at this")).toBeLessThan(d.html.indexOf("-- ")); + }); + + it("carries a shared link into the body", () => { + const d = draftFor(useCompose.getState().openFromShare(share({ text: "worth reading", url: "https://example.com/a" }))); + expect(d.text).toContain("https://example.com/a"); + }); + + it("keeps the signature when a share carried nothing but files", () => { + const key = useCompose.getState().openFromShare(share({ files: [new File(["pixels"], "beach.png", { type: "image/png" })] })); + const d = draftFor(key); + expect(d.html).toContain("John"); + expect(d.attachments.map((a) => [a.name, a.type])).toEqual([["beach.png", "image/png"]]); + }); + + it("attaches every shared file, and starts each one uploading", () => { + const files = [ + new File(["a"], "one.png", { type: "image/png" }), + new File(["b"], "two.pdf", { type: "application/pdf" }), + ]; + const d = draftFor(useCompose.getState().openFromShare(share({ title: "Two things", files }))); + expect(d.attachments).toHaveLength(2); + expect(d.attachments.every((a) => a.error === null)).toBe(true); + expect(client.upload).toHaveBeenCalledTimes(2); + }); + + it("opens a plain draft for a share that carried only a subject", () => { + const d = draftFor(useCompose.getState().openFromShare(share({ title: "Just this" }))); + expect(d.subject).toBe("Just this"); + expect(d.attachments).toEqual([]); + }); +}); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index 7776145..2785561 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -14,6 +14,7 @@ import { BASE_PATH } from "@/lib/basePath"; import { settings } from "./settings"; import { emlFilename } from "@/lib/emlName"; import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders"; +import { shareBody, type SharedContent } from "@/lib/shareTarget"; export interface ComposeAttachment { id: string; @@ -83,6 +84,8 @@ interface ComposeState { activeKey: string | null; pendingSends: Record; open(init?: Partial): string; + /** Open a draft holding what the operating system's share sheet sent us. */ + openFromShare(share: SharedContent): string; openDraftEmail(email: Email): Promise; /** Open a message again as a mail that has not been sent yet. */ composeAsNew(email: Email): Promise; @@ -182,6 +185,29 @@ export const useCompose = create((set, get) => ({ return d.key; }, + /* + * A share from the operating system, as a message being written. + * + * The subject and body are filled in but nothing is addressed and nothing is + * sent: a share says what to send, never who to. What arrives is somebody + * part-way through a thought, and the composer is where the rest of it goes. + * + * Opened empty first and the body pushed in above afterwards, rather than + * passed to `open()`. `open()` only fits a signature when it is given no + * body at all, so handing it the shared text would quietly drop the + * signature from every message that started as a share. + */ + openFromShare(share) { + const body = shareBody(share); + const key = get().open({ subject: share.title.trim() }); + if (body) { + const d = get().drafts.find((x) => x.key === key); + if (d) get().update(key, { html: `
${textToHtml(body)}
${d.html}`, text: `${body}\n${d.text}` }); + } + if (share.files.length) get().addFiles(key, share.files); + return key; + }, + async openDraftEmail(email) { const existing = get().drafts.find((d) => d.draftId === email.id); if (existing) { diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 56c2f1c..2d2dd84 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -18,6 +18,7 @@ import { CalendarSidebar } from "./calendar/CalendarSidebar"; import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts"; import { MailboxPicker } from "./mail/MailboxPicker"; import { formatSize } from "@/lib/format"; +import { collectShare } from "@/lib/shareTarget"; import { TranslateBoundary } from "@/ui/TranslateBoundary"; import { t } from "@/lib/i18n"; @@ -35,6 +36,7 @@ export function AppShell({ children }: { children: ReactNode }) { const [drawer, setDrawer] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const openCompose = useCompose((s) => s.open); + const openShare = useCompose((s) => s.openFromShare); const pushState = useSession((s) => s.pushState); const session = useSession((s) => s.session); const logout = useSession((s) => s.logout); @@ -73,6 +75,28 @@ export function AppShell({ children }: { children: ReactNode }) { } }, [openCompose, navigate]); + /* + * A share from the operating system, collected rather than read off the URL. + * + * The other deep links above arrive as a query the app can read on the spot. + * A share cannot: it is a POST, the service worker answered it, and what it + * left behind has to survive the redirect -- and, when nobody was signed in, + * a trip through the sign-in page as well. So this asks on every start + * instead of only when `?share=1` says so, and finds nothing almost every + * time. The `at` stamp is what stops an abandoned one turning up days later. + * + * It runs here rather than in `main.tsx` because attaching needs an account: + * `addFiles` uploads as it goes, and there is nothing to upload to until the + * session is in place. AppShell only exists once there is one. + */ + useEffect(() => { + void collectShare().then((share) => { + if (!share) return; + openShare(share); + if (new URLSearchParams(window.location.search).has("share")) navigate("/mail", { replace: true }); + }); + }, [openShare, navigate]); + /* * There is no account switcher any more. *