Merge remote-tracking branch 'origin/main' into feat/app-rail-layout
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m44s
ci / announce (pull_request) Skipped
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m44s
ci / announce (pull_request) Skipped
This commit is contained in:
commit
6190d2b4f5
23 files changed
+1011
-44
No files matched your search
@@ -248,7 +248,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
already knows the name they are half-way through typing. */}
|
||||
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")}>{translate("To")}</button>
|
||||
</label>
|
||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
|
||||
<RecipientInput id={`${key}-to`} value={d.to} exclude={[...d.cc, ...d.bcc]} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
|
||||
<span className="field-extra">
|
||||
{/* Beside Cc and Bcc, because that is where someone looks when
|
||||
they are thinking about who the message goes to. The label
|
||||
@@ -268,13 +268,13 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
{d.showCc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-cc`}>{translate("Cc")}</label>
|
||||
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
|
||||
<RecipientInput id={`${key}-cc`} value={d.cc} exclude={[...d.to, ...d.bcc]} onChange={(cc) => patch({ cc })} />
|
||||
</div>
|
||||
)}
|
||||
{d.showBcc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-bcc`}>{translate("Bcc")}</label>
|
||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
|
||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} exclude={[...d.to, ...d.cc]} onChange={(bcc) => patch({ bcc })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
|
||||
@@ -12,9 +12,11 @@ interface Props {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
/** Addresses already in the message's other recipient fields, not to be suggested again. */
|
||||
exclude?: EmailAddress[];
|
||||
}
|
||||
|
||||
export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: Props) {
|
||||
export function RecipientInput({ value, onChange, placeholder, autoFocus, id, exclude }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [sugg, setSugg] = useState<Suggestion[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
@@ -34,7 +36,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
|
||||
const t = window.setTimeout(() => {
|
||||
void suggest(q).then((list) => {
|
||||
if (id !== reqId.current) return;
|
||||
const existing = new Set(value.map((v) => v.email.toLowerCase()));
|
||||
const existing = new Set([...value, ...(exclude ?? [])].map((v) => v.email.toLowerCase()));
|
||||
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
|
||||
setSugg(filtered);
|
||||
setActive(0);
|
||||
@@ -42,7 +44,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
|
||||
});
|
||||
}, 120);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [text, suggest, value]);
|
||||
}, [text, suggest, value, exclude]);
|
||||
|
||||
const commit = (raw?: string) => {
|
||||
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
|
||||
|
||||
@@ -25,6 +25,15 @@ interface Props {
|
||||
imageUpload?: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
/** Pasted or dropped images larger than this go in as attachments, not inline. */
|
||||
export const INLINE_IMAGE_MAX = 10 * 1024 * 1024;
|
||||
|
||||
/** A single http(s) or mailto address, nothing else: what may become a link over a selection. */
|
||||
export function isLinkToPaste(text: string): boolean {
|
||||
if (!text || /\s/.test(text)) return false;
|
||||
return /^(https?:\/\/[^\s]+|mailto:[^\s@]+@[^\s@]+)$/i.test(text);
|
||||
}
|
||||
|
||||
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
|
||||
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
|
||||
|
||||
@@ -133,10 +142,25 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
const f = imgItem.getAsFile();
|
||||
if (f) {
|
||||
e.preventDefault();
|
||||
insertImageFile(f);
|
||||
// A large image is a file to send, not a picture in the text: it goes
|
||||
// in as an attachment, where an inline copy would swell every reply.
|
||||
if (f.size > INLINE_IMAGE_MAX && onFiles) onFiles([f]);
|
||||
else insertImageFile(f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* A link pasted over selected words makes those words the link, the way
|
||||
* most editors do, instead of replacing them with the address.
|
||||
*/
|
||||
const pastedText = e.clipboardData.getData("text/plain").trim();
|
||||
const sel = window.getSelection();
|
||||
if (isLinkToPaste(pastedText) && sel && !sel.isCollapsed && elRef.current?.contains(sel.anchorNode)) {
|
||||
e.preventDefault();
|
||||
document.execCommand("createLink", false, pastedText);
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
const htmlData = e.clipboardData.getData("text/html");
|
||||
if (htmlData) {
|
||||
e.preventDefault();
|
||||
@@ -175,8 +199,9 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const others = files.filter((f) => !f.type.startsWith("image/"));
|
||||
const inline = (f: File) => f.type.startsWith("image/") && !(f.size > INLINE_IMAGE_MAX && onFiles);
|
||||
const images = files.filter(inline);
|
||||
const others = files.filter((f) => !inline(f));
|
||||
images.forEach(insertImageFile);
|
||||
if (others.length) onFiles?.(others);
|
||||
};
|
||||
|
||||
@@ -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 { INLINE_IMAGE_MAX, RichEditor, isLinkToPaste } from "../RichEditor";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||
|
||||
/*
|
||||
* Two things pasting into a message now does: a link pasted over selected
|
||||
* words turns those words into the link, and an image too large to belong
|
||||
* in the text goes in as an attachment. jsdom does no editing, so these check
|
||||
* the command and the callback the editor reaches for.
|
||||
*/
|
||||
|
||||
describe("what counts as a link to paste", () => {
|
||||
it("takes one web or mailto address", () => {
|
||||
expect(isLinkToPaste("https://example.com/a?b=1")).toBe(true);
|
||||
expect(isLinkToPaste("http://example.com")).toBe(true);
|
||||
expect(isLinkToPaste("mailto:[email protected]")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves ordinary text and anything with spaces alone", () => {
|
||||
expect(isLinkToPaste("see https://example.com")).toBe(false);
|
||||
expect(isLinkToPaste("example.com")).toBe(false);
|
||||
expect(isLinkToPaste("javascript:alert(1)")).toBe(false);
|
||||
expect(isLinkToPaste("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pasting into the editor", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let onFiles: ReturnType<typeof vi.fn<(files: File[]) => void>>;
|
||||
let exec: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
onFiles = vi.fn<(files: File[]) => void>();
|
||||
exec = vi.fn(() => true);
|
||||
(document as unknown as { execCommand: unknown }).execCommand = exec;
|
||||
await act(async () => {
|
||||
root.render(<RichEditor html="<p>read the docs here</p>" onChange={() => {}} onFiles={onFiles} showToolbar={false} />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
|
||||
|
||||
const paste = async (data: { text?: string; html?: string; files?: File[] }) => {
|
||||
const items = (data.files ?? []).map((f) => ({ type: f.type, kind: "file", getAsFile: () => f }));
|
||||
const ev = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "clipboardData", {
|
||||
value: { items, getData: (t: string) => (t === "text/plain" ? data.text ?? "" : t === "text/html" ? data.html ?? "" : "") },
|
||||
});
|
||||
await act(async () => editor().dispatchEvent(ev));
|
||||
return ev;
|
||||
};
|
||||
|
||||
const select = (word: string) => {
|
||||
const text = editor().querySelector("p")!.firstChild!;
|
||||
const i = text.textContent!.indexOf(word);
|
||||
const r = document.createRange();
|
||||
r.setStart(text, i);
|
||||
r.setEnd(text, i + word.length);
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(r);
|
||||
};
|
||||
|
||||
it("turns selected words into the pasted link", async () => {
|
||||
select("docs");
|
||||
const ev = await paste({ text: "https://example.com/docs" });
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(exec).toHaveBeenCalledWith("createLink", false, "https://example.com/docs");
|
||||
});
|
||||
|
||||
it("pastes a link as text when nothing is selected", async () => {
|
||||
window.getSelection()!.removeAllRanges();
|
||||
await paste({ text: "https://example.com/docs" });
|
||||
expect(exec).not.toHaveBeenCalledWith("createLink", expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it("attaches a pasted image too large to sit in the text", async () => {
|
||||
const big = new File([new Uint8Array(8)], "huge.png", { type: "image/png" });
|
||||
Object.defineProperty(big, "size", { value: INLINE_IMAGE_MAX + 1 });
|
||||
await paste({ files: [big] });
|
||||
expect(onFiles).toHaveBeenCalledWith([big]);
|
||||
});
|
||||
|
||||
it("keeps a normal-sized pasted image in the text", async () => {
|
||||
const small = new File([new Uint8Array(8)], "shot.png", { type: "image/png" });
|
||||
await paste({ files: [small] });
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RecipientInput } from "../RecipientInput";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/*
|
||||
* Someone already in Cc was still offered when typing in To, so it was easy
|
||||
* to address the same person twice from two fields.
|
||||
*/
|
||||
|
||||
describe("suggestions across To, Cc and Bcc", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
useContacts.setState({
|
||||
suggest: (async () => [
|
||||
{ name: "Ann Lee", email: "[email protected]", source: "contact" },
|
||||
{ name: "Ann Taylor", email: "[email protected]", source: "contact" },
|
||||
]) as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("leaves out an address already in another recipient field", async () => {
|
||||
await act(async () => {
|
||||
root.render(<RecipientInput value={[]} exclude={[{ name: null, email: "[email protected]" }]} onChange={() => {}} />);
|
||||
});
|
||||
const input = host.querySelector("input")!;
|
||||
await act(async () => {
|
||||
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
set.call(input, "ann");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
const offered = host.textContent ?? "";
|
||||
expect(offered).toContain("[email protected]");
|
||||
expect(offered).not.toContain("[email protected]");
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,14 @@ export function CalendarSettings() {
|
||||
hint={t("A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar\u2019s own sidebar without turning it off here.")}
|
||||
/>
|
||||
|
||||
<h2>{t("Contacts")}</h2>
|
||||
<Switch
|
||||
checked={s.collectRecipients}
|
||||
onChange={(v) => update({ collectRecipients: v })}
|
||||
label={t("Save people you write to as contacts")}
|
||||
hint={t("Everyone you send to who isn\u2019t a contact yet is added to the Collected address book, so they\u2019re suggested on every device. Your own addresses are never added.")}
|
||||
/>
|
||||
|
||||
<h2>{t("Working hours")}</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
|
||||
Reference in new issue
Block a user