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
@@ -0,0 +1,102 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FilePreviewDialog, type PreviewFile } from "../filepreview";
import { resetShareSupport } from "@/lib/share";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* The share sheet is wiring rather than arithmetic, and wiring is what a store
* test cannot see: whether the button is drawn at all is a question about the
* browser, and what it does is a fetch, a File and a fallback that has to fire
* on any failure rather than leaving the reader with nothing.
*
* The dialog is also where both callers meet -- an attachment opened from a
* message and a file opened from Files land in this same component -- so it is
* the one place worth driving.
*/
const FILE: PreviewFile = {
name: "photo.png",
type: "image/png",
size: 12,
url: "/api/blob/photo.png",
inlineUrl: "/api/blob/photo.png?inline=1",
};
function stubNavigator(nav: Partial<Navigator>) {
vi.stubGlobal("navigator", nav as Navigator);
resetShareSupport();
}
describe("sharing from the file preview", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["bytes"], { type: "image/png" }), { status: 200 })));
});
afterEach(() => {
act(() => root.unmount());
host.remove();
vi.unstubAllGlobals();
vi.restoreAllMocks();
resetShareSupport();
});
const show = () => act(() => root.render(<FilePreviewDialog file={FILE} onClose={() => undefined} />));
/* The dialog portals to the body, so the buttons are not under `host`. */
const shareButton = () => [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Share") ?? null;
it("draws no Share button where the browser cannot share files", () => {
// Desktop Linux and Firefox. A control that could only ever fall back to
// the Download beside it is one worth not drawing.
stubNavigator({});
show();
expect(shareButton()).toBeNull();
expect(document.body.textContent).toContain("Download");
});
it("hands the bytes to the sheet as a File, not the URL", async () => {
const share = vi.fn(async () => undefined);
stubNavigator({ share, canShare: (() => true) as unknown as Navigator["canShare"] });
show();
const btn = shareButton();
expect(btn).not.toBeNull();
await act(async () => {
btn!.click();
await Promise.resolve();
});
await act(async () => { await Promise.resolve(); });
expect(fetch).toHaveBeenCalledWith(FILE.url, { credentials: "same-origin" });
const shared = (share.mock.calls[0] as unknown as [ShareData])[0];
const file = shared.files?.[0];
expect(file).toBeInstanceOf(File);
expect(file?.name).toBe("photo.png");
expect(file?.type).toBe("image/png");
});
it("downloads instead when the blob cannot be fetched", async () => {
// The failure that matters is silent: without the fallback the tap does
// nothing at all and the file is simply unreachable from a phone.
const clicked = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 500 })));
stubNavigator({ share: vi.fn(), canShare: (() => true) as unknown as Navigator["canShare"] });
show();
await act(async () => {
shareButton()!.click();
await Promise.resolve();
});
await act(async () => { await Promise.resolve(); });
expect(clicked).toHaveBeenCalled();
});
});