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:
+26
@@ -1141,6 +1141,32 @@ needed nothing in either half.
|
||||
installability and fast loads, API requests never are, and navigations are
|
||||
network-first with the shell as fallback.
|
||||
- **Manifest shortcuts** for Compose, Calendar and Contacts.
|
||||
- **One window, not one per launch.** A `mailto:` link, a shortcut or a
|
||||
notification opened while ihasmail is already running arrives in the copy
|
||||
that is running. Two windows on the same inbox disagree about what has been
|
||||
read, and only one of them is where the half-written reply is.
|
||||
- **The unread count on the installed app's icon.** The tab title and the
|
||||
painted favicon are the same idea for a browser tab, and an installed app has
|
||||
neither -- in `display: standalone` there is no tab strip and no favicon on
|
||||
screen, so a home-screen ihasmail showed nothing at all. Web Push marks the
|
||||
icon while the app is closed, 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.
|
||||
Unsupported browsers show nothing, as does iOS until notification permission
|
||||
has been granted, which is that platform's condition for a badge.
|
||||
- **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
|
||||
goes hunting for it in a file manager. The sheet is on the message menu, on
|
||||
each attachment row, and in the file viewer, which is where an attachment is
|
||||
already open. A message shares as text rather than as the `.eml` beside it,
|
||||
because 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 one of those
|
||||
controls is drawn only where the browser has Web Share -- absent on desktop
|
||||
Linux and in Firefox -- and sharing a file is asked about separately from
|
||||
sharing at all. Where the share cannot be made, the download it sits beside
|
||||
happens instead, so the worst case costs a tap rather than the file.
|
||||
- **`mailto:` handler** — registered from Settings › General for the browser
|
||||
(needs HTTPS; Safari does not support it), and declared in the manifest so an
|
||||
installed ihasmail is offered by the operating system wherever something asks
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
"short_name": "ihasmail",
|
||||
"description": "Fast, friendly JMAP webmail for Stalwart",
|
||||
"_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
|
||||
"_comment_id": "There is deliberately no `id`. It is the one member NOT resolved against this file's address -- the spec resolves it against the origin of start_url, so `./`, `mail` and `/mail` all mean the same thing at the domain root and none of them can name a subpath mount. Adding one would therefore break the same thing the note above describes. Worse, the default id IS start_url, which is already mount-correct: writing an id now would give every installed copy a new identity and orphan it as a second app rather than updating it. If one is ever wanted it has to be substituted at build time from BASE_PATH, and the changeover costs everybody their install.",
|
||||
"start_url": "mail",
|
||||
"scope": "./",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"_comment_launch": "One window, not one per launch. A `mailto:` link, a manifest shortcut or a notification tapped while ihasmail is already open should arrive in the copy that is running rather than beside it -- two windows on the same inbox disagree about what has been read. `navigate-existing` rather than `focus-existing` because the latter only focuses and leaves the app to handle the target URL through launchQueue, which nothing here consumes: it would swallow the mailto entirely. The navigation goes through the same beforeunload guard as a reload, so an unsent draft still stops it and asks.",
|
||||
"launch_handler": {
|
||||
"client_mode": "navigate-existing"
|
||||
},
|
||||
"protocol_handlers": [
|
||||
{
|
||||
"protocol": "mailto",
|
||||
|
||||
@@ -120,6 +120,18 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||
event.waitUntil((async () => {
|
||||
/*
|
||||
* Mark the app icon, without claiming a number.
|
||||
*
|
||||
* `setAppBadge()` with no count shows a dot rather than a figure, which is
|
||||
* the only honest thing to show from here: this worker has no session, so
|
||||
* it cannot ask how many messages are unread, and a push carries the new
|
||||
* mail rather than a total. Counting the payload would badge "2" over an
|
||||
* inbox holding forty. The next time a tab opens, `setUnreadBadge` writes
|
||||
* the real count over the dot.
|
||||
*/
|
||||
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||
|
||||
if (!emails.length) {
|
||||
// A StateChange, or a payload too large to carry the message. Say
|
||||
// something true rather than inventing a sender.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -880,6 +880,8 @@ export const catalog: Catalog = {
|
||||
"folder\u0004Junk Mail": "Spam",
|
||||
"folder\u0004Important": "Wichtig",
|
||||
"folder\u0004All mail": "Alle Nachrichten",
|
||||
"share sheet\u0004Share": "Teilen",
|
||||
"share sheet\u0004Share…": "Teilen…",
|
||||
"folder": "Ordner",
|
||||
"“{name}” moved into “{parent}”": "„{name}“ wurde nach „{parent}“ verschoben",
|
||||
"“{name}” moved to the top level": "„{name}“ wurde auf die oberste Ebene verschoben",
|
||||
@@ -930,6 +932,7 @@ export const catalog: Catalog = {
|
||||
"Could not copy the address": "Die Adresse konnte nicht kopiert werden",
|
||||
"Could not empty folder: {error}": "Ordner konnte nicht geleert werden: {error}",
|
||||
"Could not load source: {error}": "Quelltext konnte nicht geladen werden: {error}",
|
||||
"Could not share: {error}": "Teilen nicht möglich: {error}",
|
||||
"Could not mark as read: {error}": "Konnte nicht als gelesen markiert werden: {error}",
|
||||
"Could not save draft: {error}": "Entwurf konnte nicht gespeichert werden: {error}",
|
||||
"Could not save filter: {error}": "Filter konnte nicht gespeichert werden: {error}",
|
||||
|
||||
@@ -801,6 +801,8 @@ export const catalog: Catalog = {
|
||||
"folder\u0004Junk Mail": "Spam",
|
||||
"folder\u0004Important": "Importante",
|
||||
"folder\u0004All mail": "Todos los mensajes",
|
||||
"share sheet\u0004Share": "Compartir",
|
||||
"share sheet\u0004Share…": "Compartir…",
|
||||
"folder": "carpeta",
|
||||
"“{name}” moved into “{parent}”": "«{name}» se ha movido a «{parent}»",
|
||||
"“{name}” moved to the top level": "«{name}» se ha movido al nivel superior",
|
||||
@@ -903,6 +905,7 @@ export const catalog: Catalog = {
|
||||
"Could not copy the address": "No se pudo copiar la dirección",
|
||||
"Could not empty folder: {error}": "No se pudo vaciar la carpeta: {error}",
|
||||
"Could not load source: {error}": "No se pudo cargar el código fuente: {error}",
|
||||
"Could not share: {error}": "No se pudo compartir: {error}",
|
||||
"Could not mark as read: {error}": "No se pudo marcar como leído: {error}",
|
||||
"Could not save draft: {error}": "No se pudo guardar el borrador: {error}",
|
||||
"Could not save filter: {error}": "No se pudo guardar el filtro: {error}",
|
||||
|
||||
@@ -806,6 +806,8 @@ export const catalog: Catalog = {
|
||||
"folder\u0004Junk Mail": "Spam",
|
||||
"folder\u0004Important": "Important",
|
||||
"folder\u0004All mail": "Tous les messages",
|
||||
"share sheet\u0004Share": "Partager",
|
||||
"share sheet\u0004Share…": "Partager…",
|
||||
"folder": "dossier",
|
||||
"“{name}” moved into “{parent}”": "« {name} » a été déplacé dans « {parent} »",
|
||||
"“{name}” moved to the top level": "« {name} » a été déplacé au niveau supérieur",
|
||||
@@ -908,6 +910,7 @@ export const catalog: Catalog = {
|
||||
"Could not copy the address": "Impossible de copier l’adresse",
|
||||
"Could not empty folder: {error}": "Impossible de vider le dossier : {error}",
|
||||
"Could not load source: {error}": "Impossible de charger la source : {error}",
|
||||
"Could not share: {error}": "Impossible de partager : {error}",
|
||||
"Could not mark as read: {error}": "Impossible de marquer comme lu : {error}",
|
||||
"Could not save draft: {error}": "Impossible d’enregistrer le brouillon : {error}",
|
||||
"Could not save filter: {error}": "Impossible d’enregistrer le filtre : {error}",
|
||||
|
||||
@@ -861,6 +861,8 @@ export const catalog: Catalog = {
|
||||
"folder\u0004Junk Mail": "迷惑メール",
|
||||
"folder\u0004Important": "重要",
|
||||
"folder\u0004All mail": "すべてのメール",
|
||||
"share sheet\u0004Share": "共有",
|
||||
"share sheet\u0004Share…": "共有…",
|
||||
"folder": "フォルダー",
|
||||
"“{name}” moved into “{parent}”": "「{name}」を「{parent}」に移動しました",
|
||||
"“{name}” moved to the top level": "「{name}」を最上位に移動しました",
|
||||
@@ -911,6 +913,7 @@ export const catalog: Catalog = {
|
||||
"Could not copy the address": "アドレスをコピーできませんでした",
|
||||
"Could not empty folder: {error}": "フォルダーを空にできませんでした: {error}",
|
||||
"Could not load source: {error}": "ソースを読み込めませんでした: {error}",
|
||||
"Could not share: {error}": "共有できませんでした: {error}",
|
||||
"Could not mark as read: {error}": "既読にできませんでした: {error}",
|
||||
"Could not save draft: {error}": "下書きを保存できませんでした: {error}",
|
||||
"Could not save filter: {error}": "フィルターを保存できませんでした: {error}",
|
||||
|
||||
@@ -797,6 +797,8 @@ export const catalog: Catalog = {
|
||||
"folder\u0004Junk Mail": "Spam",
|
||||
"folder\u0004Important": "Belangrijk",
|
||||
"folder\u0004All mail": "Alle berichten",
|
||||
"share sheet\u0004Share": "Delen",
|
||||
"share sheet\u0004Share…": "Delen…",
|
||||
"folder": "map",
|
||||
"“{name}” moved into “{parent}”": "“{name}” is verplaatst naar “{parent}”",
|
||||
"“{name}” moved to the top level": "“{name}” is naar het hoogste niveau verplaatst",
|
||||
@@ -899,6 +901,7 @@ export const catalog: Catalog = {
|
||||
"Could not copy the address": "Het adres kon niet worden gekopieerd",
|
||||
"Could not empty folder: {error}": "Map legen mislukt: {error}",
|
||||
"Could not load source: {error}": "De bron kon niet worden geladen: {error}",
|
||||
"Could not share: {error}": "Delen is niet gelukt: {error}",
|
||||
"Could not mark as read: {error}": "Markeren als gelezen mislukt: {error}",
|
||||
"Could not save draft: {error}": "Concept opslaan mislukt: {error}",
|
||||
"Could not save filter: {error}": "Filter opslaan mislukt: {error}",
|
||||
|
||||
@@ -804,6 +804,8 @@ export const catalog: Catalog = {
|
||||
"folder\u0004Junk Mail": "Spam",
|
||||
"folder\u0004Important": "Importante",
|
||||
"folder\u0004All mail": "Todas as mensagens",
|
||||
"share sheet\u0004Share": "Compartilhar",
|
||||
"share sheet\u0004Share…": "Compartilhar…",
|
||||
"folder": "pasta",
|
||||
"“{name}” moved into “{parent}”": "“{name}” foi movida para “{parent}”",
|
||||
"“{name}” moved to the top level": "“{name}” foi movida para o nível superior",
|
||||
@@ -906,6 +908,7 @@ export const catalog: Catalog = {
|
||||
"Could not copy the address": "Não foi possível copiar o endereço",
|
||||
"Could not empty folder: {error}": "Não foi possível esvaziar a pasta: {error}",
|
||||
"Could not load source: {error}": "Não foi possível carregar o código-fonte: {error}",
|
||||
"Could not share: {error}": "Não foi possível compartilhar: {error}",
|
||||
"Could not mark as read: {error}": "Não foi possível marcar como lida: {error}",
|
||||
"Could not save draft: {error}": "Não foi possível salvar o rascunho: {error}",
|
||||
"Could not save filter: {error}": "Não foi possível salvar o filtro: {error}",
|
||||
|
||||
@@ -803,6 +803,8 @@ export const catalog: Catalog = {
|
||||
"folder\u0004Junk Mail": "Спам",
|
||||
"folder\u0004Important": "Важное",
|
||||
"folder\u0004All mail": "Вся почта",
|
||||
"share sheet\u0004Share": "Поделиться",
|
||||
"share sheet\u0004Share…": "Поделиться…",
|
||||
"folder": "папка",
|
||||
"“{name}” moved into “{parent}”": "«{name}» перемещена в «{parent}»",
|
||||
"“{name}” moved to the top level": "«{name}» перемещена на верхний уровень",
|
||||
@@ -905,6 +907,7 @@ export const catalog: Catalog = {
|
||||
"Could not copy the address": "Не удалось скопировать адрес",
|
||||
"Could not empty folder: {error}": "Не удалось очистить папку: {error}",
|
||||
"Could not load source: {error}": "Не удалось загрузить исходный текст: {error}",
|
||||
"Could not share: {error}": "Не удалось поделиться: {error}",
|
||||
"Could not mark as read: {error}": "Не удалось отметить как прочитанное: {error}",
|
||||
"Could not save draft: {error}": "Не удалось сохранить черновик: {error}",
|
||||
"Could not save filter: {error}": "Не удалось сохранить фильтр: {error}",
|
||||
|
||||
@@ -797,6 +797,8 @@ export const catalog: Catalog = {
|
||||
"folder\u0004Junk Mail": "Спам",
|
||||
"folder\u0004Important": "Важливе",
|
||||
"folder\u0004All mail": "Уся пошта",
|
||||
"share sheet\u0004Share": "Поділитися",
|
||||
"share sheet\u0004Share…": "Поділитися…",
|
||||
"folder": "тека",
|
||||
"“{name}” moved into “{parent}”": "«{name}» переміщено до «{parent}»",
|
||||
"“{name}” moved to the top level": "«{name}» переміщено на верхній рівень",
|
||||
@@ -899,6 +901,7 @@ export const catalog: Catalog = {
|
||||
"Could not copy the address": "Не вдалося скопіювати адресу",
|
||||
"Could not empty folder: {error}": "Не вдалося очистити теку: {error}",
|
||||
"Could not load source: {error}": "Не вдалося завантажити вихідний текст: {error}",
|
||||
"Could not share: {error}": "Не вдалося поділитися: {error}",
|
||||
"Could not mark as read: {error}": "Не вдалося позначити як прочитане: {error}",
|
||||
"Could not save draft: {error}": "Не вдалося зберегти чернетку: {error}",
|
||||
"Could not save filter: {error}": "Не вдалося зберегти фільтр: {error}",
|
||||
|
||||
@@ -860,6 +860,8 @@ export const catalog: Catalog = {
|
||||
"folder\u0004Junk Mail": "垃圾邮件",
|
||||
"folder\u0004Important": "重要",
|
||||
"folder\u0004All mail": "全部邮件",
|
||||
"share sheet\u0004Share": "分享",
|
||||
"share sheet\u0004Share…": "分享…",
|
||||
"folder": "文件夹",
|
||||
"“{name}” moved into “{parent}”": "「{name}」已移入「{parent}」",
|
||||
"“{name}” moved to the top level": "「{name}」已移至顶层",
|
||||
@@ -910,6 +912,7 @@ export const catalog: Catalog = {
|
||||
"Could not copy the address": "无法复制该地址",
|
||||
"Could not empty folder: {error}": "无法清空文件夹:{error}",
|
||||
"Could not load source: {error}": "无法加载原文:{error}",
|
||||
"Could not share: {error}": "无法分享:{error}",
|
||||
"Could not mark as read: {error}": "无法标为已读:{error}",
|
||||
"Could not save draft: {error}": "无法保存草稿:{error}",
|
||||
"Could not save filter: {error}": "无法保存过滤器:{error}",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Code2, Download, Eye, Pencil, Printer, Save, X } from "lucide-react";
|
||||
import { Code2, Download, Eye, Pencil, Printer, Save, Share2, X } from "lucide-react";
|
||||
import { confirmDialog, Dialog } from "./dialog";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
|
||||
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { canShareFiles, shareFile } from "@/lib/share";
|
||||
import { t, tc } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* One blob, described the way both callers can describe it. The URLs are built
|
||||
@@ -144,6 +145,43 @@ export function FilePreviewDialog({
|
||||
void confirmDialog({ title: t("Close without saving?"), confirmLabel: t("Discard"), danger: true }).then((yes) => yes && onClose());
|
||||
};
|
||||
|
||||
/*
|
||||
* Hand the file to another app rather than to the filesystem.
|
||||
*
|
||||
* This is the surface where it matters most on a phone: opening an
|
||||
* attachment lands here, and until now the only way onward was Download,
|
||||
* which on Android and iOS means "put it somewhere and go and find it".
|
||||
*
|
||||
* The bytes have to be fetched rather than the URL passed along, because the
|
||||
* share sheet takes a File. `same-origin` credentials because both URLs are
|
||||
* ihasmail's own blob proxy and it is the session cookie that authorises the
|
||||
* read -- which is also why this does not break the rule about `ui/` not
|
||||
* reaching for the JMAP client: it is a plain fetch of a URL the caller
|
||||
* already handed over.
|
||||
*
|
||||
* Anything that goes wrong, including a share the browser turned out not to
|
||||
* support, falls through to the download. That is the button that was here
|
||||
* before, so the worst case costs a tap rather than the file.
|
||||
*/
|
||||
const shareIt = useCallback(async () => {
|
||||
if (!file) return;
|
||||
const download = () => {
|
||||
const l = document.createElement("a");
|
||||
l.href = file.url;
|
||||
l.download = file.name;
|
||||
l.click();
|
||||
};
|
||||
try {
|
||||
const res = await fetch(file.url, { credentials: "same-origin" });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
const out = await shareFile(new File([blob], file.name, { type: file.type || blob.type || "application/octet-stream" }));
|
||||
if (out === "unsupported") download();
|
||||
} catch {
|
||||
download();
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
/*
|
||||
* Print what is on screen, not the mail or the file list behind it.
|
||||
*
|
||||
@@ -209,6 +247,7 @@ export function FilePreviewDialog({
|
||||
</div>
|
||||
)}
|
||||
{editable && <button className="btn" onClick={startEditing}><Pencil size={16} /> {t("Edit")}</button>}
|
||||
{canShareFiles() && <button className="btn" onClick={() => void shareIt()}><Share2 size={16} /> {tc("share sheet", "Share")}</button>}
|
||||
{kind && !tooBig && <button className="btn" onClick={print}><Printer size={16} /> {t("Print")}</button>}
|
||||
<a className="btn" href={file.url} download={file.name}><Download size={16} /> {t("Download")}</a>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File as FileIcon, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter, Share2 } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
||||
@@ -21,7 +21,8 @@ import { displayName, domainOf, formatAddress } from "@/lib/address";
|
||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/html";
|
||||
import { openableInTab, previewKind } from "@/lib/preview";
|
||||
import { FilePreviewDialog } from "@/ui/filepreview";
|
||||
import { findQuoteStart, textToHtml } from "@/lib/text";
|
||||
import { findQuoteStart, htmlToText, textToHtml } from "@/lib/text";
|
||||
import { canShare, canShareFiles, shareFile, shareText } from "@/lib/share";
|
||||
import { Avatar } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { Dialog, choiceDialog} from "@/ui/dialog";
|
||||
@@ -35,7 +36,7 @@ import { useScheduled } from "@/store/scheduled";
|
||||
import { formatScheduleTime } from "@/lib/schedule";
|
||||
import { mdnDecision, refusalText } from "@/lib/mdn";
|
||||
import { sendReadReceipt } from "@/store/mdn";
|
||||
import { t as translate, tNode } from "@/lib/i18n";
|
||||
import { t as translate, tc, tNode } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
@@ -211,6 +212,25 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
a.click();
|
||||
};
|
||||
|
||||
/*
|
||||
* Pass the message itself to another app -- the reply that has to go to
|
||||
* somebody who is not on mail, the address read out over a chat.
|
||||
*
|
||||
* Text rather than the `.eml` above, and the difference is who the other end
|
||||
* is. A message file is for another mail client; a share sheet is aimed at
|
||||
* everything that is not one, and handing WhatsApp an `.eml` gives it an
|
||||
* attachment nobody can open. So the plain-text body goes, falling back to
|
||||
* the HTML flattened, which is the same body the sender wrote either way.
|
||||
*/
|
||||
const shareMessage = async () => {
|
||||
const body = textRaw ?? (htmlRaw ? htmlToText(htmlRaw) : "");
|
||||
try {
|
||||
await shareText({ title: e.subject || translate("(no subject)"), text: body });
|
||||
} catch (err) {
|
||||
toast.error(translate("Could not share: {error}", { error: (err as Error).message }));
|
||||
}
|
||||
};
|
||||
|
||||
const onUnsubscribe = async () => {
|
||||
if (!unsubscribe) return;
|
||||
const urls = [...unsubscribe.matchAll(/<([^>]+)>/g)].map((m) => m[1]!);
|
||||
@@ -322,6 +342,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
<MenuItem icon={<Eye size={16} />} label={translate("Show original")} onClick={() => void openSource()} />
|
||||
<MenuItem icon={<Code size={16} />} label={translate("Show headers")} onClick={() => setShowHeaders(true)} />
|
||||
<MenuItem icon={<Download size={16} />} label={translate("Download (.eml)")} onClick={downloadEml} />
|
||||
{canShare() && <MenuItem icon={<Share2 size={16} />} label={tc("share sheet", "Share…")} onClick={() => void shareMessage()} />}
|
||||
<MenuItem icon={<Printer size={16} />} label={translate("Print")} onClick={printThis} />
|
||||
<MenuItem icon={<Filter size={16} />} label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} />
|
||||
{hasCalendar && <MenuItem icon={<CalendarPlus size={16} />} label={translate("Create event…")} onClick={() => void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message))} />}
|
||||
@@ -743,7 +764,7 @@ export function attachmentIcon(type: string, name?: string | null) {
|
||||
if (t === "text/calendar") return <Calendar size={18} />;
|
||||
if (t.includes("vcard")) return <UserPlus size={18} />;
|
||||
if (t.startsWith("text/") || /word|document/.test(t)) return <FileText size={18} />;
|
||||
return <File size={18} />;
|
||||
return <FileIcon size={18} />;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -822,6 +843,36 @@ function TnefContents({ part, accountId }: { part: EmailBodyPart; accountId: Id
|
||||
|
||||
function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) {
|
||||
const [preview, setPreview] = useState<EmailBodyPart | null>(null);
|
||||
|
||||
/*
|
||||
* The same share the preview dialog offers, on the row itself.
|
||||
*
|
||||
* Both are wanted: a photo is opened and then passed on, but a spreadsheet
|
||||
* cannot be previewed at all and passing it on is the only thing anybody
|
||||
* wants to do with it from a phone.
|
||||
*
|
||||
* Falls back to the download it sits beside where the browser turns out not
|
||||
* to take the file -- see the note on `shareFile`, which is where the
|
||||
* transient-activation case is explained.
|
||||
*/
|
||||
const shareAttachment = async (a: EmailBodyPart) => {
|
||||
if (!a.blobId) return;
|
||||
const name = a.name ?? "attachment";
|
||||
const download = () => {
|
||||
const l = document.createElement("a");
|
||||
l.href = client.downloadUrl(accountId, a.blobId!, name, a.type);
|
||||
l.download = name;
|
||||
l.click();
|
||||
};
|
||||
try {
|
||||
const blob = await client.fetchBlob(accountId, a.blobId, a.type);
|
||||
const out = await shareFile(new File([blob], name, { type: a.type || blob.type || "application/octet-stream" }));
|
||||
if (out === "unsupported") download();
|
||||
} catch {
|
||||
download();
|
||||
}
|
||||
};
|
||||
|
||||
/* Whether we can show it, and whether the server will serve it inline, are
|
||||
different questions -- see the note in lib/preview.ts. */
|
||||
const viewable = (a: EmailBodyPart) => Boolean(a.blobId) && previewKind(a.type, a.name) !== null;
|
||||
@@ -842,6 +893,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
||||
<span className="att-size">{formatSize(a.size)}</span>
|
||||
<span className="att-actions">
|
||||
<button className="icon-btn xs" title={translate("Download")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
|
||||
{canShareFiles() && a.blobId && <button className="icon-btn xs" title={tc("share sheet", "Share")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); void shareAttachment(a); }}><Share2 size={14} /></button>}
|
||||
{openableInTab(a.type) && a.blobId && <button className="icon-btn xs" title={translate("Open in new tab")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user