diff --git a/FEATURES.md b/FEATURES.md index 6e19c06..9655e1d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest index ca71983..cf26eaf 100644 --- a/web/public/manifest.webmanifest +++ b/web/public/manifest.webmanifest @@ -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", diff --git a/web/public/sw.js b/web/public/sw.js index a38af54..002e34a 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -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. diff --git a/web/src/lib/__tests__/share.test.ts b/web/src/lib/__tests__/share.test.ts new file mode 100644 index 0000000..8934468 --- /dev/null +++ b/web/src/lib/__tests__/share.test.ts @@ -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) { + 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"); + }); +}); diff --git a/web/src/lib/notify.ts b/web/src/lib/notify.ts index e903e75..4d69819 100644 --- a/web/src/lib/notify.ts +++ b/web/src/lib/notify.ts @@ -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('link[rel="icon"][type="image/png"]'); if (!link) return; diff --git a/web/src/lib/share.ts b/web/src/lib/share.ts new file mode 100644 index 0000000..36af676 --- /dev/null +++ b/web/src/lib/share.ts @@ -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 { + 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 { + if (!canShare() || !navigator.canShare?.({ files: [file] })) return "unsupported"; + return await run({ ...extra, files: [file] }); +} + +async function run(data: ShareData): Promise { + 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; + } +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 0fd8fa6..05f93ab 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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}", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 6f07709..9a5bedf 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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}", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 820e1db..4017cb9 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -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}", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 3b1f49e..6076028 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -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}", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index e62f744..81ee9d6 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -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}", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 64991de..b899c88 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -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}", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 0e04aed..39a86e3 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -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}", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index bf7fd3a..606a8c2 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -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}", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index c969111..fa73af0 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -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}", diff --git a/web/src/ui/__tests__/filepreview-share.test.tsx b/web/src/ui/__tests__/filepreview-share.test.tsx new file mode 100644 index 0000000..33f7990 --- /dev/null +++ b/web/src/ui/__tests__/filepreview-share.test.tsx @@ -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) { + 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( 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(); + }); +}); diff --git a/web/src/ui/filepreview.tsx b/web/src/ui/filepreview.tsx index 9256891..9d66537 100644 --- a/web/src/ui/filepreview.tsx +++ b/web/src/ui/filepreview.tsx @@ -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({ )} {editable && } + {canShareFiles() && } {kind && !tooBig && } {t("Download")} diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index b0ddaae..2840059 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -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 } label={translate("Show original")} onClick={() => void openSource()} /> } label={translate("Show headers")} onClick={() => setShowHeaders(true)} /> } label={translate("Download (.eml)")} onClick={downloadEml} /> + {canShare() && } label={tc("share sheet", "Share…")} onClick={() => void shareMessage()} />} } label={translate("Print")} onClick={printThis} /> } label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} /> {hasCalendar && } 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 ; if (t.includes("vcard")) return ; if (t.startsWith("text/") || /word|document/.test(t)) return ; - return ; + return ; } /** @@ -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(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 {formatSize(a.size)} + {canShareFiles() && a.blobId && } {openableInTab(a.type) && a.blobId && }