diff --git a/web/src/lib/__tests__/shareTarget.test.ts b/web/src/lib/__tests__/shareTarget.test.ts index d126833..6e16a3f 100644 --- a/web/src/lib/__tests__/shareTarget.test.ts +++ b/web/src/lib/__tests__/shareTarget.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget"; +import { shareSummary, collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget"; import { SW_CACHE_NAME } from "@/lib/sw/swCache"; /** @@ -112,3 +112,19 @@ describe("the body a share turns into", () => { expect(shareBody({ text: "a thought", url: "" })).toBe("a thought"); }); }); + +describe("shareSummary", () => { + const file = (name: string) => new File(["x"], name); + it("gives the title, the text and link together, and the file names", () => { + expect(shareSummary({ title: " Trip ", text: "See this", url: "https://example.com", files: [file("a.jpg")] })).toEqual({ + title: "Trip", + preview: "See this https://example.com", + files: ["a.jpg"], + }); + }); + it("shortens a long text rather than showing all of it", () => { + const { preview } = shareSummary({ title: "", text: "word ".repeat(100), url: "", files: [] }); + expect(preview.length).toBeLessThanOrEqual(160); + expect(preview.endsWith("…")).toBe(true); + }); +}); diff --git a/web/src/lib/shareTarget.ts b/web/src/lib/shareTarget.ts index 8e5fbc0..9689675 100644 --- a/web/src/lib/shareTarget.ts +++ b/web/src/lib/shareTarget.ts @@ -117,3 +117,17 @@ export function shareBody(share: Pick): string { if (!url || text.includes(url)) return text; return text ? `${text}\n\n${url}` : url; } + +/** + * What a share holds, in the few words the confirmation shows. + * + * Only what the reader needs to recognize it as theirs: the title, the start + * of the text or link, and the names of the files. It is shown before any of + * it goes near a message, because the page cannot tell a share the reader + * made from one a website posted at the same address. + */ +export function shareSummary(share: SharedContent): { title: string; preview: string; files: string[] } { + const body = [share.text, share.url].map((s) => s.trim()).filter(Boolean).join(" "); + const preview = body.length > 160 ? `${body.slice(0, 157).trimEnd()}…` : body; + return { title: share.title.trim(), preview, files: share.files.map((f) => f.name) }; +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 9d040ae..7137239 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1190,6 +1190,9 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Neue Nachricht", + "Start a new message with what was shared?": "Neue Nachricht mit dem geteilten Inhalt beginnen?", + "Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Es wurde etwas mit ihasmail geteilt. Gesendet wird erst, wenn Sie „Senden“ wählen. Wenn Sie dies nicht gerade selbst geteilt haben, verwerfen Sie es.", + "Start a message": "Nachricht beginnen", "New mail": "Neue E-Mail", "Could not do that — open ihasmail and try again": "Nicht möglich – öffnen Sie ihasmail und versuchen Sie es erneut", "Sending…": "Wird gesendet…", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 0f3ad17..344377f 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1163,6 +1163,9 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Mensaje nuevo", + "Start a new message with what was shared?": "¿Empezar un mensaje nuevo con lo que se ha compartido?", + "Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Se ha compartido algo con ihasmail. No se envía nada hasta que elija Enviar. Si no acaba de compartirlo usted, descártelo.", + "Start a message": "Empezar mensaje", "New mail": "Correo nuevo", "Could not do that — open ihasmail and try again": "No se pudo hacer eso: abra ihasmail e inténtelo de nuevo", "Sending…": "Enviando…", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 1214660..5af24b0 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1168,6 +1168,9 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Nouveau message", + "Start a new message with what was shared?": "Commencer un nouveau message avec le contenu partagé ?", + "Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Un contenu a été partagé avec ihasmail. Rien n'est envoyé tant que vous n'avez pas choisi Envoyer. Si vous ne venez pas de le partager, abandonnez-le.", + "Start a message": "Commencer un message", "New mail": "Nouveau courrier", "Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez", "Sending…": "Envoi…", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 9b894f5..193d3a2 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1171,6 +1171,9 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "新規メール", + "Start a new message with what was shared?": "共有された内容で新規メールを作成しますか?", + "Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "ihasmail に何かが共有されました。「送信」を選ぶまで何も送信されません。共有した覚えがない場合は破棄してください。", + "Start a message": "メールを作成", "New mail": "新着メール", "Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください", "Sending…": "送信中…", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 93b1e6c..4463809 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1161,6 +1161,9 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Nieuw bericht", + "Start a new message with what was shared?": "Een nieuw bericht beginnen met wat is gedeeld?", + "Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Er is iets met ihasmail gedeeld. Er wordt niets verzonden totdat u Verzenden kiest. Hebt u dit niet zelf zojuist gedeeld, gooi het dan weg.", + "Start a message": "Bericht beginnen", "New mail": "Nieuwe e-mail", "Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw", "Sending…": "Bezig met verzenden…", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 7868b20..28786b5 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1166,6 +1166,9 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Nova mensagem", + "Start a new message with what was shared?": "Iniciar uma nova mensagem com o que foi compartilhado?", + "Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Algo foi compartilhado com o ihasmail. Nada é enviado até você escolher Enviar. Se não foi você que acabou de compartilhar, descarte.", + "Start a message": "Iniciar mensagem", "New mail": "Novo e-mail", "Could not do that — open ihasmail and try again": "Não foi possível fazer isso — abra o ihasmail e tente novamente", "Sending…": "Enviando…", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 90842fa..386f5d6 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1165,6 +1165,9 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Новое письмо", + "Start a new message with what was shared?": "Начать новое письмо с полученным содержимым?", + "Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "В ihasmail что-то передали через «Поделиться». Ничего не отправится, пока вы не нажмёте «Отправить». Если вы только что ничего не передавали, нажмите «Не сохранять».", + "Start a message": "Начать письмо", "New mail": "Новое письмо", "Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку", "Sending…": "Отправка…", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 2eccdcb..2d202ce 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1159,6 +1159,9 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "Новий лист", + "Start a new message with what was shared?": "Почати новий лист з отриманим вмістом?", + "Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "До ihasmail щось передали через «Поділитися». Нічого не буде надіслано, доки ви не натиснете «Надіслати». Якщо ви щойно нічого не передавали, натисніть «Не зберігати».", + "Start a message": "Почати лист", "New mail": "Новий лист", "Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу", "Sending…": "Надсилання…", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index a612c65..425eac0 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1170,6 +1170,9 @@ export const catalog: Catalog = { // ── Composer status, calendar title ──────────────────────────────── "New message": "新邮件", + "Start a new message with what was shared?": "用共享的内容新建邮件吗?", + "Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "有内容被共享到 ihasmail。在您选择“发送”之前不会发送任何内容。如果不是您刚才共享的,请放弃。", + "Start a message": "新建邮件", "New mail": "新邮件", "Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试", "Sending…": "正在发送…", diff --git a/web/src/styles/app.css b/web/src/styles/app.css index c1c0495..c6db6e4 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -2418,3 +2418,7 @@ button.dp-open:disabled { cursor: default; opacity: .5; } .ev-resize { position: absolute; left: 0; right: 0; bottom: 0; height: 8px; cursor: ns-resize; touch-action: none; } .ev-resize::after { content: ""; position: absolute; left: 50%; bottom: 2px; width: 18px; height: 2px; margin-left: -9px; border-radius: 2px; background: currentColor; opacity: 0; } .ev-block:hover .ev-resize::after { opacity: .5; } + +/* What arrived from the operating system's share sheet, shown before it is opened. */ +.share-summary { margin: 0 0 8px; padding-left: 1ex; border-left: 2px solid var(--border-strong); color: var(--fg-muted); overflow-wrap: anywhere; } +.share-summary-files { margin: 0 0 8px; padding-left: 1.2em; color: var(--fg-muted); overflow-wrap: anywhere; } diff --git a/web/src/ui/dialog.tsx b/web/src/ui/dialog.tsx index 5c0692b..1314a16 100644 --- a/web/src/ui/dialog.tsx +++ b/web/src/ui/dialog.tsx @@ -185,7 +185,8 @@ export function ConfirmHost() { ) } > - {req.message &&

{req.message}

} + {/* A paragraph for text; a block for anything with blocks of its own in it. */} + {req.message && (typeof req.message === "string" ?

{req.message}

:
{req.message}
)} {req.kind === "choice" && (
{req.choices?.map((c) => ( diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index d31181b..2e26ed9 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -17,6 +17,7 @@ import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts"; import { MailboxPicker } from "./mail/MailboxPicker"; import { formatSize } from "@/lib/format"; import { collectShare } from "@/lib/shareTarget"; +import { offerShare } from "./ShareOffer"; import { TranslateBoundary } from "@/ui/TranslateBoundary"; import { t } from "@/lib/i18n"; import { hasAdministration } from "@/lib/admin/adminAccess"; @@ -119,11 +120,12 @@ export function AppShell({ children }: { children: ReactNode }) { * `addFiles` uploads as it goes, and there is nothing to upload to until the * session is in place. AppShell only exists once there is one. */ + // Asked about first, not opened straight away: see `offerShare`. useEffect(() => { - void collectShare().then((share) => { + void collectShare().then(async (share) => { if (!share) return; - openShare(share); if (new URLSearchParams(window.location.search).has("share")) navigate("/mail", { replace: true }); + await offerShare(share, openShare); }); }, [openShare, navigate]); diff --git a/web/src/views/ShareOffer.tsx b/web/src/views/ShareOffer.tsx new file mode 100644 index 0000000..7d0bd1a --- /dev/null +++ b/web/src/views/ShareOffer.tsx @@ -0,0 +1,45 @@ +import { shareSummary, type SharedContent } from "@/lib/shareTarget"; +import { confirmDialog } from "@/ui/dialog"; +import { t } from "@/lib/i18n"; + +/** + * Ask before a share becomes a message. + * + * The share address takes a plain form POST, so any website can send one, and + * the page cannot tell that from a share the reader made. Nothing would be + * sent without them pressing Send, but a composer that appears full of + * somebody else's text and files is still something to be asked about first. + */ +export async function offerShare(share: SharedContent, open: (share: SharedContent) => unknown): Promise { + const yes = await confirmDialog({ + title: t("Start a new message with what was shared?"), + message: , + confirmLabel: t("Start a message"), + cancelLabel: t("Discard"), + }); + if (yes) open(share); + return yes; +} + +/** What arrived, so the reader can tell whether it is theirs. */ +function ShareSummary({ share }: { share: SharedContent }) { + const { title, preview, files } = shareSummary(share); + return ( +
+ {(title || preview) && ( +
+ {title && {title}} + {title && preview &&
} + {preview} +
+ )} + {files.length > 0 && ( +
    + {files.slice(0, 5).map((name, i) =>
  • {name}
  • )} + {files.length > 5 &&
  • } +
+ )} +

{t("Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.")}

+
+ ); +} diff --git a/web/src/views/__tests__/share-offer.test.tsx b/web/src/views/__tests__/share-offer.test.tsx new file mode 100644 index 0000000..f01bb45 --- /dev/null +++ b/web/src/views/__tests__/share-offer.test.tsx @@ -0,0 +1,69 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ConfirmHost } from "@/ui/dialog"; +import { offerShare } from "../ShareOffer"; +import type { SharedContent } from "@/lib/shareTarget"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +/** + * A share becomes a message only when the reader says so. The share address + * takes a plain form POST, which any website can make. + */ + +const share: SharedContent = { + title: "Quarterly figures", + text: "Have a look at these before Friday", + url: "https://example.com/q3", + files: [new File(["x"], "q3.xlsx"), new File(["y"], "notes.txt")], +}; + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + act(() => root.render()); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); + document.body.innerHTML = ""; +}); + +const button = (label: string) => [...document.querySelectorAll("button")].find((b) => b.textContent?.trim() === label); + +describe("offering a share", () => { + it("shows what arrived before anything is opened", async () => { + const open = vi.fn(); + let pending!: Promise; + await act(async () => { + pending = offerShare(share, open); + }); + const text = document.body.textContent ?? ""; + expect(text).toContain("Start a new message with what was shared?"); + expect(text).toContain("Quarterly figures"); + expect(text).toContain("Have a look at these before Friday https://example.com/q3"); + expect(text).toContain("q3.xlsx"); + expect(text).toContain("notes.txt"); + expect(open).not.toHaveBeenCalled(); + await act(async () => button("Start a message")!.click()); + expect(await pending).toBe(true); + expect(open).toHaveBeenCalledWith(share); + }); + + it("opens nothing when discarded", async () => { + const open = vi.fn(); + let pending!: Promise; + await act(async () => { + pending = offerShare(share, open); + }); + await act(async () => button("Discard")!.click()); + expect(await pending).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); +});