Merge pull request #391 from Coffey-Labs/feat/share-confirm

Ask before opening a shared item in a message
This commit is contained in:
jcoffey
2026-09-16 12:27:33 -07:00
committed by GitHub
16 changed files with 182 additions and 4 deletions
+17 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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"; 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"); 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);
});
});
+14
View File
@@ -117,3 +117,17 @@ export function shareBody(share: Pick<SharedContent, "text" | "url">): string {
if (!url || text.includes(url)) return text; if (!url || text.includes(url)) return text;
return text ? `${text}\n\n${url}` : url; 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) };
}
+3
View File
@@ -1190,6 +1190,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Neue Nachricht", "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", "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", "Could not do that — open ihasmail and try again": "Nicht möglich öffnen Sie ihasmail und versuchen Sie es erneut",
"Sending…": "Wird gesendet…", "Sending…": "Wird gesendet…",
+3
View File
@@ -1163,6 +1163,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Mensaje nuevo", "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", "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", "Could not do that — open ihasmail and try again": "No se pudo hacer eso: abra ihasmail e inténtelo de nuevo",
"Sending…": "Enviando…", "Sending…": "Enviando…",
+3
View File
@@ -1168,6 +1168,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nouveau message", "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", "New mail": "Nouveau courrier",
"Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez", "Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez",
"Sending…": "Envoi…", "Sending…": "Envoi…",
+3
View File
@@ -1171,6 +1171,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "新規メール", "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": "新着メール", "New mail": "新着メール",
"Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください", "Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください",
"Sending…": "送信中…", "Sending…": "送信中…",
+3
View File
@@ -1161,6 +1161,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nieuw bericht", "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", "New mail": "Nieuwe e-mail",
"Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw", "Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw",
"Sending…": "Bezig met verzenden…", "Sending…": "Bezig met verzenden…",
+3
View File
@@ -1166,6 +1166,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nova mensagem", "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", "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", "Could not do that — open ihasmail and try again": "Não foi possível fazer isso — abra o ihasmail e tente novamente",
"Sending…": "Enviando…", "Sending…": "Enviando…",
+3
View File
@@ -1165,6 +1165,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Новое письмо", "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": "Новое письмо", "New mail": "Новое письмо",
"Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку", "Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку",
"Sending…": "Отправка…", "Sending…": "Отправка…",
+3
View File
@@ -1159,6 +1159,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Новий лист", "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": "Новий лист", "New mail": "Новий лист",
"Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу", "Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу",
"Sending…": "Надсилання…", "Sending…": "Надсилання…",
+3
View File
@@ -1170,6 +1170,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "新邮件", "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": "新邮件", "New mail": "新邮件",
"Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试", "Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试",
"Sending…": "正在发送…", "Sending…": "正在发送…",
+4
View File
@@ -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 { 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-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; } .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; }
+2 -1
View File
@@ -185,7 +185,8 @@ export function ConfirmHost() {
) )
} }
> >
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>} {/* A paragraph for text; a block for anything with blocks of its own in it. */}
{req.message && (typeof req.message === "string" ? <p style={{ marginTop: 0 }}>{req.message}</p> : <div style={{ marginTop: 0 }}>{req.message}</div>)}
{req.kind === "choice" && ( {req.kind === "choice" && (
<div className="dialog-choices"> <div className="dialog-choices">
{req.choices?.map((c) => ( {req.choices?.map((c) => (
+4 -2
View File
@@ -17,6 +17,7 @@ import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { MailboxPicker } from "./mail/MailboxPicker"; import { MailboxPicker } from "./mail/MailboxPicker";
import { formatSize } from "@/lib/format"; import { formatSize } from "@/lib/format";
import { collectShare } from "@/lib/shareTarget"; import { collectShare } from "@/lib/shareTarget";
import { offerShare } from "./ShareOffer";
import { TranslateBoundary } from "@/ui/TranslateBoundary"; import { TranslateBoundary } from "@/ui/TranslateBoundary";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { hasAdministration } from "@/lib/admin/adminAccess"; 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 * `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. * session is in place. AppShell only exists once there is one.
*/ */
// Asked about first, not opened straight away: see `offerShare`.
useEffect(() => { useEffect(() => {
void collectShare().then((share) => { void collectShare().then(async (share) => {
if (!share) return; if (!share) return;
openShare(share);
if (new URLSearchParams(window.location.search).has("share")) navigate("/mail", { replace: true }); if (new URLSearchParams(window.location.search).has("share")) navigate("/mail", { replace: true });
await offerShare(share, openShare);
}); });
}, [openShare, navigate]); }, [openShare, navigate]);
+45
View File
@@ -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<boolean> {
const yes = await confirmDialog({
title: t("Start a new message with what was shared?"),
message: <ShareSummary share={share} />,
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 (
<div>
{(title || preview) && (
<blockquote className="share-summary notranslate" translate="no">
{title && <strong>{title}</strong>}
{title && preview && <br />}
{preview}
</blockquote>
)}
{files.length > 0 && (
<ul className="share-summary-files notranslate" translate="no">
{files.slice(0, 5).map((name, i) => <li key={`${name}-${i}`}>{name}</li>)}
{files.length > 5 && <li></li>}
</ul>
)}
<p>{t("Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.")}</p>
</div>
);
}
@@ -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(<ConfirmHost />));
});
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<boolean>;
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<boolean>;
await act(async () => {
pending = offerShare(share, open);
});
await act(async () => button("Discard")!.click());
expect(await pending).toBe(false);
expect(open).not.toHaveBeenCalled();
});
});