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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 (#375 review).
|
||||||
|
*/
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…": "送信中…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…": "Отправка…",
|
||||||
|
|||||||
@@ -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…": "Надсилання…",
|
||||||
|
|||||||
@@ -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…": "正在发送…",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user