Composer: smarter suggestions, links over selections, big images attached
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 3m5s
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 3m5s
ci / announce (pull_request) Skipped
Recipient suggestions: - the words typed match in any order, each one the start of a word in the name, a nickname, the organization or the address: "jane smi" finds "Smith, Jane", and "globex" finds the people at Globex; - someone written to lately ranks a little above an equal match; - an address already in To, Cc or Bcc is no longer offered in the other two fields. Pasting: - a single web or mailto address pasted over selected words makes those words the link, instead of replacing them with the address; - a pasted or dropped image over 10 MB goes in as an attachment rather than inline, where it would swell every reply. No new strings.
This commit is contained in:
1 parent
3434a5ed39
commit
b11f3997d4
7 files changed
+275
-15
No files matched your search
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* What the recipient field offers as you type. Matching used to take the
|
||||
* typed text as one piece, so "jane smi" found nobody filed as "Smith, Jane",
|
||||
* and a nickname or a company name found nothing at all.
|
||||
*/
|
||||
|
||||
const card = (id: string, full: string, email: string, extra: Partial<ContactCard> = {}): ContactCard =>
|
||||
({ id, uid: id, addressBookIds: { b1: true }, name: { full }, emails: { e: { address: email } }, ...extra }) as unknown as ContactCard;
|
||||
|
||||
beforeEach(() => {
|
||||
useContacts.setState({
|
||||
accountId: "a1",
|
||||
available: false,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
principalsLoaded: true,
|
||||
principals: [],
|
||||
sharedCards: {},
|
||||
recent: [],
|
||||
cards: {
|
||||
c1: card("c1", "Smith, Jane", "[email protected]"),
|
||||
c2: card("c2", "Robert Jones", "[email protected]", { nicknames: { n: { name: "Bobby" } } } as Partial<ContactCard>),
|
||||
c3: card("c3", "Ann Lee", "[email protected]", { organizations: { o: { name: "Globex Industries" } } } as Partial<ContactCard>),
|
||||
c4: card("c4", "Ann Taylor", "[email protected]"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const emails = async (q: string) => (await useContacts.getState().suggest(q)).map((s) => s.email);
|
||||
|
||||
describe("recipient suggestions", () => {
|
||||
it("matches the words typed in any order", async () => {
|
||||
expect(await emails("jane smi")).toEqual(["[email protected]"]);
|
||||
expect(await emails("smi jan")).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does not match when one of the words fits nobody", async () => {
|
||||
expect(await emails("jane xyz")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches a nickname and an organization", async () => {
|
||||
expect(await emails("bobby")).toEqual(["[email protected]"]);
|
||||
expect(await emails("globex")).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("puts someone written to lately ahead of an equal match", async () => {
|
||||
expect(await emails("ann")).toEqual(["[email protected]", "[email protected]"]);
|
||||
useContacts.setState({ recent: [{ name: "Ann Taylor", email: "[email protected]" }] });
|
||||
expect((await emails("ann"))[0]).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("still ranks a match at the start above a word inside the name", async () => {
|
||||
// "jo" starts Jones's surname word; it starts no other card's name or address.
|
||||
expect(await emails("jo")).toEqual(["[email protected]"]);
|
||||
});
|
||||
});
|
||||
@@ -793,14 +793,29 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
seen.add(k);
|
||||
out.push(s);
|
||||
};
|
||||
const score = (name: string | null, email: string): number => {
|
||||
/*
|
||||
* Words in any order: "jane smi" finds "Smith, Jane", and a nickname or the
|
||||
* organization counts as much as the name. Each word typed has to start a
|
||||
* word of the person's name, nickname, organization or address.
|
||||
*/
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
const wordsOf = (parts: Array<string | null | undefined>) =>
|
||||
parts.flatMap((p) => (p ?? "").toLowerCase().split(/[\s,.;:@_+()<>"'-]+/)).filter(Boolean);
|
||||
const score = (name: string | null, email: string, extra: Array<string | null | undefined> = []): number => {
|
||||
const n = (name ?? "").toLowerCase();
|
||||
const e = email.toLowerCase();
|
||||
if (e.startsWith(q) || n.startsWith(q)) return 0;
|
||||
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
|
||||
const words = wordsOf([name, email, ...extra]);
|
||||
if (tokens.every((t) => words.some((w) => w.startsWith(t)))) return 1;
|
||||
if (e.includes(q) || n.includes(q)) return 2;
|
||||
return 99;
|
||||
};
|
||||
// Someone written to lately ranks a little above someone not, within the same kind of match.
|
||||
const recentRank = new Map(st.recent.map((r, i) => [r.email.toLowerCase(), i] as const));
|
||||
const recency = (email: string) => {
|
||||
const i = recentRank.get(email.toLowerCase());
|
||||
return i === undefined ? 0 : -0.3 * (1 - i / Math.max(1, st.recent.length));
|
||||
};
|
||||
const candidates: Array<Suggestion & { score: number }> = [];
|
||||
// A shared address book is only useful if it answers when you are writing
|
||||
// to someone in it, so its cards are offered alongside the reader's own.
|
||||
@@ -808,19 +823,20 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
|
||||
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
|
||||
for (const { c, penalty } of [...own, ...shared]) {
|
||||
const extra = [...Object.values(c.nicknames ?? {}).map((x) => x.name), ...Object.values(c.organizations ?? {}).map((x) => x.name)];
|
||||
for (const a of contactEmails(c)) {
|
||||
const sc = score(a.name, a.email);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty });
|
||||
const sc = score(a.name, a.email, extra);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty + recency(a.email) });
|
||||
}
|
||||
}
|
||||
for (const p of st.principals) {
|
||||
if (!p.email) continue;
|
||||
const sc = score(p.name, p.email);
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 });
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 + recency(p.email) });
|
||||
}
|
||||
for (const r of st.recent) {
|
||||
const sc = score(r.name, r.email);
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 });
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 + recency(r.email) });
|
||||
}
|
||||
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
||||
for (const c of candidates) {
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user