Badge the installed icon, and share to the phone rather than to Downloads

Three things an installed ihasmail did not do that a phone user expects,
and all three are about the app once it is off the browser tab.

The unread count was painted into the tab title and the favicon, neither
of which exists in `display: standalone` -- so putting ihasmail on a home
screen threw the count away entirely. It goes to the Badging API as well
now. Web Push marks the icon while the app is closed, and marks it with a
dot rather than a figure: the service worker has no session to ask how
many messages are unread, and a push carries the new mail rather than a
total, so counting the payload would badge "2" over an inbox holding
forty. The next tab to open writes the real count over it.

Sharing is new. Everything that left ihasmail left as a download, which
on a phone is close to a dead end -- the file lands in Downloads and
whoever meant to send it somewhere goes looking for it in a file manager.
The share sheet is now on the message menu, on each attachment row, and
in the file viewer, which is where an attachment is already open and
where both callers meet. A message shares as text rather than as the
.eml beside it: a share sheet is aimed at everything that is not a mail
client, and an .eml in a chat app is an attachment nobody can open.

Every control feature-detects, and sharing a file is a separate question
from sharing at all -- desktop Linux and Firefox have neither, and not
every browser with `share` takes files. Anything that fails, including
the transient activation running out while a large attachment is fetched,
falls through to the download the button sits beside, so the worst case
costs a tap rather than the file. `NotAllowedError` is reported as
unsupported for that reason: it cannot be told apart from a refusal, and
a toast about activation is not something a reader can act on.

The share strings are contextual keys rather than the existing "Share…".
That one means granting another account access, and several languages use
a different verb for it -- German had "Freigeben" where the sheet wants
"Teilen". Three new strings, in all nine catalogues.

The manifest gains `launch_handler: navigate-existing`, so a mailto:, a
shortcut or a notification tapped while ihasmail is running arrives in
the copy that is running: two windows on one inbox disagree about what
has been read. `focus-existing` would have been wrong -- it only focuses
and leaves the target URL to launchQueue, which nothing here consumes, so
it would swallow the mailto. There is deliberately still no `id`, and the
manifest now says why: it is the one member resolved against the origin
of start_url rather than against the manifest's own address, so no
relative form can name a subpath mount, and the default id already is
start_url -- writing one now would give every installed copy a new
identity and orphan it as a second app.

Verified by test rather than on a device: the extension driving Chrome
was not connected, and Chrome on Linux has no Web Share to drive anyway.
The preview dialog is covered by a component test that stubs the browser
both ways.
This commit is contained in:
2026-09-07 22:28:17 -07:00
parent b65732ea04
commit 4e61adfe80
18 changed files with 516 additions and 7 deletions
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { canShare, canShareFiles, resetShareSupport, shareFile, shareText } from "@/lib/share";
/**
* The outcomes are the whole of this module: what the callers do next is
* decided entirely by which of the three comes back, and two of the three are
* reached through an exception rather than a return.
*
* `unsupported` is the one worth guarding. It is the instruction to download
* instead, and it has to cover the browser that cannot share files *and* the
* share that was refused because the tap's activation ran out while the
* attachment was fetched -- which arrives as an error indistinguishable from a
* permissions refusal, and would otherwise reach the reader as a toast about
* something they cannot act on.
*/
function stubNavigator(nav: Partial<Navigator>) {
vi.stubGlobal("navigator", nav as Navigator);
resetShareSupport();
}
const aFile = () => new File(["x"], "note.txt", { type: "text/plain" });
afterEach(() => {
vi.unstubAllGlobals();
resetShareSupport();
});
describe("share availability", () => {
it("is absent where the browser has no Web Share", () => {
stubNavigator({});
expect(canShare()).toBe(false);
expect(canShareFiles()).toBe(false);
});
it("asks about files separately from sharing at all", () => {
// Every iOS and Android browser shares text; not all of them take files,
// and a Share button that turns out to be a download is worse than none.
stubNavigator({ share: vi.fn(), canShare: () => false });
expect(canShare()).toBe(true);
expect(canShareFiles()).toBe(false);
});
it("probes with a real file, since canShare() cannot answer without one", () => {
const canShareFn = vi.fn(() => true);
stubNavigator({ share: vi.fn(), canShare: canShareFn as unknown as Navigator["canShare"] });
expect(canShareFiles()).toBe(true);
const probe = (canShareFn.mock.calls[0] as unknown as [ShareData])[0];
expect(probe.files?.[0]).toBeInstanceOf(File);
expect(probe.files?.[0]?.size).toBeGreaterThan(0);
});
it("asks once and remembers, because the answer is about the browser", () => {
const canShareFn = vi.fn(() => true);
stubNavigator({ share: vi.fn(), canShare: canShareFn as unknown as Navigator["canShare"] });
canShareFiles();
canShareFiles();
canShareFiles();
expect(canShareFn).toHaveBeenCalledTimes(1);
});
});
describe("sharing text", () => {
it("hands the data straight to the sheet", async () => {
const share = vi.fn(async () => undefined);
stubNavigator({ share });
await expect(shareText({ title: "Lunch", text: "One o'clock?" })).resolves.toBe("shared");
expect(share).toHaveBeenCalledWith({ title: "Lunch", text: "One o'clock?" });
});
it("reports unsupported rather than throwing where there is no share", async () => {
stubNavigator({});
await expect(shareText({ text: "hello" })).resolves.toBe("unsupported");
});
});
describe("sharing a file", () => {
it("passes the file through when the browser takes it", async () => {
const share = vi.fn(async () => undefined);
stubNavigator({ share, canShare: (() => true) as unknown as Navigator["canShare"] });
const f = aFile();
await expect(shareFile(f, { title: "note.txt" })).resolves.toBe("shared");
expect(share).toHaveBeenCalledWith({ title: "note.txt", files: [f] });
});
it("does not call share at all when this file is not shareable", async () => {
const share = vi.fn(async () => undefined);
stubNavigator({ share, canShare: (() => false) as unknown as Navigator["canShare"] });
await expect(shareFile(aFile())).resolves.toBe("unsupported");
expect(share).not.toHaveBeenCalled();
});
it("treats a closed sheet as a decision, not a failure", async () => {
stubNavigator({
share: vi.fn(async () => { throw new DOMException("cancelled", "AbortError"); }),
canShare: (() => true) as unknown as Navigator["canShare"],
});
await expect(shareFile(aFile())).resolves.toBe("dismissed");
});
it("falls back rather than reporting an error when the gesture has expired", async () => {
// What NotAllowedError means here is that fetching the attachment outlived
// the tap that asked for it. The caller downloads; the reader sees a file
// rather than a message about transient activation.
stubNavigator({
share: vi.fn(async () => { throw new DOMException("no activation", "NotAllowedError"); }),
canShare: (() => true) as unknown as Navigator["canShare"],
});
await expect(shareFile(aFile())).resolves.toBe("unsupported");
});
it("raises anything it does not recognise, so a real fault is still reported", async () => {
stubNavigator({
share: vi.fn(async () => { throw new DOMException("boom", "DataError"); }),
canShare: (() => true) as unknown as Navigator["canShare"],
});
await expect(shareFile(aFile())).rejects.toThrow("boom");
});
});
+25 -1
View File
@@ -8,9 +8,33 @@ export function setBaseTitle(t: string) {
baseTitle = t;
}
/** Update document title and favicon badge with unread count. */
/*
* The unread count on the installed app's icon.
*
* The title and the favicon below are the same idea for a tab, and an
* installed app has neither: in `display: standalone` there is no tab strip
* and no favicon anywhere on screen, so everything this file did for the
* unread count vanished at exactly the moment somebody put ihasmail on a home
* screen. The Badging API is where the count goes instead, and it is the one
* thing every phone user expects a mail icon to do.
*
* Silently nothing where it is unsupported, and silently nothing on iOS until
* notification permission has been granted, which is that platform's condition
* for showing a badge at all. Neither is worth reporting: a count that does not
* appear is not a failure anybody can act on.
*/
function setIconBadge(count: number): void {
if (!("setAppBadge" in navigator)) return;
const done = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge();
void done.catch(() => {
/* unsupported, or not permitted on this platform */
});
}
/** Update document title, favicon and app icon badge with unread count. */
export function setUnreadBadge(count: number): void {
document.title = count > 0 ? `(${count > 999 ? "999+" : count}) ${baseTitle}` : baseTitle;
setIconBadge(count);
try {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"][type="image/png"]');
if (!link) return;
+102
View File
@@ -0,0 +1,102 @@
/*
* The operating system's own share sheet.
*
* Everything that leaves ihasmail today leaves as a download, and on a phone a
* download is close to a dead end: the file lands in Downloads and the person
* who wanted to send it somewhere goes hunting for it in a file manager. Web
* Share hands the bytes straight to whatever they meant to send them to, which
* is the thing they were actually trying to do.
*
* Every entry point feature-detects and disappears where the API is not there
* rather than failing at the tap: `navigator.share` is absent on desktop Linux
* and in Firefox, exists on iOS and Android and on Windows and macOS Chrome,
* and file sharing is a separate question from sharing at all.
*/
/**
* What became of a share.
*
* `unsupported` is the interesting one: it says the share did not happen and
* the caller should do whatever it did before — for an attachment, download
* it. It covers both "this browser cannot" and "this browser could not this
* time", because to the caller those are the same instruction.
*/
export type ShareOutcome = "shared" | "dismissed" | "unsupported";
/** Whether the browser can share at all. */
export function canShare(): boolean {
return typeof navigator !== "undefined" && typeof navigator.share === "function";
}
/*
* Whether it can share *files*, asked once and remembered.
*
* `canShare()` needs a real File to answer, and the answer is about the
* browser rather than about any particular file, so a one-byte probe settles
* it for the session. It has to be asked before there is anything to share:
* this is what decides whether a Share button is drawn at all, and drawing one
* that turns out to be a download in disguise is worse than not drawing it.
*
* A byte rather than an empty file on purpose — an implementation is entitled
* to refuse a zero-length one, and being told "no" by the probe would hide the
* button everywhere.
*/
let fileShareSupported: boolean | null = null;
export function canShareFiles(): boolean {
if (fileShareSupported === null) {
try {
fileShareSupported =
canShare() &&
typeof navigator.canShare === "function" &&
navigator.canShare({ files: [new File(["x"], "probe.txt", { type: "text/plain" })] });
} catch {
fileShareSupported = false;
}
}
return fileShareSupported;
}
/** Reset the remembered probe. Tests only. */
export function resetShareSupport(): void {
fileShareSupported = null;
}
/** Share text, a title, a URL, or any combination the browser accepts. */
export async function shareText(data: { title?: string; text?: string; url?: string }): Promise<ShareOutcome> {
if (!canShare()) return "unsupported";
return await run(data);
}
/**
* Share one file. `unsupported` means nothing happened and the caller should
* fall back to a download.
*/
export async function shareFile(file: File, extra: { title?: string; text?: string } = {}): Promise<ShareOutcome> {
if (!canShare() || !navigator.canShare?.({ files: [file] })) return "unsupported";
return await run({ ...extra, files: [file] });
}
async function run(data: ShareData): Promise<ShareOutcome> {
try {
await navigator.share(data);
return "shared";
} catch (err) {
const name = err instanceof DOMException ? err.name : "";
// The sheet opened and was closed again. That is a decision, not a fault,
// and a toast for it would be scolding somebody for changing their mind.
if (name === "AbortError") return "dismissed";
/*
* `NotAllowedError` is reported as unsupported rather than raised, because
* what it nearly always means here is that the tap's transient activation
* ran out while the attachment downloaded. `share()` takes files and not a
* promise of them, so there is no way to open the sheet first and fill it
* afterwards — the fetch has to happen inside the gesture's window, and on
* a slow connection and a large attachment it will sometimes not fit.
*
* The caller's fallback is a download, which is exactly what the button
* did before this existed, so the failure costs a tap rather than the file.
*/
if (name === "NotAllowedError") return "unsupported";
throw err;
}
}