Merge pull request #306 from Coffey-Labs/mobile-share-and-app-badge
Badge the installed icon, and share to the phone rather than to Downloads
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