Ask before opening a shared item in a message

The share address takes a plain form POST, which any website can make,
and the app opened whatever arrived straight into a composer. It now
shows what was shared -- the title, the start of the text and link, and
the file names -- and opens a message only when the reader chooses to.
Discarding drops it.

Confirm dialogs now put a message that is not plain text in a div, since
the summary has blocks of its own.

Three new strings, translated in all nine catalogs.
This commit is contained in:
2026-09-16 12:23:27 -07:00
parent 4c1ceca8e9
commit 191c4e7e68
16 changed files with 182 additions and 4 deletions
+4 -2
View File
@@ -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]);
+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();
});
});