mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/desktop-offline' into feat/do-ux-pim-fixes
# Conflicts: # stores/settings-store.ts
This commit is contained in:
@@ -37,7 +37,9 @@ describe('keyboard shortcuts help per app', () => {
|
||||
nav.pathname = '/';
|
||||
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getAllByText('or').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Ctrl + K').length).toBe(1);
|
||||
// The command palette, and Insert link in the composer.
|
||||
expect(screen.getAllByText('Ctrl + K').length).toBe(2);
|
||||
expect(screen.queryByText('⌘ + K')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DirectoryPickerDialog } from '../directory-picker-dialog';
|
||||
import { useContactStore, type DirectoryEntry, type DirectoryStatus } from '@/stores/contact-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
// next-intl is mocked globally in vitest.setup (t returns the key).
|
||||
|
||||
@@ -25,7 +26,11 @@ function rows() {
|
||||
}
|
||||
|
||||
describe('DirectoryPickerDialog', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// No contacts of the user's own: the picker opens on the directory.
|
||||
useContactStore.setState({ contacts: [] });
|
||||
});
|
||||
|
||||
it('lists the directory before any search, one page at a time', () => {
|
||||
setDirectory(people(60), 'ready');
|
||||
@@ -109,3 +114,69 @@ describe('DirectoryPickerDialog', () => {
|
||||
expect(screen.queryByText('refresh_failed')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// compose-17: the picker listed only the server's directory, not the
|
||||
// contacts the To field's suggestions offer.
|
||||
describe('DirectoryPickerDialog with contacts', () => {
|
||||
function contact(id: string, name: string, ...emails: string[]): ContactCard {
|
||||
return {
|
||||
id, addressBookIds: { book: true },
|
||||
name: { full: name },
|
||||
emails: Object.fromEntries(emails.map((address, i) => [`e${i}`, { address }])),
|
||||
} as unknown as ContactCard;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setDirectory([{ name: 'Server Account', email: 'server@example.org' }], 'ready');
|
||||
useContactStore.setState({
|
||||
contacts: [
|
||||
contact('c1', 'Zoe Fischer', 'zoe@example.com', 'zoe.f@work.example'),
|
||||
contact('c2', 'Anna Becker', 'anna@example.com'),
|
||||
{ id: 'g1', kind: 'group', name: { full: 'Team' } } as unknown as ContactCard,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('opens on the contacts, one row per address, without groups', () => {
|
||||
render(<DirectoryPickerDialog client={client} onClose={vi.fn()} onAdd={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'tab_contacts' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(rows().map((r) => r.textContent)).toEqual([
|
||||
expect.stringContaining('anna@example.com'),
|
||||
expect.stringContaining('zoe.f@work.example'),
|
||||
expect.stringContaining('zoe@example.com'),
|
||||
]);
|
||||
expect(screen.queryByText('Server Account')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('searches the contacts', () => {
|
||||
render(<DirectoryPickerDialog client={client} onClose={vi.fn()} onAdd={vi.fn()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'anna' } });
|
||||
expect(rows()).toHaveLength(1);
|
||||
fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'nobody' } });
|
||||
expect(screen.getByText('contacts_no_match')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds what was checked on both tabs', () => {
|
||||
const onAdd = vi.fn();
|
||||
render(<DirectoryPickerDialog client={client} onClose={vi.fn()} onAdd={onAdd} />);
|
||||
fireEvent.click(rows()[0]);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'tab_directory' }));
|
||||
fireEvent.click(rows()[0]);
|
||||
fireEvent.click(screen.getByText('add_to'));
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith('to', [
|
||||
expect.objectContaining({ name: 'Anna Becker', email: 'anna@example.com' }),
|
||||
expect.objectContaining({ email: 'server@example.org' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the contacts alone where the server has no directory', () => {
|
||||
const noDirectory = { supportsPrincipals: () => false } as unknown as IJMAPClient;
|
||||
render(<DirectoryPickerDialog client={noDirectory} onClose={vi.fn()} onAdd={vi.fn()} />);
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
||||
expect(rows()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,13 @@ interface DirectoryBrowserProps {
|
||||
/** Set while the list is being printed. */
|
||||
printing?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* Browse these instead of the server's directory (the recipient picker's
|
||||
* Contacts tab): nothing is fetched and there is nothing to refresh.
|
||||
*/
|
||||
entries?: DirectoryEntry[];
|
||||
/** With `entries`: the list's name and what an empty list or search says. */
|
||||
labels?: { list: string; emptyTitle: string; emptyHint?: string; noMatch: string };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,13 +76,17 @@ export function DirectoryBrowser({
|
||||
onPrint,
|
||||
printing = false,
|
||||
className,
|
||||
entries: ownEntries,
|
||||
labels,
|
||||
}: DirectoryBrowserProps) {
|
||||
const t = useTranslations("contacts.directory");
|
||||
const tContacts = useTranslations("contacts");
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const entries = useContactStore((s) => s.directoryPrincipals);
|
||||
const status = useContactStore((s) => s.directoryStatus);
|
||||
const directoryEntries = useContactStore((s) => s.directoryPrincipals);
|
||||
const directoryStatus = useContactStore((s) => s.directoryStatus);
|
||||
const entries = ownEntries ?? directoryEntries;
|
||||
const status = ownEntries ? "ready" : directoryStatus;
|
||||
const fetchDirectory = useContactStore((s) => s.fetchDirectory);
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -83,8 +94,8 @@ export function DirectoryBrowser({
|
||||
|
||||
// The directory loads at sign-in; fetch here only if that never ran.
|
||||
useEffect(() => {
|
||||
if (client && status === "idle") void fetchDirectory(client);
|
||||
}, [client, status, fetchDirectory]);
|
||||
if (!ownEntries && client && status === "idle") void fetchDirectory(client);
|
||||
}, [ownEntries, client, status, fetchDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocusSearch) searchRef.current?.focus();
|
||||
@@ -117,7 +128,9 @@ export function DirectoryBrowser({
|
||||
};
|
||||
|
||||
let placeholder: React.ReactNode = null;
|
||||
if (entries.length === 0) {
|
||||
if (entries.length === 0 && ownEntries) {
|
||||
placeholder = <StatePanel icon={<BookUser className="w-6 h-6" />} title={labels?.emptyTitle ?? t("empty_title")} hint={labels?.emptyHint} />;
|
||||
} else if (entries.length === 0) {
|
||||
if (loading) {
|
||||
placeholder = (
|
||||
<StatePanel icon={<Loader2 className="w-6 h-6 animate-spin" />} title={t("loading")} />
|
||||
@@ -132,7 +145,7 @@ export function DirectoryBrowser({
|
||||
placeholder = <StatePanel icon={<BookUser className="w-6 h-6" />} title={t("empty_title")} hint={t("empty_hint")} />;
|
||||
}
|
||||
} else if (filtered.length === 0) {
|
||||
placeholder = <StatePanel icon={<Search className="w-6 h-6" />} title={t("no_match")} />;
|
||||
placeholder = <StatePanel icon={<Search className="w-6 h-6" />} title={labels?.noMatch ?? t("no_match")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -163,13 +176,15 @@ export function DirectoryBrowser({
|
||||
onClear={() => { setQuery(""); setPage(0); }}
|
||||
clearLabel={tContacts("clear_search")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label={t("refresh")}
|
||||
onClick={refresh}
|
||||
disabled={!client || status === "loading" || status === "unavailable"}
|
||||
>
|
||||
<RefreshCw className={cn(status === "loading" && "animate-spin [animation-direction:reverse]")} />
|
||||
</ToolbarButton>
|
||||
{!ownEntries && (
|
||||
<ToolbarButton
|
||||
label={t("refresh")}
|
||||
onClick={refresh}
|
||||
disabled={!client || status === "loading" || status === "unavailable"}
|
||||
>
|
||||
<RefreshCw className={cn(status === "loading" && "animate-spin [animation-direction:reverse]")} />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
{onPrint && (
|
||||
<ToolbarButton label={tCommon("print")} onClick={onPrint}>
|
||||
<Printer />
|
||||
@@ -186,7 +201,7 @@ export function DirectoryBrowser({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto" role="list" aria-label={t("title")}>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto" role="list" aria-label={labels?.list ?? t("title")}>
|
||||
{placeholder ?? pageEntries.map((entry) => {
|
||||
const key = directoryKey(entry);
|
||||
const checked = selected.has(key);
|
||||
|
||||
@@ -1,36 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useContactStore, type DirectoryEntry } from "@/stores/contact-store";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { useContactStore, getContactDisplayName, type DirectoryEntry } from "@/stores/contact-store";
|
||||
import { DirectoryBrowser, directoryKey } from "./directory-browser";
|
||||
|
||||
export type RecipientField = "to" | "cc" | "bcc";
|
||||
|
||||
type PickerTab = "contacts" | "directory";
|
||||
|
||||
interface DirectoryPickerDialogProps {
|
||||
client: IJMAPClient | null;
|
||||
onClose: () => void;
|
||||
/** Called with the checked entries in directory order; the dialog closes afterwards. */
|
||||
/** Called with the checked entries in list order; the dialog closes afterwards. */
|
||||
onAdd: (field: RecipientField, entries: DirectoryEntry[]) => void;
|
||||
}
|
||||
|
||||
/** The composer's "Select from directory" dialog. */
|
||||
/** Every address of the user's own contacts, one row each; groups are left out. */
|
||||
export function contactEntries(contacts: ContactCard[]): DirectoryEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const entries: DirectoryEntry[] = [];
|
||||
for (const contact of contacts) {
|
||||
if (contact.kind === "group") continue;
|
||||
const name = getContactDisplayName(contact);
|
||||
for (const email of Object.values(contact.emails ?? {})) {
|
||||
const address = email?.address?.trim();
|
||||
if (!address) continue;
|
||||
const entry = { id: `${contact.id}:${address}`, name: name || address, email: address };
|
||||
if (seen.has(directoryKey(entry))) continue;
|
||||
seen.add(directoryKey(entry));
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's recipient picker: the user's own contacts, and the server's
|
||||
* directory (other accounts) on a tab of its own where the server has one.
|
||||
* It used to list only the directory, without the contacts autocomplete offers.
|
||||
*/
|
||||
export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPickerDialogProps) {
|
||||
const t = useTranslations("contacts.directory");
|
||||
const tSend = useTranslations("compose_send");
|
||||
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
const ownEntries = useMemo(() => contactEntries(contacts ?? []), [contacts]);
|
||||
const hasDirectory = !!client?.supportsPrincipals?.();
|
||||
// Contacts first; straight to the directory for someone who has none.
|
||||
const [tab, setTab] = useState<PickerTab>(() => (ownEntries.length === 0 && hasDirectory ? "directory" : "contacts"));
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const add = (field: RecipientField) => {
|
||||
const entries = useContactStore.getState().directoryPrincipals.filter((e) => selected.has(directoryKey(e)));
|
||||
// Checked on either tab, each address once.
|
||||
const pool = [...ownEntries, ...useContactStore.getState().directoryPrincipals];
|
||||
const seen = new Set<string>();
|
||||
const entries = pool.filter((e) => {
|
||||
const key = directoryKey(e);
|
||||
if (!selected.has(key) || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
if (entries.length === 0) return;
|
||||
onAdd(field, entries);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const tabButton = (value: PickerTab, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`recipient-picker-tab-${value}`}
|
||||
aria-selected={tab === value}
|
||||
aria-controls="recipient-picker-panel"
|
||||
onClick={() => setTab(value)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
tab === value ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
@@ -43,7 +101,7 @@ export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPicke
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 id="directory-picker-title" className="text-sm font-semibold text-foreground">
|
||||
{t("picker_title")}
|
||||
{tSend("picker_title")}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -55,13 +113,40 @@ export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPicke
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DirectoryBrowser
|
||||
client={client}
|
||||
selected={selected}
|
||||
onSelectedChange={setSelected}
|
||||
autoFocusSearch
|
||||
className="flex-1"
|
||||
/>
|
||||
{hasDirectory && (
|
||||
<div role="tablist" className="flex gap-1 px-3 border-b border-border">
|
||||
{tabButton("contacts", tSend("tab_contacts"))}
|
||||
{tabButton("directory", tSend("tab_directory"))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
id="recipient-picker-panel"
|
||||
role={hasDirectory ? "tabpanel" : undefined}
|
||||
aria-labelledby={hasDirectory ? `recipient-picker-tab-${tab}` : undefined}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
>
|
||||
<DirectoryBrowser
|
||||
// A fresh search and first page on each tab.
|
||||
key={tab}
|
||||
client={client}
|
||||
selected={selected}
|
||||
onSelectedChange={setSelected}
|
||||
autoFocusSearch
|
||||
className="flex-1"
|
||||
{...(tab === "contacts"
|
||||
? {
|
||||
entries: ownEntries,
|
||||
labels: {
|
||||
list: tSend("tab_contacts"),
|
||||
emptyTitle: tSend("contacts_empty_title"),
|
||||
emptyHint: tSend("contacts_empty_hint"),
|
||||
noMatch: tSend("contacts_no_match"),
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-3 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground me-auto tabular-nums">
|
||||
|
||||
@@ -307,6 +307,46 @@ describe('recipient fields (SR-06, CV-02)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// compose-6: Backspace never leaves half an address behind.
|
||||
it('selects the last chip on the first Backspace and removes it whole on the second', () => {
|
||||
render(<EmailComposer initialData={{ ...DRAFT, to: 'Anna Berg <anna@example.com>, Zoe Fischer <zoe@example.com>' }} />);
|
||||
const input = screen.getByRole('combobox', { name: 'to:' }) as HTMLInputElement;
|
||||
input.focus();
|
||||
fireEvent.keyDown(input, { key: 'Backspace' });
|
||||
let chips = within(screen.getByRole('list', { name: 'to:' })).getAllByRole('listitem');
|
||||
expect(chips).toHaveLength(2);
|
||||
expect(document.activeElement).toBe(chips[1]);
|
||||
expect(input.value).toBe('');
|
||||
|
||||
fireEvent.keyDown(chips[1], { key: 'Backspace' });
|
||||
chips = within(screen.getByRole('list', { name: 'to:' })).getAllByRole('listitem');
|
||||
expect(chips).toHaveLength(1);
|
||||
expect(chips[0]).toHaveTextContent('Anna Berg');
|
||||
expect(input.value).toBe('');
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
// Three presses take out two recipients, never part of one.
|
||||
fireEvent.keyDown(input, { key: 'Backspace' });
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Backspace' });
|
||||
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('lets go of a selected chip on Escape and keeps the composer open', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<EmailComposer initialData={{ ...DRAFT, to: 'anna@example.com' }} onClose={onClose} />);
|
||||
const input = screen.getByRole('combobox', { name: 'to:' }) as HTMLInputElement;
|
||||
input.focus();
|
||||
fireEvent.keyDown(input, { key: 'Backspace' });
|
||||
const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem');
|
||||
expect(document.activeElement).toBe(chip);
|
||||
fireEvent.keyDown(chip, { key: 'Escape' });
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('announces a recipient added by typing', () => {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
expect(liveRegion()).toHaveTextContent('');
|
||||
@@ -370,17 +410,17 @@ describe('message body (SR-07)', () => {
|
||||
expect(screen.getByLabelText('body_label').tagName).toBe('TEXTAREA');
|
||||
});
|
||||
|
||||
it('marks an empty body invalid and links the error text', async () => {
|
||||
render(<EmailComposer initialData={{ ...DRAFT, to: 'bob@example.com', body: '' }} />);
|
||||
it('asks before sending an empty body instead of marking it invalid', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={{ ...DRAFT, to: 'bob@example.com', subject: 'Hi', body: '' }} onSend={onSend} />);
|
||||
const editor = screen.getByTestId('rich-text-editor');
|
||||
expect(editor).not.toHaveAttribute('data-invalid');
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('composer-subject'), { key: 'Enter', ctrlKey: true });
|
||||
await act(async () => {});
|
||||
|
||||
expect(editor).toHaveAttribute('data-invalid', 'true');
|
||||
const error = document.getElementById(editor.getAttribute('data-describedby')!);
|
||||
expect(error).toHaveTextContent('validation.body_required');
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('empty_text_title');
|
||||
expect(editor).not.toHaveAttribute('data-invalid');
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -233,9 +233,8 @@ describe('composer across an account switch', () => {
|
||||
useAuthStore.setState({ client: clientB as never, activeAccountId: 'acct-B' as never });
|
||||
view.rerender(<EmailComposer {...props} />);
|
||||
|
||||
// Closing saves the draft; no question unless the save fails.
|
||||
act(() => requestCloseRef.current!(vi.fn()));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
fireEvent.click(within(dialog).getByText('save'));
|
||||
|
||||
await waitFor(() => expect(clientA.createDraft).toHaveBeenCalled());
|
||||
expect(clientB.createDraft).not.toHaveBeenCalled();
|
||||
|
||||
@@ -218,21 +218,20 @@ const DRAFT = {
|
||||
attachments: ATTACHMENTS,
|
||||
};
|
||||
|
||||
/** The wrapping flex row that holds every chip and the expand button. */
|
||||
/** The wrapping flex row that holds every chip. */
|
||||
function chipRow() {
|
||||
return screen.getByText('invoice-0.pdf').closest('[class*="flex-wrap"]') as HTMLElement;
|
||||
return screen.getByTestId('composer-attachments');
|
||||
}
|
||||
|
||||
/**
|
||||
* The attachment row is a shrink-0 sibling of the body's scroll container,
|
||||
* so whatever height it takes is taken from the composer and from nothing
|
||||
* else. Collapsed that is three chips. Expanded it is one row per two
|
||||
* attachments, and with a few dozen files it grew past the bottom of the
|
||||
* composer: the send toolbar was pushed off-screen and no ancestor
|
||||
* scrolled, so neither the rest of the list nor Send could be reached.
|
||||
* Expanding therefore has to bound the row and scroll it itself.
|
||||
* else. With a few dozen files it grew past the bottom of the composer: the
|
||||
* send toolbar was pushed off-screen and no ancestor scrolled. It used to
|
||||
* show three chips and "+N", which hid the fourth file's upload. Now every
|
||||
* chip shows, wrapping, and the row is bounded and scrolls itself.
|
||||
*/
|
||||
describe('expanded attachment list', () => {
|
||||
describe('attachment list', () => {
|
||||
beforeEach(() => {
|
||||
observers.length = 0;
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
|
||||
@@ -242,39 +241,27 @@ describe('expanded attachment list', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not scroll while collapsed', () => {
|
||||
it('shows every attachment, with no counter to expand', () => {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
|
||||
expect(chipRow().className).not.toContain('overflow-y-auto');
|
||||
expect(screen.getByText('invoice-3.pdf')).toBeInTheDocument();
|
||||
expect(screen.getByText('invoice-11.pdf')).toBeInTheDocument();
|
||||
expect(screen.queryByText(`+${ATTACHMENTS.length - 3}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows only the first three attachments and a counter for the rest', () => {
|
||||
it('caps its height and scrolls itself', () => {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
|
||||
expect(screen.getByText('invoice-2.pdf')).toBeInTheDocument();
|
||||
expect(screen.queryByText('invoice-3.pdf')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(`+${ATTACHMENTS.length - 3}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('caps its height and scrolls once expanded', () => {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
|
||||
fireEvent.click(screen.getByText(`+${ATTACHMENTS.length - 3}`));
|
||||
|
||||
const row = chipRow();
|
||||
expect(screen.getByText('invoice-11.pdf')).toBeInTheDocument();
|
||||
expect(row.className).toContain('overflow-y-auto');
|
||||
// Bounded, or the row grows with the attachment count again and takes
|
||||
// the send toolbar off the bottom of the composer with it.
|
||||
expect(row.className).toContain('max-h-[');
|
||||
});
|
||||
|
||||
it('stops scrolling again when collapsed back', () => {
|
||||
it('says how many files and how much they weigh', () => {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
|
||||
fireEvent.click(screen.getByText(`+${ATTACHMENTS.length - 3}`));
|
||||
fireEvent.click(screen.getByText('show_less'));
|
||||
|
||||
expect(chipRow().className).not.toContain('overflow-y-auto');
|
||||
expect(screen.getByTestId('composer-attachments-summary')).toHaveTextContent('attachments_summary');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,6 +135,9 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
const draftToast = vi.hoisted(() => ({ showDraftSavedToast: vi.fn() }));
|
||||
vi.mock('@/lib/compose/draft-close-toast', () => draftToast);
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
@@ -164,6 +167,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
escapeHtml: (v: string) => v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'),
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
@@ -213,8 +217,9 @@ describe('a message row dragged over the composer', () => {
|
||||
/**
|
||||
* A host that replaces the compose session - a mailto: click, say - must not
|
||||
* drop text the user has not sent. It hands a follow-up to requestCloseRef and
|
||||
* the composer runs it only once it has really closed, so "Save" and "Discard"
|
||||
* continue into the new session while "Cancel" leaves the draft standing.
|
||||
* the composer runs it only once it has really closed. Closing saves the draft
|
||||
* without asking; only a failed save asks, and there "Discard" continues into
|
||||
* the new session while "Cancel" leaves the draft standing.
|
||||
*/
|
||||
|
||||
const DIRTY_DRAFT = {
|
||||
@@ -253,13 +258,40 @@ function renderWithCloseRef(props: Record<string, unknown> = {}) {
|
||||
beforeEach(resetAnnouncerForTests);
|
||||
|
||||
describe('composer close guard with a follow-up', () => {
|
||||
it('asks before replacing an unsaved draft instead of dropping it', async () => {
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef();
|
||||
const savingClient = (createDraft = vi.fn().mockResolvedValue('draft-9')) => {
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
return createDraft;
|
||||
};
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
it('saves the draft and runs the follow-up, without asking', async () => {
|
||||
const createDraft = savingClient();
|
||||
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
|
||||
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
|
||||
expect(await screen.findByText('close_draft_title')).toBeInTheDocument();
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
expect(createDraft).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks only when the save fails, and keeps the text', async () => {
|
||||
// No connection to save through: the save fails.
|
||||
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
|
||||
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('save_failed_close');
|
||||
expect(within(dialog).getByRole('button', { name: /try_again/ })).toBeInTheDocument();
|
||||
expect(afterClose).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByDisplayValue('Half-written more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('in the desktop app', () => {
|
||||
@@ -273,12 +305,12 @@ describe('composer close guard with a follow-up', () => {
|
||||
return confirm;
|
||||
};
|
||||
|
||||
it('asks through the OS, and shows no dialog of its own', async () => {
|
||||
it('asks through the OS when the save fails, and shows no dialog of its own', async () => {
|
||||
const confirm = nativeBridge(2);
|
||||
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
|
||||
expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]);
|
||||
expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', detail: 'save_failed_close', buttons: ['try_again', 'discard', 'cancel'], defaultId: 0 }]);
|
||||
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
|
||||
// The last button is Cancel: nothing happens.
|
||||
await act(async () => {});
|
||||
@@ -293,9 +325,18 @@ describe('composer close guard with a follow-up', () => {
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('saves without asking when the save works', async () => {
|
||||
const confirm = nativeBridge(2);
|
||||
savingClient();
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef();
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('runs the follow-up after discarding', async () => {
|
||||
it('runs the follow-up after discarding a draft that could not be saved', async () => {
|
||||
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
|
||||
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
@@ -327,44 +368,77 @@ describe('composer close guard with a follow-up', () => {
|
||||
expect(afterClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes without asking once autosave has stored every change', async () => {
|
||||
const createDraft = vi.fn().mockResolvedValue('draft-9');
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
try {
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef();
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
await act(async () => {});
|
||||
it('closes without saving again once autosave has stored every change', async () => {
|
||||
const createDraft = savingClient();
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef();
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
await act(async () => {});
|
||||
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
|
||||
} finally {
|
||||
useAuthStore.setState({ client: null });
|
||||
}
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
expect(createDraft).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still asks when there are edits since the last autosave', async () => {
|
||||
const createDraft = vi.fn().mockResolvedValue('draft-9');
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
try {
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef();
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
|
||||
it('saves the edits made since the last autosave when it closes', async () => {
|
||||
const createDraft = savingClient();
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef();
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
|
||||
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
|
||||
expect(await screen.findByText('close_draft_title')).toBeInTheDocument();
|
||||
} finally {
|
||||
useAuthStore.setState({ client: null });
|
||||
}
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
expect(createDraft).toHaveBeenCalledTimes(2);
|
||||
expect(createDraft.mock.calls[1][1]).toBe('Half-written more and more');
|
||||
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says the draft was kept, with a Discard that deletes it through the host', async () => {
|
||||
savingClient();
|
||||
draftToast.showDraftSavedToast.mockClear();
|
||||
const onDiscardDraft = vi.fn();
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef({ onDiscardDraft });
|
||||
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
expect(draftToast.showDraftSavedToast).toHaveBeenCalledTimes(1);
|
||||
const [discard, labels] = draftToast.showDraftSavedToast.mock.calls[0];
|
||||
expect(labels).toMatchObject({ saved: 'draft_saved', discard: 'discard' });
|
||||
discard();
|
||||
expect(onDiscardDraft).toHaveBeenCalledWith('draft-9', expect.anything());
|
||||
});
|
||||
|
||||
// compose-19: the keys people bring from other mail apps.
|
||||
it('saves the draft on Ctrl+S, and keeps the browser from saving the page', async () => {
|
||||
const createDraft = savingClient();
|
||||
renderWithCloseRef();
|
||||
const subject = screen.getByDisplayValue('Half-written more');
|
||||
expect(fireEvent.keyDown(subject, { key: 's', ctrlKey: true })).toBe(false);
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Nothing new to save: it still says so.
|
||||
await waitFor(() => expect(screen.getByTestId('composer-save-status')).toHaveAttribute('data-status', 'idle'), { timeout: 4000 });
|
||||
fireEvent.keyDown(subject, { key: 's', ctrlKey: true });
|
||||
await waitFor(() => expect(screen.getByTestId('composer-save-status')).toHaveAttribute('data-status', 'saved'));
|
||||
expect(createDraft).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows Cc on Ctrl+Shift+C and Bcc on Ctrl+Shift+B, and puts the cursor there', async () => {
|
||||
renderWithCloseRef();
|
||||
// A new message puts the cursor in To shortly after it opens; let that pass.
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 250)); });
|
||||
const subject = screen.getByDisplayValue('Half-written more');
|
||||
expect(fireEvent.keyDown(subject, { key: 'C', ctrlKey: true, shiftKey: true })).toBe(false);
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'cc_label' })));
|
||||
expect(fireEvent.keyDown(subject, { key: 'B', ctrlKey: true, shiftKey: true })).toBe(false);
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'bcc_label' })));
|
||||
});
|
||||
|
||||
it('closes straight through when there is nothing to lose', async () => {
|
||||
draftToast.showDraftSavedToast.mockClear();
|
||||
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
|
||||
const afterClose = vi.fn();
|
||||
render(<EmailComposer requestCloseRef={requestCloseRef} onClose={vi.fn()} />);
|
||||
@@ -373,67 +447,37 @@ describe('composer close guard with a follow-up', () => {
|
||||
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
|
||||
expect(draftToast.showDraftSavedToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs the follow-up after saving the draft', async () => {
|
||||
const createDraft = vi.fn().mockResolvedValue('draft-9');
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
try {
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef();
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
fireEvent.click(within(dialog).getByText('save'));
|
||||
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
expect(createDraft).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
useAuthStore.setState({ client: null });
|
||||
}
|
||||
});
|
||||
|
||||
it('tells screen readers the draft was saved when the user saves, not on autosave', async () => {
|
||||
const createDraft = vi.fn().mockResolvedValue('draft-9');
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
it('tells screen readers the draft was saved when the user closes, not on autosave', async () => {
|
||||
const createDraft = savingClient();
|
||||
const polite = () => document.getElementById(LIVE_REGION_IDS.polite);
|
||||
try {
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef();
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(polite()?.textContent ?? '').not.toContain('draft_saved');
|
||||
const { requestCloseRef, afterClose } = renderWithCloseRef();
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(polite()?.textContent ?? '').not.toContain('draft_saved');
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
fireEvent.click(within(await screen.findByRole('alertdialog')).getByText('save'));
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
|
||||
await waitFor(() => expect(polite()?.textContent).toContain('draft_saved'));
|
||||
} finally {
|
||||
useAuthStore.setState({ client: null });
|
||||
}
|
||||
await waitFor(() => expect(polite()?.textContent).toContain('draft_saved'));
|
||||
});
|
||||
|
||||
it('keeps the composer open and drops the follow-up when the save fails', async () => {
|
||||
it('keeps the composer open, says why and drops the follow-up when the save fails', async () => {
|
||||
// A refused save keeps the live composer recoverable. Replacing it via a
|
||||
// close follow-up would bury the unsaved text. Navigation can still stash
|
||||
// that text through the existing unmount path.
|
||||
const createDraft = vi.fn().mockRejectedValue(new Error('server refused'));
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
const createDraft = savingClient(vi.fn().mockRejectedValue(new Error('server refused')));
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const onSaveState = vi.fn();
|
||||
const { requestCloseRef, afterClose, onClose, unmount } = renderWithCloseRef({ onSaveState });
|
||||
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
fireEvent.click(within(dialog).getByText('save'));
|
||||
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalled());
|
||||
await act(async () => {});
|
||||
expect(await screen.findByRole('alertdialog')).toHaveTextContent('save_failed_close');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByDisplayValue('Half-written more')).toBeInTheDocument();
|
||||
expect(afterClose).not.toHaveBeenCalled();
|
||||
@@ -444,7 +488,6 @@ describe('composer close guard with a follow-up', () => {
|
||||
expect.objectContaining({ subject: 'Half-written more' }),
|
||||
);
|
||||
} finally {
|
||||
useAuthStore.setState({ client: null });
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -107,7 +107,7 @@ afterEach(async () => {
|
||||
vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.clearAllTimers(); vi.useRealTimers();
|
||||
});
|
||||
|
||||
type Control = 'close' | 'toolbar';
|
||||
type Control = 'toolbar';
|
||||
async function openComposer(strict = false) {
|
||||
const closed = vi.fn();
|
||||
let reopen!: () => void;
|
||||
@@ -135,7 +135,7 @@ async function openComposer(strict = false) {
|
||||
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files } }); await advance();
|
||||
},
|
||||
dialog: (control: Control) => {
|
||||
fireEvent.click(screen.getByTestId(control === 'close' ? 'composer-close' : 'composer-discard'));
|
||||
fireEvent.click(screen.getByTestId(control === 'toolbar' ? 'composer-discard' : 'composer-close'));
|
||||
return within(screen.getByRole('alertdialog'));
|
||||
},
|
||||
reopen: async () => { act(() => reopen()); await advance(); },
|
||||
@@ -148,7 +148,9 @@ function expectNewComposer() {
|
||||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||||
expect(screen.queryByText('A.txt')).toBeNull(); expect(screen.queryByText('C.txt')).toBeNull();
|
||||
}
|
||||
const modes = (['close', 'toolbar'] as const).flatMap(control => [false, true].map(strict => ({ control, strict })));
|
||||
// The X no longer asks Save or Discard: it saves (composer-save-pending-attachments).
|
||||
// Discard is the toolbar's.
|
||||
const modes = (['toolbar'] as const).flatMap(control => [false, true].map(strict => ({ control, strict })));
|
||||
|
||||
describe('explicit Discard with pending attachment uploads', () => {
|
||||
describe.each(modes)('$control control, Strict Mode=$strict', ({ control, strict }) => {
|
||||
@@ -232,7 +234,7 @@ describe('explicit Discard with pending attachment uploads', () => {
|
||||
await c.add(file('A.txt'), file('C.txt'));
|
||||
if (method === 'deleteFile') await complete(0);
|
||||
expect(entered).toHaveBeenCalledOnce();
|
||||
fireEvent.click(c.dialog('close').getByRole('button', { name: /^Discard$/ })); await advance();
|
||||
fireEvent.click(c.dialog('toolbar').getByRole('button', { name: /^Discard$/ })); await advance();
|
||||
await c.reopen();
|
||||
await act(async () => { gate.resolve(); }); await advance();
|
||||
await c.add(file('B.txt')); await complete(UploadXHR.instances.findIndex(xhr => xhr.body?.name === 'B.txt'));
|
||||
|
||||
@@ -376,7 +376,8 @@ describe('a re-opened draft that lives in a group account', () => {
|
||||
render(<EmailComposer initialData={GROUP_DRAFT} onDiscardDraft={onDiscardDraft} requestCloseRef={requestCloseRef} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Quarterly numbers'), { target: { value: 'Quarterly numbers v2' } });
|
||||
act(() => requestCloseRef.current!());
|
||||
// Closing keeps the draft now; Discard is the way to delete it.
|
||||
fireEvent.click(screen.getAllByTestId('composer-discard')[0]);
|
||||
fireEvent.click(within(screen.getByRole('alertdialog')).getByText('discard'));
|
||||
|
||||
expect(onDiscardDraft).toHaveBeenCalledWith('eaaaaab', { accountId: 'c', localAccountId: undefined });
|
||||
@@ -393,7 +394,8 @@ describe('a re-opened draft that lives in a group account', () => {
|
||||
expect(createDraft.mock.calls[0][13]?.previousDraftAccountId).toBeUndefined();
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Quarterly numbers v2'), { target: { value: 'Quarterly numbers v3' } });
|
||||
act(() => requestCloseRef.current!());
|
||||
// Closing keeps the draft now; Discard is the way to delete it.
|
||||
fireEvent.click(screen.getAllByTestId('composer-discard')[0]);
|
||||
fireEvent.click(within(screen.getByRole('alertdialog')).getByText('discard'));
|
||||
expect(onDiscardDraft).toHaveBeenCalledWith('draft-v2', { accountId: undefined, localAccountId: undefined });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { emailHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
// "Require encrypted delivery" read like encryption but only asks the servers
|
||||
// for TLS on the way. It is now named for what it does and is an advanced
|
||||
// option, and without a crypto plugin an "Encrypt…" item says where to get one.
|
||||
|
||||
// ─── Heavy component mocks (mirrors composer-close-guard.test.tsx) ──────────
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push, back: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/en',
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
showAdvancedSettings: false,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: (...args: unknown[]) => updateSetting(...args),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { size: 0, takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
sanitizeSignatureHtmlForDisplay: (v: string) => v,
|
||||
sanitizePluginBodyHtml: (v: string) => v,
|
||||
escapeHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRAFT = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Plans',
|
||||
body: '<p>Hello</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const openOptions = () => fireEvent.click(screen.getByTestId('composer-options-menu'));
|
||||
const setComposeSendHandlers = (size: number) => {
|
||||
(emailHooks.onComposeSend as unknown as { size: number }).size = size;
|
||||
};
|
||||
|
||||
describe('composer encryption options', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
client: { supportsSubmissionExtension: (name: string) => name === 'REQUIRETLS', hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
usePolicyStore.setState({ demoFeatureOverrides: { pluginsEnabled: true } } as never);
|
||||
setComposeSendHandlers(0);
|
||||
});
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
useSettingsStore.setState({ showAdvancedSettings: false } as never);
|
||||
usePolicyStore.setState({ demoFeatureOverrides: {} } as never);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('names the TLS option for what it does and keeps it to advanced settings', () => {
|
||||
const { unmount } = render(<EmailComposer initialData={DRAFT} />);
|
||||
openOptions();
|
||||
expect(screen.queryByTestId('composer-require-tls-toggle')).toBeNull();
|
||||
unmount();
|
||||
|
||||
useSettingsStore.setState({ showAdvancedSettings: true } as never);
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
openOptions();
|
||||
const item = screen.getByTestId('composer-require-tls-toggle');
|
||||
expect(item).toHaveTextContent('require_tls');
|
||||
expect(item).toHaveTextContent('require_tls_hint');
|
||||
expect(item).not.toHaveTextContent('option_require_tls');
|
||||
});
|
||||
|
||||
it('keeps a ticked TLS option visible so it can be unticked', () => {
|
||||
render(<EmailComposer initialData={{ ...DRAFT, requireTls: true }} />);
|
||||
openOptions();
|
||||
expect(screen.getByTestId('composer-require-tls-toggle')).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('offers "Encrypt…" without a crypto plugin, opening the add-ons', () => {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
openOptions();
|
||||
const item = screen.getByTestId('composer-encrypt-setup');
|
||||
expect(item).toHaveTextContent('encrypt');
|
||||
expect(item).toHaveTextContent('encrypt_hint');
|
||||
fireEvent.click(item);
|
||||
expect(push).toHaveBeenCalledWith('/settings/addons');
|
||||
});
|
||||
|
||||
it('leaves "Encrypt…" out once a crypto plugin takes over sending', () => {
|
||||
setComposeSendHandlers(1);
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
openOptions();
|
||||
expect(screen.queryByTestId('composer-encrypt-setup')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves "Encrypt…" out where add-ons are switched off', () => {
|
||||
usePolicyStore.setState({ demoFeatureOverrides: { pluginsEnabled: false } } as never);
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
openOptions();
|
||||
expect(screen.queryByTestId('composer-encrypt-setup')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,10 @@ vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({
|
||||
SubAddressHelper: ({ open, hideTrigger }: { open?: boolean; hideTrigger?: boolean }) =>
|
||||
open ? <div role="dialog">sub-address popover</div> : hideTrigger ? null : <button type="button">sub-address button</button>,
|
||||
}));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
@@ -268,17 +271,46 @@ describe('composer From override envelope (#1009)', () => {
|
||||
expect(sent.envelopeMailFrom).toBeUndefined();
|
||||
});
|
||||
|
||||
it('offers no Custom button', () => {
|
||||
it('offers no Edit sender', () => {
|
||||
render(<EmailComposer />);
|
||||
expect(screen.queryByText('from_override.toggle_off')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'from_edit_sender' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Control: the button is there with the gate on, so the test above is not
|
||||
// Control: the entry is there with the gate on, so the test above is not
|
||||
// passing on a renamed label.
|
||||
it('offers the Custom button with the gate on', () => {
|
||||
it('offers Edit sender with the gate on', () => {
|
||||
setCustomFrom(true);
|
||||
render(<EmailComposer />);
|
||||
expect(screen.getByText('from_override.toggle_off')).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'from_edit_sender' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// compose-20: the rare sender options live at the end of the From list, not
|
||||
// as buttons on every message.
|
||||
describe('the From list', () => {
|
||||
const from = () => screen.getByTestId('composer-from') as HTMLSelectElement;
|
||||
|
||||
it('has no sub-address or Override button in the row', () => {
|
||||
render(<EmailComposer />);
|
||||
expect(screen.queryByText('sub-address button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('from_override.toggle_off')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'from_sub_address' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the sub-address popover from the list and keeps the identity', () => {
|
||||
render(<EmailComposer />);
|
||||
const before = from().value;
|
||||
fireEvent.change(from(), { target: { value: '__sub_address' } });
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('sub-address popover');
|
||||
expect(from().value).toBe(before);
|
||||
});
|
||||
|
||||
it('turns From into fields to edit from the list, with a way back', () => {
|
||||
render(<EmailComposer />);
|
||||
fireEvent.change(from(), { target: { value: '__edit_sender' } });
|
||||
expect(screen.getByLabelText('from_override.email_label')).toHaveValue('me@example.com');
|
||||
fireEvent.click(screen.getByText('from_override.toggle_on'));
|
||||
expect(screen.queryByLabelText('from_override.email_label')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
@@ -301,7 +302,7 @@ describe('composer with the @ recipient list', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('lets Escape close the open list, not the composer; the next Escape asks to close', async () => {
|
||||
it('lets Escape close the open list, not the composer; the next Escape closes that', async () => {
|
||||
const { editor, onClose } = await renderComposer();
|
||||
await typeInto(editor, ' @');
|
||||
await screen.findByRole('listbox', { name: 'mention_recipients' });
|
||||
@@ -311,8 +312,10 @@ describe('composer with the @ recipient list', () => {
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// The composer's close runs: with no connection here its save fails,
|
||||
// which is when it asks.
|
||||
press(editor, 'Escape');
|
||||
expect(await screen.findByRole('alertdialog')).toHaveTextContent('close_draft_title');
|
||||
expect(await screen.findByRole('alertdialog')).toHaveTextContent('save_failed_close');
|
||||
});
|
||||
|
||||
it('keeps Ctrl+Enter as the send shortcut while the list is open', async () => {
|
||||
@@ -357,7 +360,7 @@ describe('composer with the @ recipient list', () => {
|
||||
await waitFor(() => expect(mentionList()).toBeNull());
|
||||
|
||||
fireEvent.keyDown(subject, { key: 'Escape' });
|
||||
expect(await screen.findByRole('alertdialog')).toHaveTextContent('close_draft_title');
|
||||
expect(await screen.findByRole('alertdialog')).toHaveTextContent('save_failed_close');
|
||||
});
|
||||
|
||||
it('closes the list on a click elsewhere, but not on a click on the list itself', async () => {
|
||||
@@ -538,12 +541,30 @@ describe('composer with the @ recipient list', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pulls the last recipient back whole on Backspace, without the key deleting its last character', async () => {
|
||||
// The first Backspace selects the last recipient, it doesn't turn it into text.
|
||||
it('selects the last recipient on Backspace, without the key deleting anything', async () => {
|
||||
await renderComposer({ to: 'Sarah Kim <sarah@example.com>' });
|
||||
const to = within(screen.getByTestId('composer-to')).getByRole('combobox') as HTMLInputElement;
|
||||
// fireEvent returns false when the handler stopped the key's default action.
|
||||
expect(fireEvent.keyDown(to, { key: 'Backspace' })).toBe(false);
|
||||
expect(to.value).toBe('Sarah Kim <sarah@example.com>');
|
||||
expect(to.value).toBe('');
|
||||
expect(document.activeElement).toHaveAttribute('data-recipient-chip');
|
||||
});
|
||||
|
||||
// compose-17: the button was there only with a server directory, so
|
||||
// contacts alone were never offered.
|
||||
it('offers the recipient picker for the contacts when the server has no directory', async () => {
|
||||
const contactState = (useContactStore as unknown as { getState: () => { contacts: unknown[] } }).getState();
|
||||
contactState.contacts = [{ id: 'c1', name: { full: 'Zoe Fischer' }, emails: { e: { address: 'zoe@example.com' } } }];
|
||||
try {
|
||||
await renderComposer();
|
||||
const button = screen.getByTestId('composer-directory-picker');
|
||||
expect(button).toHaveAccessibleName('picker_button');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole('dialog', { name: 'directory' })).toBeInTheDocument();
|
||||
} finally {
|
||||
contactState.contacts = [];
|
||||
}
|
||||
});
|
||||
|
||||
// The directory picker is drawn inside the composer itself, not portaled.
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
|
||||
// Writing offline. The footer says "Offline" next to Send. With "Keep mail on
|
||||
// this device" on, Send puts the message in the outbox as before. Without it
|
||||
// nothing can keep the message safely for later: Send says so, keeps the
|
||||
// text, and points at the setting. It used to fail with "Failed to send".
|
||||
|
||||
const offline = vi.hoisted(() => ({ active: false, allowed: true }));
|
||||
vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
offlineActive: () => offline.active,
|
||||
offlineAllowed: () => offline.allowed,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) =>
|
||||
React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
const DATA = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Lunch',
|
||||
body: '<p>Hello there</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
let online = true;
|
||||
beforeEach(() => {
|
||||
online = true;
|
||||
offline.active = false;
|
||||
offline.allowed = true;
|
||||
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online });
|
||||
});
|
||||
afterEach(() => {
|
||||
online = true;
|
||||
});
|
||||
|
||||
function goOffline() {
|
||||
online = false;
|
||||
act(() => { window.dispatchEvent(new Event('offline')); });
|
||||
}
|
||||
function goOnline() {
|
||||
online = true;
|
||||
act(() => { window.dispatchEvent(new Event('online')); });
|
||||
}
|
||||
|
||||
const footer = () => document.querySelector<HTMLElement>('[data-composer-footer]')!;
|
||||
|
||||
describe('the composer while offline', () => {
|
||||
it('says "Offline" next to Send, and stops once the network is back', () => {
|
||||
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
|
||||
expect(screen.queryByTestId('composer-offline')).toBeNull();
|
||||
goOffline();
|
||||
const label = screen.getByTestId('composer-offline');
|
||||
expect(footer()).toContainElement(label);
|
||||
expect(label.textContent).toContain('offline');
|
||||
expect(label.textContent).toContain('offline_hint');
|
||||
goOnline();
|
||||
expect(screen.queryByTestId('composer-offline')).toBeNull();
|
||||
});
|
||||
|
||||
it('without offline mode, Send keeps the message and says why, with the way to the setting', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DATA} ownWindow={{}} onSend={onSend} />);
|
||||
goOffline();
|
||||
fireEvent.click(screen.getByTestId('composer-send'));
|
||||
const notice = await screen.findByTestId('composer-offline-notice');
|
||||
expect(notice.textContent).toContain('offline_send_unsaved');
|
||||
const link = screen.getByRole('link', { name: 'settings.keep_label' });
|
||||
expect(link.getAttribute('href')).toMatch(/\/settings\/backup\/offline$/);
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('email-composer')).toBeInTheDocument();
|
||||
|
||||
goOnline();
|
||||
expect(screen.queryByTestId('composer-offline-notice')).toBeNull();
|
||||
});
|
||||
|
||||
it('says the message is in Drafts only when the server has it', async () => {
|
||||
render(<EmailComposer initialData={{ ...DATA, draftId: 'd1' }} ownWindow={{}} onSend={vi.fn()} />);
|
||||
goOffline();
|
||||
fireEvent.click(screen.getByTestId('composer-send'));
|
||||
expect((await screen.findByTestId('composer-offline-notice')).textContent).toContain('offline_send_saved');
|
||||
});
|
||||
|
||||
it('Ctrl+Enter takes the same way', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DATA} ownWindow={{}} onSend={onSend} />);
|
||||
goOffline();
|
||||
fireEvent.keyDown(screen.getByTestId('composer-send'), { key: 'Enter', ctrlKey: true });
|
||||
await screen.findByTestId('composer-offline-notice');
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves out the link where this device may not keep mail', async () => {
|
||||
offline.allowed = false;
|
||||
render(<EmailComposer initialData={DATA} ownWindow={{}} onSend={vi.fn()} />);
|
||||
goOffline();
|
||||
fireEvent.click(screen.getByTestId('composer-send'));
|
||||
await screen.findByTestId('composer-offline-notice');
|
||||
expect(screen.queryByRole('link', { name: 'settings.keep_label' })).toBeNull();
|
||||
});
|
||||
|
||||
it('with offline mode on, Send goes ahead: the outbox keeps it until the network is back', async () => {
|
||||
offline.active = true;
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DATA} ownWindow={{}} onSend={onSend} />);
|
||||
goOffline();
|
||||
expect(screen.getByTestId('composer-offline').textContent).toContain('offline_hint_outbox');
|
||||
fireEvent.click(screen.getByTestId('composer-send'));
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('composer-offline-notice')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { EmailComposer, type ComposerDraftData, type ComposerPopOut } from '../email-composer';
|
||||
@@ -325,6 +325,20 @@ describe('a composer that is all its window shows', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Escape in a pop-out or quick compose window: the same as anywhere,
|
||||
// saved and kept, and the main window says so (this one is gone).
|
||||
it('saves on Escape, closes, and hands "Draft saved" to the main window', async () => {
|
||||
const onDraftKept = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
mount({ ownWindow: { onDraftKept }, onClose });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Kept on Escape' } });
|
||||
fireEvent.keyDown(subjectField(), { key: 'Escape' });
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(creations.map((draft) => draft.subject)).toEqual(['Kept on Escape']);
|
||||
expect(onDraftKept).toHaveBeenCalledWith('saved-1', expect.objectContaining({ localAccountId: expect.anything() }));
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('lets an untouched composer go without a word', () => {
|
||||
mount({ ownWindow: {} });
|
||||
expect(unload().defaultPrevented).toBe(false);
|
||||
@@ -364,3 +378,22 @@ describe('a composer that is all its window shows', () => {
|
||||
expect(creations).toHaveLength(saved);
|
||||
});
|
||||
});
|
||||
|
||||
describe('back online', () => {
|
||||
it('saves what could not be saved while offline, so "saved in Drafts once you are back online" holds', async () => {
|
||||
let onLine = true;
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine);
|
||||
rejectSave = true;
|
||||
mount({});
|
||||
onLine = false;
|
||||
act(() => { window.dispatchEvent(new Event('offline')); });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Written on a train' } });
|
||||
await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 });
|
||||
|
||||
rejectSave = false;
|
||||
onLine = true;
|
||||
act(() => { window.dispatchEvent(new Event('online')); });
|
||||
await waitFor(() => expect(creations).toHaveLength(2));
|
||||
expect(creations[1].subject).toBe('Written on a train');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,6 +331,21 @@ describe('composer-send-menu', () => {
|
||||
expect(schedule).toHaveTextContent('Ctrl + Shift + Enter');
|
||||
});
|
||||
|
||||
// compose-23: a menu with "Schedule send" as its only item.
|
||||
it('opens the schedule dialog straight from the arrow when it is the only entry', () => {
|
||||
useAuthStore.setState({
|
||||
client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
|
||||
});
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
const arrow = screen.getAllByRole('button', { name: 'schedule_send' })[0];
|
||||
expect(arrow).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
fireEvent.click(arrow);
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull();
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('schedule_send_description');
|
||||
});
|
||||
|
||||
it('keeps a plain Send button without plugin entries or scheduled send', () => {
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
|
||||
|
||||
@@ -232,9 +232,8 @@ function mockClient() {
|
||||
}
|
||||
|
||||
async function saveViaCloseDialog(requestCloseRef: React.MutableRefObject<((afterClose?: () => void) => void) | null>) {
|
||||
// Closing saves the draft; no question unless the save fails.
|
||||
act(() => requestCloseRef.current!(vi.fn()));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
fireEvent.click(within(dialog).getByText('save'));
|
||||
}
|
||||
|
||||
describe('composer message priority (#970)', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
@@ -226,9 +226,8 @@ describe('re-opened reply draft', () => {
|
||||
render(<EmailComposer initialData={REOPENED_REPLY} requestCloseRef={requestCloseRef} onClose={vi.fn()} />);
|
||||
fireEvent.change(screen.getByDisplayValue('Re: Plans'), { target: { value: 'Re: Plans, edited' } });
|
||||
|
||||
// Closing saves the draft; no question unless the save fails.
|
||||
act(() => requestCloseRef.current!(vi.fn()));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
fireEvent.click(within(dialog).getByText('save'));
|
||||
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalled());
|
||||
const args = createDraft.mock.calls[0];
|
||||
|
||||
@@ -379,3 +379,39 @@ describe('reply quote attribution markers', () => {
|
||||
expect(html).toContain('<p data-reply-quote-start="true"><b>From:</b> Bob</p>');
|
||||
});
|
||||
});
|
||||
|
||||
// The forwarded header had From, Date and Subject only; Gmail and Outlook
|
||||
// also say who the mail went to.
|
||||
describe('forwarded header recipients', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState({ plainTextMode: false, signaturePosition: 'below_quote' });
|
||||
setIdentities([ME]);
|
||||
});
|
||||
const FORWARDED = {
|
||||
...REPLY_TO,
|
||||
to: [{ name: 'Anna Becker', email: 'anna@example.com' }, { email: 'team@example.com' }],
|
||||
cc: [{ name: 'Carol', email: 'carol@example.com' }],
|
||||
};
|
||||
|
||||
it('lists To and Cc in a plain text forward', async () => {
|
||||
useSettingsStore.setState({ plainTextMode: true });
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer mode="forward" replyTo={FORWARDED} prefill={{ to: ['dave@example.com'] }} onSend={onSend} />);
|
||||
const sent = await send(onSend);
|
||||
expect(sent.body).toContain('subject_label: Question\nforward_to_label: Anna Becker <anna@example.com>, team@example.com\nforward_cc_label: Carol <carol@example.com>\n\nOriginal text');
|
||||
});
|
||||
|
||||
// escapeHtml is mocked to pass values through in this file.
|
||||
it('lists To and Cc in an HTML forward', () => {
|
||||
render(<EmailComposer mode="forward" replyTo={FORWARDED} />);
|
||||
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
|
||||
expect(html).toContain('subject_label: Question<br>forward_to_label: Anna Becker <anna@example.com>, team@example.com<br>forward_cc_label: Carol <carol@example.com><br><br>');
|
||||
});
|
||||
|
||||
it('leaves out a Cc line when nobody was copied', () => {
|
||||
render(<EmailComposer mode="forward" replyTo={{ ...FORWARDED, cc: [] }} />);
|
||||
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
|
||||
expect(html).toContain('forward_to_label: ');
|
||||
expect(html).not.toContain('forward_cc_label');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,9 +164,9 @@ async function openComposer() {
|
||||
add: async (...files: File[]) => {
|
||||
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files } }); await flush();
|
||||
},
|
||||
// Closing saves the draft without asking (it asks only when that fails).
|
||||
chooseSave: async () => {
|
||||
fireEvent.click(screen.getByTestId('composer-close'));
|
||||
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: /^Save$/ }));
|
||||
await flush();
|
||||
},
|
||||
reopen: async () => { act(() => reopen()); await flush(); },
|
||||
@@ -229,7 +229,8 @@ describe('Save and close with attachment uploads', () => {
|
||||
await c.chooseSave(); await expectSaved(['A.txt']); await waitFor(() => expect(c.closed).toHaveBeenCalledOnce());
|
||||
});
|
||||
it('can discard during the wait without a later save or close follow-up', async () => {
|
||||
const c = await openComposer(); await c.add(file()); await c.chooseSave(); c.closeAgain();
|
||||
const c = await openComposer(); await c.add(file()); await c.chooseSave();
|
||||
fireEvent.click(screen.getAllByTestId('composer-discard')[0]);
|
||||
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: /^Discard$/ }));
|
||||
await complete(0); await new Promise(resolve => setTimeout(resolve, 200));
|
||||
expect(creations).toEqual([]); expect(c.closed).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
|
||||
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
|
||||
|
||||
const editorKeyDown = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', {
|
||||
'data-testid': 'rich-text-editor',
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (!event.defaultPrevented) editorKeyDown();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
// compose-24: the host closes the composer as soon as the send starts. A send
|
||||
// that fails must still hand the message back, or it would vanish.
|
||||
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: (...args: unknown[]) => updateSetting(...args),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const READY = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Quarterly numbers',
|
||||
body: '<p>Here they are.</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: 'draft-3',
|
||||
};
|
||||
|
||||
const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement;
|
||||
|
||||
describe('a send that fails after the host closed the composer', () => {
|
||||
it('hands the message back to the host', async () => {
|
||||
const onSend = vi.fn().mockRejectedValue(new Error('refused'));
|
||||
const onSendFailed = vi.fn();
|
||||
render(<EmailComposer initialData={READY} onSend={onSend} onSendFailed={onSendFailed} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
await waitFor(() => expect(onSendFailed).toHaveBeenCalledTimes(1));
|
||||
expect(onSendFailed.mock.calls[0][0]).toMatchObject({
|
||||
to: 'bob@example.com',
|
||||
subject: 'Quarterly numbers',
|
||||
body: '<p>Here they are.</p>',
|
||||
draftId: 'draft-3',
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing to the host when the message went out', async () => {
|
||||
const onSend = vi.fn().mockResolvedValue(undefined);
|
||||
const onSendFailed = vi.fn();
|
||||
render(<EmailComposer initialData={READY} onSend={onSend} onSendFailed={onSendFailed} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(onSendFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -252,7 +252,7 @@ describe('composer send failures', () => {
|
||||
// window.confirm, before the message goes out at once.
|
||||
it('asks in a dialog before sending without the delay the account lacks', async () => {
|
||||
const nativeConfirm = vi.spyOn(window, 'confirm');
|
||||
useSettingsStore.setState({ sendDelaySeconds: 10 } as never);
|
||||
useSettingsStore.setState({ sendDelaySeconds: 10, explicitSettings: ['sendDelaySeconds'] } as never);
|
||||
try {
|
||||
const onSend = vi.fn().mockResolvedValue(undefined);
|
||||
render(<EmailComposer initialData={DRAFT} onSend={onSend} />);
|
||||
@@ -262,6 +262,21 @@ describe('composer send failures', () => {
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'send' }));
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
} finally {
|
||||
useSettingsStore.setState({ sendDelaySeconds: 0, explicitSettings: [] } as never);
|
||||
}
|
||||
});
|
||||
|
||||
// Undo send is on by default: an account that can't delay just sends.
|
||||
it('sends at once, without asking, when the delay is only the default', async () => {
|
||||
useSettingsStore.setState({ sendDelaySeconds: 5, explicitSettings: [] } as never);
|
||||
try {
|
||||
const onSend = vi.fn().mockResolvedValue(undefined);
|
||||
render(<EmailComposer initialData={DRAFT} onSend={onSend} />);
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(onSend.mock.calls[0][0].delayedUntil).toBeUndefined();
|
||||
expect(screen.queryByRole('alertdialog', { name: 'send_delay_unsupported_title' })).toBeNull();
|
||||
} finally {
|
||||
useSettingsStore.setState({ sendDelaySeconds: 0 } as never);
|
||||
}
|
||||
|
||||
@@ -246,15 +246,18 @@ describe('composer flush before an automatic sign-out', () => {
|
||||
expect(createDraft.mock.calls[0][1]).toBe('Half-written');
|
||||
});
|
||||
|
||||
it('asks before closing a composer opened with stashed edits', async () => {
|
||||
// Closing keeps the draft without asking: the stashed edits are saved first.
|
||||
it('saves a composer opened with stashed edits before it closes', async () => {
|
||||
const onClose = vi.fn();
|
||||
const requestCloseRef: { current: ((afterClose?: () => void) => void) | null } = { current: null };
|
||||
render(<EmailComposer initialData={{ ...DRAFT, unsaved: true }} onClose={onClose} requestCloseRef={requestCloseRef} />);
|
||||
|
||||
act(() => requestCloseRef.current?.());
|
||||
|
||||
expect(await screen.findByRole('alertdialog')).toBeTruthy();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(createDraft).toHaveBeenCalledTimes(1);
|
||||
expect(createDraft.mock.calls[0][1]).toBe('Half-written');
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes a composer opened with text the server already has without asking', () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
|
||||
// The real composer around the real editor: applying a template to a reply
|
||||
// the user has already started (#911, follow-up to #540).
|
||||
@@ -30,9 +31,9 @@ vi.mock('@/components/templates/template-picker', () => ({
|
||||
React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
|
||||
}));
|
||||
// Save as template: what the composer hands the form.
|
||||
const savedForm = vi.hoisted(() => ({ initialData: null as null | { body?: string; isHTML?: boolean } }));
|
||||
const savedForm = vi.hoisted(() => ({ initialData: null as null | { name?: string; body?: string; isHTML?: boolean } }));
|
||||
vi.mock('@/components/templates/template-form', () => ({
|
||||
TemplateForm: (props: { initialData?: { body?: string; isHTML?: boolean } }) => {
|
||||
TemplateForm: (props: { initialData?: { name?: string; body?: string; isHTML?: boolean } }) => {
|
||||
savedForm.initialData = props.initialData ?? null;
|
||||
return null;
|
||||
},
|
||||
@@ -158,6 +159,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
onTemplateCreate: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
@@ -308,13 +310,46 @@ describe('applying a template to a reply (#911)', () => {
|
||||
savedForm.initialData = null;
|
||||
});
|
||||
|
||||
async function openSaveAsTemplate() {
|
||||
async function openNameDialog() {
|
||||
fireEvent.click(await screen.findByTestId('composer-templates-menu'));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'save_as_template' }));
|
||||
return screen.findByTestId('quick-save-template');
|
||||
}
|
||||
|
||||
// The full editor is behind "Edit details…" (compose-18).
|
||||
async function openSaveAsTemplate() {
|
||||
fireEvent.click(within(await openNameDialog()).getByText('template_edit_details'));
|
||||
await waitFor(() => expect(savedForm.initialData).not.toBeNull());
|
||||
return savedForm.initialData!;
|
||||
}
|
||||
|
||||
// compose-18: Save as template opened an editor with nine fields.
|
||||
it('asks for a name only, starting from the subject, and saves the message', async () => {
|
||||
useTemplateStore.setState({ templates: [] });
|
||||
await openReply();
|
||||
const subject = (screen.getByTestId('composer-subject') as HTMLInputElement).value;
|
||||
const dialog = await openNameDialog();
|
||||
const name = within(dialog).getByLabelText('template_name') as HTMLInputElement;
|
||||
expect(name.value).toBe(subject);
|
||||
expect(savedForm.initialData).toBeNull();
|
||||
|
||||
fireEvent.change(name, { target: { value: 'Quick answer' } });
|
||||
fireEvent.click(within(dialog).getByText('save'));
|
||||
|
||||
await waitFor(() => expect(useTemplateStore.getState().templates).toHaveLength(1));
|
||||
expect(useTemplateStore.getState().templates[0]).toMatchObject({ name: 'Quick answer', subject, isHTML: true });
|
||||
expect(screen.queryByTestId('quick-save-template')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('carries the name over into the full editor', async () => {
|
||||
await openReply();
|
||||
const dialog = await openNameDialog();
|
||||
fireEvent.change(within(dialog).getByLabelText('template_name'), { target: { value: 'Quick answer' } });
|
||||
fireEvent.click(within(dialog).getByText('template_edit_details'));
|
||||
|
||||
await waitFor(() => expect(savedForm.initialData).toMatchObject({ name: 'Quick answer' }));
|
||||
});
|
||||
|
||||
it('marks a rich text draft as HTML', async () => {
|
||||
await openReply();
|
||||
const data = await openSaveAsTemplate();
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// ─── Heavy component mocks (as composer-reply-signature.test.tsx) ─
|
||||
|
||||
// A stand-in for the TipTap editor: it hands the composer an editor object
|
||||
// whose content is whatever the composer last rendered into it, the way the
|
||||
// real editor reports itself ready from an effect after mounting.
|
||||
const editorState = vi.hoisted(() => ({ html: '' }));
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: ({ content, onChange, onEditorReady }: {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
onEditorReady?: (editor: unknown) => void;
|
||||
}) => {
|
||||
editorState.html = content;
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
React.useEffect(() => {
|
||||
onEditorReady?.({
|
||||
isDestroyed: false,
|
||||
commands: {
|
||||
focus: () => {},
|
||||
setContent: (html: string) => {
|
||||
editorState.html = html;
|
||||
onChangeRef.current(html);
|
||||
},
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-html': content });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [
|
||||
{ id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: '<b>Alice</b>', textSignature: 'Alice' },
|
||||
{ id: 'id-team', email: 'team@example.com', name: 'Team', htmlSignature: '', textSignature: '' },
|
||||
] as Array<Record<string, unknown>>,
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook, notifyIdentitySelected: () => {} };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
// The default position: replies/forwards get the signature appended at
|
||||
// send time rather than embedded above the quote.
|
||||
signaturePosition: 'below_quote',
|
||||
signatureNewMailEnabled: true,
|
||||
signatureReplyForwardEnabled: true,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
externalContentPolicy: 'block',
|
||||
isSenderTrusted: () => false,
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeSignatureHtmlForDisplay: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
sanitizePluginBodyHtml: (v: string) => v,
|
||||
escapeHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: (html: string) => `<div data-quoted-html="">${html}</div>`,
|
||||
serializeEditorContent: () => editorState.html,
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A Reply or Forward opened and closed again, with nothing typed or changed,
|
||||
* leaves no draft: the quote and the signature it starts with are no edit,
|
||||
* nor is a signature that follows the identity the reply resolves to later.
|
||||
*/
|
||||
|
||||
const ME = { id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: '<b>Alice</b>', textSignature: 'Alice' };
|
||||
const SALES = { id: 'id-sales', email: 'sales@example.com', name: 'Sales', htmlSignature: '<i>Sales team</i>', textSignature: 'Sales team' };
|
||||
const REPLY_TO = {
|
||||
from: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
to: [{ email: 'sales@example.com' }],
|
||||
subject: 'Question',
|
||||
body: 'Original text',
|
||||
htmlBody: '<p>Original text</p>',
|
||||
receivedAt: '2026-08-01T10:00:00Z',
|
||||
};
|
||||
|
||||
function savingClient() {
|
||||
const createDraft = vi.fn().mockResolvedValue('draft-9');
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
return createDraft;
|
||||
}
|
||||
|
||||
function renderReply(mode: 'reply' | 'replyAll' | 'forward') {
|
||||
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
|
||||
const onClose = vi.fn();
|
||||
render(<EmailComposer mode={mode} replyTo={REPLY_TO} requestCloseRef={requestCloseRef} onClose={onClose} />);
|
||||
return { requestCloseRef, onClose };
|
||||
}
|
||||
|
||||
const pastAutosave = () => act(async () => { await new Promise((r) => setTimeout(r, 2600)); });
|
||||
|
||||
describe('an untouched reply or forward', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
useSettingsStore.setState({ signaturePosition: 'below_quote' });
|
||||
useIdentityStore.setState({ identities: [ME] as Identity[] });
|
||||
});
|
||||
|
||||
for (const mode of ['reply', 'replyAll', 'forward'] as const) {
|
||||
it(`saves nothing, also not by autosave, and closes without asking (${mode})`, async () => {
|
||||
const createDraft = savingClient();
|
||||
const { requestCloseRef, onClose } = renderReply(mode);
|
||||
|
||||
await pastAutosave();
|
||||
expect(createDraft).not.toHaveBeenCalled();
|
||||
|
||||
act(() => requestCloseRef.current!());
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(createDraft).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
// The host builds the quote header after the composer opened (a plugin
|
||||
// may rewrite it), so the body changes once by itself.
|
||||
it('stays untouched when the quote header arrives after it opened', async () => {
|
||||
const createDraft = savingClient();
|
||||
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
|
||||
const onClose = vi.fn();
|
||||
const props = { mode: 'reply' as const, requestCloseRef, onClose };
|
||||
const view = render(<EmailComposer {...props} replyTo={REPLY_TO} />);
|
||||
view.rerender(<EmailComposer {...props} replyTo={{ ...REPLY_TO, quoteHeaderHtml: '<p>Bob wrote:</p>', quoteHeaderText: 'Bob wrote:' }} />);
|
||||
|
||||
await pastAutosave();
|
||||
expect(createDraft).not.toHaveBeenCalled();
|
||||
act(() => requestCloseRef.current!());
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(createDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays untouched when its signature follows the identity it resolves to', async () => {
|
||||
useSettingsStore.setState({ signaturePosition: 'above_quote' });
|
||||
useIdentityStore.setState({ identities: [ME, SALES] as Identity[] });
|
||||
const createDraft = savingClient();
|
||||
const { requestCloseRef, onClose } = renderReply('reply');
|
||||
|
||||
await pastAutosave();
|
||||
act(() => requestCloseRef.current!());
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(createDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves once the user changes something', async () => {
|
||||
const createDraft = savingClient();
|
||||
const { requestCloseRef, onClose } = renderReply('reply');
|
||||
fireEvent.change(screen.getByDisplayValue(/Question/), { target: { value: 'Question, answered' } });
|
||||
|
||||
act(() => requestCloseRef.current!());
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(createDraft).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
|
||||
// One layout in every composer: Send at the bottom end, Discard as a bin left
|
||||
// of it, the formatting toolbar showing. A pop-out or quick compose window is
|
||||
// narrower than a tablet, and used to get the phone's layout: Send in the
|
||||
// header, Discard where Send was, the toolbar behind a button.
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) =>
|
||||
React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
const DATA = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Lunch',
|
||||
body: '<p>Hello there</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const footer = () => document.querySelector<HTMLElement>('[data-composer-footer]')!;
|
||||
const header = () => document.querySelector<HTMLElement>('[data-composer-header]')!;
|
||||
|
||||
describe('a composer in a window of its own', () => {
|
||||
it('has one Send, at the bottom end, with Discard as a bin right before it', () => {
|
||||
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
|
||||
const sends = screen.getAllByTestId('composer-send');
|
||||
expect(sends).toHaveLength(1);
|
||||
expect(footer()).toContainElement(sends[0]);
|
||||
expect(sends[0].className).not.toMatch(/(^|\s)hidden(\s|$)/);
|
||||
expect(within(header()).queryByTestId('composer-send')).toBeNull();
|
||||
|
||||
const discard = screen.getByTestId('composer-discard');
|
||||
expect(discard.querySelector('svg')).not.toBeNull();
|
||||
// Right before Send, in the same group.
|
||||
expect(discard.nextElementSibling).toBe(sends[0]);
|
||||
});
|
||||
|
||||
it('shows the formatting toolbar, with no button to fold it away', () => {
|
||||
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
|
||||
expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'false');
|
||||
expect(screen.queryByRole('button', { name: 'formatting' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the close button where the main window has it', () => {
|
||||
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
|
||||
expect(screen.getByTestId('composer-close').className).not.toContain('order-first');
|
||||
});
|
||||
});
|
||||
|
||||
describe('full screen', () => {
|
||||
it('puts the composer over the whole window, and the same button puts it back', () => {
|
||||
render(<EmailComposer initialData={DATA} />);
|
||||
const root = screen.getByTestId('email-composer');
|
||||
expect(root).not.toHaveAttribute('data-maximized');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'maximize' }));
|
||||
expect(root).toHaveAttribute('data-maximized');
|
||||
expect(root.className).toMatch(/(^|\s)fixed(\s|$)/);
|
||||
expect(root.className).toContain('inset-0');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'restore' }));
|
||||
expect(root).not.toHaveAttribute('data-maximized');
|
||||
expect(root.className).not.toMatch(/(^|\s)fixed(\s|$)/);
|
||||
});
|
||||
|
||||
it('goes back with Escape, without closing the composer', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<EmailComposer initialData={DATA} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'maximize' }));
|
||||
const root = screen.getByTestId('email-composer');
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'restore' }), { key: 'Escape' });
|
||||
|
||||
expect(root).not.toHaveAttribute('data-maximized');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('is not offered in a window of its own, which is all composer already', () => {
|
||||
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
|
||||
expect(screen.queryByRole('button', { name: 'maximize' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a composer in the main window', () => {
|
||||
it('keeps the phone layout for a phone: Send in the header there, toolbar folded', () => {
|
||||
render(<EmailComposer initialData={DATA} />);
|
||||
expect(within(header()).getByTestId('composer-send')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'formatting' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Discard as a bin too, with its name for screen readers', () => {
|
||||
render(<EmailComposer initialData={DATA} />);
|
||||
const discard = screen.getByTestId('composer-discard');
|
||||
expect(discard).toHaveAccessibleName('discard');
|
||||
expect(discard.querySelector('svg')).not.toBeNull();
|
||||
expect(within(discard).getByText('discard').className).toContain('sr-only');
|
||||
});
|
||||
});
|
||||
@@ -263,27 +263,76 @@ describe('composer empty subject warning', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not ask again after Send anyway and a trip back to add an attachment', async () => {
|
||||
// compose-27: the subject and the attachment used to be two dialogs in a row.
|
||||
it('asks about a missing subject and a missing attachment in one dialog', async () => {
|
||||
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['hello'] } as never);
|
||||
try {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITHOUT_SUBJECT} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(await screen.findByText('empty_subject.send_anyway'));
|
||||
// The body says "Hello", so the attachment reminder stops the send.
|
||||
fireEvent.click(await screen.findByText('forgot_attachment.back'));
|
||||
await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('warnings_title');
|
||||
expect(dialog).toHaveTextContent('warning_subject');
|
||||
expect(dialog).toHaveTextContent('warning_attachment');
|
||||
// Several warnings: no "don't ask again", which would be unclear.
|
||||
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument();
|
||||
expect(screen.queryByText('empty_subject.title')).not.toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByText('send_anyway'));
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument();
|
||||
} finally {
|
||||
useSettingsStore.setState({ attachmentReminderEnabled: false, attachmentReminderKeywords: [] } as never);
|
||||
}
|
||||
});
|
||||
|
||||
it('goes back to editing from the combined dialog without sending', async () => {
|
||||
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['hello'] } as never);
|
||||
try {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITHOUT_SUBJECT} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(await screen.findByText('back'));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
useSettingsStore.setState({ attachmentReminderEnabled: false, attachmentReminderKeywords: [] } as never);
|
||||
}
|
||||
});
|
||||
|
||||
// compose-28: Send works with an empty body and asks first.
|
||||
it('keeps Send enabled with an empty body and asks "Send without text?"', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={{ ...DRAFT_WITHOUT_SUBJECT, subject: 'Lunch?', body: '' }} onSend={onSend} />);
|
||||
expect(sendButton()).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('empty_text_title');
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(dialog).getByText('send_anyway'));
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(onSend.mock.calls[0][0]).toMatchObject({ subject: 'Lunch?' });
|
||||
});
|
||||
|
||||
it('lists no subject and no text together', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={{ ...DRAFT_WITHOUT_SUBJECT, body: '' }} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('warning_subject');
|
||||
expect(dialog).toHaveTextContent('warning_text');
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('goes back to editing without sending', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITHOUT_SUBJECT} onSend={onSend} />);
|
||||
@@ -329,9 +378,8 @@ describe('composer empty subject warning', () => {
|
||||
);
|
||||
fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: '' } });
|
||||
|
||||
// Closing saves the draft; no question unless the save fails.
|
||||
act(() => requestCloseRef.current!(vi.fn()));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
fireEvent.click(within(dialog).getByText('save'));
|
||||
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalled());
|
||||
expect(createDraft.mock.calls[0][1]).toBe('');
|
||||
|
||||
@@ -235,6 +235,7 @@ describe('composer placeholder warning', () => {
|
||||
placeholderWarningEnabled: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
sendConfirmation: false,
|
||||
} as never);
|
||||
});
|
||||
|
||||
@@ -293,33 +294,36 @@ describe('composer placeholder warning', () => {
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not ask again after Send anyway and a trip back to add an attachment', async () => {
|
||||
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
|
||||
|
||||
// Send anyway, then "Send this message?" answered with Cancel: the
|
||||
// placeholders were confirmed, the message is still here.
|
||||
const confirmThenCancel = async () => {
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(await screen.findByText('placeholder_warning.send_anyway'));
|
||||
// The body says "See you", so the attachment reminder stops the send.
|
||||
fireEvent.click(await screen.findByText('forgot_attachment.back'));
|
||||
await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
|
||||
const confirm = await screen.findByRole('alertdialog');
|
||||
expect(confirm).toHaveTextContent('send_confirm.title');
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
|
||||
};
|
||||
|
||||
it('does not ask again after Send anyway and a cancelled send', async () => {
|
||||
useSettingsStore.setState({ sendConfirmation: true } as never);
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
|
||||
await confirmThenCancel();
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument();
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('send_confirm.title');
|
||||
expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument();
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks again when a new placeholder shows up', async () => {
|
||||
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
|
||||
useSettingsStore.setState({ sendConfirmation: true } as never);
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(await screen.findByText('placeholder_warning.send_anyway'));
|
||||
fireEvent.click(await screen.findByText('forgot_attachment.back'));
|
||||
await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
|
||||
await confirmThenCancel();
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: 'Lunch with {{name}}' } });
|
||||
fireEvent.click(sendButton());
|
||||
@@ -330,6 +334,26 @@ describe('composer placeholder warning', () => {
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// compose-27: one question for everything the checks found.
|
||||
it('lists the placeholders and the missing attachment in one dialog', async () => {
|
||||
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('warnings_title');
|
||||
expect(dialog).toHaveTextContent('warning_placeholders');
|
||||
expect(dialog).toHaveTextContent('warning_attachment');
|
||||
expect(within(dialog).getByText('[TIME]')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByText('send_anyway'));
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores placeholders in quoted text and in the signature', async () => {
|
||||
const onSend = vi.fn();
|
||||
const body = '<p>Thanks!</p>'
|
||||
|
||||
@@ -243,7 +243,7 @@ describe('recipient autocomplete', () => {
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(screen.getByText('User B (userb@example.org)')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('User B <userb@example.org>')).toBeInTheDocument();
|
||||
expect(screen.queryByText('user')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -255,7 +255,7 @@ describe('recipient autocomplete', () => {
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Tab' });
|
||||
|
||||
expect(screen.getByText('User C (userc@example.org)')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('User C <userc@example.org>')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a complete typed address that no suggestion matches exactly', async () => {
|
||||
@@ -268,7 +268,7 @@ describe('recipient autocomplete', () => {
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(screen.getByText('userb@example.or')).toBeInTheDocument();
|
||||
expect(screen.queryByText('User B (userb@example.org)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('User B <userb@example.org>')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts the arrow keys from the highlighted suggestion', async () => {
|
||||
@@ -280,6 +280,6 @@ describe('recipient autocomplete', () => {
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(screen.getByText('User C (userc@example.org)')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('User C <userc@example.org>')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,8 +249,8 @@ describe('RecipientChipInput drag and drop', () => {
|
||||
it('keeps a display name with a comma in a single chip (array model)', async () => {
|
||||
render(<EmailComposer initialData={{ ...BASE_DATA, to: '"Doo, John" <john@doo.org>, ' }} />);
|
||||
|
||||
// One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma.
|
||||
const chip = await screen.findByText('Doo, John (john@doo.org)');
|
||||
// One chip, "Doo, John" with the address in its tooltip — not split on the comma.
|
||||
const chip = await screen.findByTitle('Doo, John <john@doo.org>');
|
||||
const chipSpan = chip.closest('[draggable]') as HTMLElement;
|
||||
expect(chipSpan).not.toBeNull();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
@@ -239,18 +239,31 @@ describe('RecipientChipInput paste', () => {
|
||||
const input = toInput();
|
||||
paste(input, '"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"');
|
||||
|
||||
// Chips render as "Name (email)" when a display name is present.
|
||||
expect(screen.getByText('Alice Smith (alice@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alex Smith (alex@x.com)')).toBeInTheDocument();
|
||||
// A named chip has its address in the tooltip.
|
||||
expect(screen.getByTitle('Alice Smith <alice@x.com>')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Alex Smith <alex@x.com>')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
// "Maximilian Alexander von Hoh…": the chip showed "Name (address)" and
|
||||
// cut both. It shows the name; the address is in the tooltip, and screen
|
||||
// readers still hear it.
|
||||
it('shows the name only on a named chip', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
paste(toInput(), 'Maximilian Alexander von Hohenberg <max.hohenberg@example.org>');
|
||||
|
||||
const label = screen.getByTitle('Maximilian Alexander von Hohenberg <max.hohenberg@example.org>');
|
||||
expect(within(label).getByText('Maximilian Alexander von Hohenberg')).toHaveClass('truncate');
|
||||
expect(within(label).queryByText(/max\.hohenberg@example\.org/, { ignore: '.sr-only' })).toBeNull();
|
||||
expect(label.closest('[draggable]')?.textContent).toBe('Maximilian Alexander von Hohenberg (max.hohenberg@example.org)');
|
||||
});
|
||||
|
||||
it('keeps a `Name <email>` pair as a single named chip', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, 'John Doe <j@x.com>, jane@y.com');
|
||||
|
||||
expect(screen.getByText('John Doe (j@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('John Doe <j@x.com>')).toBeInTheDocument();
|
||||
expect(screen.getByText('jane@y.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
@@ -260,7 +273,7 @@ describe('RecipientChipInput paste', () => {
|
||||
const input = toInput();
|
||||
paste(input, '"Doe, John" <j@x.com>; bob@z.com');
|
||||
|
||||
expect(screen.getByText('Doe, John (j@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Doe, John <j@x.com>')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob@z.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
@@ -295,7 +308,7 @@ describe('RecipientChipInput paste', () => {
|
||||
const input = toInput();
|
||||
paste(input, '"VIP" <vip@x.com>, not-an-email, b@y.com');
|
||||
|
||||
expect(screen.getByText('VIP (vip@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('VIP <vip@x.com>')).toBeInTheDocument();
|
||||
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('not-an-email');
|
||||
});
|
||||
|
||||
@@ -164,8 +164,11 @@ describe('RichTextEditor image descriptions (MA-02)', () => {
|
||||
const altDialog = () => screen.getByRole('dialog', { name: 'title' });
|
||||
const html = (editor: Editor) => editor.getHTML();
|
||||
|
||||
it('asks for a description when an image is dropped in, and never uses the file name', async () => {
|
||||
it('offers a description for a dropped image, and never uses the file name', async () => {
|
||||
const { editor } = await dropImage();
|
||||
// Nothing asks on the drop; the chip on the image does, when clicked.
|
||||
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
|
||||
fireEvent.click(await screen.findByTestId('image-alt-add'));
|
||||
const dialog = await waitFor(() => altDialog());
|
||||
expect(html(editor)).not.toContain('IMG_2041');
|
||||
expect(html(editor)).not.toMatch(/alt=/);
|
||||
@@ -178,6 +181,9 @@ describe('RichTextEditor image descriptions (MA-02)', () => {
|
||||
|
||||
it('marks an image decorative with an empty alt', async () => {
|
||||
const { editor } = await dropImage();
|
||||
// Nothing asks on the drop; the chip on the image does, when clicked.
|
||||
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
|
||||
fireEvent.click(await screen.findByTestId('image-alt-add'));
|
||||
const dialog = await waitFor(() => altDialog());
|
||||
fireEvent.click(within(dialog).getByLabelText('decorative'));
|
||||
expect(within(dialog).getByLabelText('label')).toBeDisabled();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
@@ -72,4 +72,33 @@ describe('RichTextEditor file drop', () => {
|
||||
expect(editor.getText()).toBe('Hello');
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A pasted or dropped image asks nothing: an optional chip on it offers a description.
|
||||
it('opens no dialog for a dropped image, and offers "Add description" on it', async () => {
|
||||
const { editor } = await setup(async () => ({ src: 'data:image/png;base64,AAAA' }));
|
||||
const file = new File(['png'], 'photo.png', { type: 'image/png' });
|
||||
|
||||
act(() => { editor.view.dom.dispatchEvent(fileDrop(file)); });
|
||||
|
||||
const chip = await screen.findByTestId('image-alt-add');
|
||||
expect(chip).toHaveTextContent('add_description');
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
|
||||
fireEvent.click(chip);
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no chip on an image marked decorative', async () => {
|
||||
let editor: Editor | null = null;
|
||||
render(
|
||||
<RichTextEditor
|
||||
content={'<p>Hi <img src="data:image/png;base64,AAAA" alt=""></p>'}
|
||||
onChange={() => {}}
|
||||
onImageUpload={async () => null}
|
||||
onEditorReady={(ed) => { editor = ed as unknown as Editor; }}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(editor).not.toBeNull());
|
||||
expect(screen.queryByTestId('image-alt-add')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,17 @@ describe('Insert link', () => {
|
||||
expect(editor.getHTML()).toContain('href="https://example.com/docs"');
|
||||
});
|
||||
|
||||
it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => {
|
||||
const editor = await setup('<p>docs here</p>');
|
||||
act(() => { editor.commands.setTextSelection({ from: 1, to: 5 }); });
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true });
|
||||
act(() => { editor.view.dom.dispatchEvent(event); });
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(screen.getByRole('dialog', { name: 'insert_link' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes a link from the dialog', async () => {
|
||||
const editor = await setup('<p><a href="https://example.com">docs</a> here</p>');
|
||||
act(() => { editor.commands.setTextSelection(2); });
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
|
||||
import { RichTextEditor, formattingFoldCount } from '../rich-text-editor';
|
||||
|
||||
// The formatting toolbar keeps one row: in a narrow composer (585 px at
|
||||
// 1280x720) it wrapped to two and took the body's room. What doesn't fit goes
|
||||
// behind "More formatting", the least used first.
|
||||
|
||||
let toolbarWidth = 1200;
|
||||
const observers = new Set<ResizeObserverCallback>();
|
||||
class ReportingResizeObserver {
|
||||
constructor(private cb: ResizeObserverCallback) {}
|
||||
observe() { observers.add(this.cb); }
|
||||
unobserve() {}
|
||||
disconnect() { observers.delete(this.cb); }
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ReportingResizeObserver);
|
||||
|
||||
const realClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')!;
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.getAttribute('role') === 'toolbar' ? toolbarWidth : realClientWidth.get!.call(this);
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
toolbarWidth = 1200;
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
let ready = false;
|
||||
render(<RichTextEditor content="<p>Hello</p>" onChange={() => {}} onEditorReady={() => { ready = true; }} />);
|
||||
await waitFor(() => expect(ready).toBe(true));
|
||||
}
|
||||
|
||||
async function resize(width: number) {
|
||||
toolbarWidth = width;
|
||||
await act(async () => {
|
||||
for (const cb of observers) cb([], {} as ResizeObserver);
|
||||
});
|
||||
}
|
||||
|
||||
const toolbar = () => screen.getByRole('toolbar', { name: 'formatting' });
|
||||
const rowItem = (name: string) => toolbar().querySelector(`[data-toolbar-item="${name}"]`);
|
||||
|
||||
describe('formatting toolbar in a narrow composer', () => {
|
||||
it('shows every button in one row when there is room, without a More button', async () => {
|
||||
await setup();
|
||||
for (const name of ['heading-1', 'quote', 'align-left', 'table', 'undo']) expect(rowItem(name)).not.toBeNull();
|
||||
expect(rowItem('more-formatting')).toBeNull();
|
||||
});
|
||||
|
||||
it('moves what does not fit behind "More formatting", and shows it there', async () => {
|
||||
await setup();
|
||||
await resize(561);
|
||||
|
||||
// The least used go first: undo, redo, clear formatting, then alignment.
|
||||
for (const name of ['undo', 'redo', 'clear-formatting', 'align-left', 'align-right']) expect(rowItem(name)).toBeNull();
|
||||
for (const name of ['bold', 'bullet-list', 'link', 'heading-1', 'table']) expect(rowItem(name)).not.toBeNull();
|
||||
|
||||
const more = rowItem('more-formatting') as HTMLButtonElement;
|
||||
expect(more).toHaveAccessibleName('more_formatting');
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(more);
|
||||
const panel = screen.getByRole('dialog', { name: 'more_formatting' });
|
||||
expect(more).toHaveAttribute('aria-expanded', 'true');
|
||||
for (const name of ['align-left', 'align-center', 'align-right', 'clear-formatting', 'undo', 'redo']) {
|
||||
expect(within(panel).getByRole('button', { name: new RegExp(`^${name.replace('-', '_')}`) })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the arrows in the row: the panel buttons are no Tab stops of the toolbar', async () => {
|
||||
await setup();
|
||||
await resize(561);
|
||||
fireEvent.click(rowItem('more-formatting')!);
|
||||
const panel = screen.getByRole('dialog', { name: 'more_formatting' });
|
||||
const rowStops = Array.from(toolbar().querySelectorAll<HTMLElement>('[data-toolbar-item]'))
|
||||
.filter((el) => !panel.contains(el) && el.tabIndex === 0);
|
||||
expect(rowStops).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('brings everything back into the row when the composer gets wider', async () => {
|
||||
await setup();
|
||||
await resize(400);
|
||||
expect(rowItem('heading-1')).toBeNull();
|
||||
await resize(1000);
|
||||
expect(rowItem('heading-1')).not.toBeNull();
|
||||
expect(rowItem('undo')).not.toBeNull();
|
||||
expect(rowItem('more-formatting')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formattingFoldCount', () => {
|
||||
const sizes = { button: 28, gap: 2, separator: 5 };
|
||||
|
||||
it('folds nothing for a toolbar that is not laid out', () => {
|
||||
expect(formattingFoldCount(0, sizes)).toBe(0);
|
||||
});
|
||||
|
||||
it('folds more groups the narrower the row', () => {
|
||||
const counts = [900, 700, 561, 480, 400, 300].map((width) => formattingFoldCount(width, sizes));
|
||||
expect(counts[0]).toBe(0);
|
||||
for (let i = 1; i < counts.length; i++) expect(counts[i]).toBeGreaterThanOrEqual(counts[i - 1]);
|
||||
expect(counts[counts.length - 1]).toBe(5);
|
||||
});
|
||||
|
||||
it('makes room for the text direction button', () => {
|
||||
const width = 700;
|
||||
expect(formattingFoldCount(width, { ...sizes, directionButton: true })).toBeGreaterThanOrEqual(formattingFoldCount(width, sizes));
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ beforeEach(() => clearAllHooks());
|
||||
async function typeAndSend(onQuickReply: (body: string) => Promise<boolean | void>) {
|
||||
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={onQuickReply} />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement;
|
||||
const box = screen.getByPlaceholderText('quick_reply_to') as HTMLTextAreaElement;
|
||||
fireEvent.focus(box);
|
||||
fireEvent.change(box, { target: { value: 'My answer' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }));
|
||||
@@ -77,7 +77,7 @@ describe('automatic sign-out while the external recipient question is open', ()
|
||||
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
|
||||
onQuickReply={onQuickReply} onSaveQuickReplyDraft={onSaveQuickReplyDraft} />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement;
|
||||
const box = screen.getByPlaceholderText('quick_reply_to') as HTMLTextAreaElement;
|
||||
fireEvent.focus(box);
|
||||
fireEvent.change(box, { target: { value: 'My answer' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }));
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { render, act, fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// The quick reply box counted characters, hid the full editor behind "More
|
||||
// options" and didn't say who the reply goes to. It now names the
|
||||
// recipients in its placeholder and offers "Open full editor".
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
||||
values ? `${key}:${JSON.stringify(values)}` : key,
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({
|
||||
dateTime: (d: Date | string) => String(d),
|
||||
relativeTime: (d: Date | string) => String(d),
|
||||
number: (n: number) => String(n),
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const message = {
|
||||
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Ben Carter', email: 'ben.carter@example.com' }], to: [{ name: 'Me', email: 'me@example.org' }],
|
||||
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
|
||||
bodyValues: { '1': { value: 'Plain body' } },
|
||||
} as unknown as Email;
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
useAuthStore.setState({ identities: [{ id: 'me', email: 'me@example.org', name: 'Me' }] } as never);
|
||||
});
|
||||
|
||||
async function renderViewer(email: Email, onReply = vi.fn()) {
|
||||
render(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} onReply={onReply} />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
}
|
||||
|
||||
describe('quick reply wording', () => {
|
||||
it('says who the reply goes to', async () => {
|
||||
await renderViewer(message);
|
||||
expect(screen.getByPlaceholderText('quick_reply_to:{"names":"Ben Carter"}')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('names the Reply-To address over the sender, by address when it has no name', async () => {
|
||||
await renderViewer({ ...message, replyTo: [{ email: 'team@example.com' }] } as unknown as Email);
|
||||
expect(screen.getByPlaceholderText('quick_reply_to:{"names":"team@example.com"}')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drops the character count and opens the full editor with the typed text', async () => {
|
||||
const onReply = vi.fn();
|
||||
await renderViewer(message, onReply);
|
||||
const box = screen.getByPlaceholderText(/^quick_reply_to/);
|
||||
fireEvent.focus(box);
|
||||
fireEvent.change(box, { target: { value: 'Thanks, will do' } });
|
||||
|
||||
expect(screen.queryByText(/characters_count/)).toBeNull();
|
||||
expect(screen.queryByText('more_options')).toBeNull();
|
||||
const full = screen.getByTestId('quick-reply-full-editor');
|
||||
expect(full).toHaveTextContent('open_full_editor');
|
||||
fireEvent.click(full);
|
||||
expect(onReply).toHaveBeenCalledWith('Thanks, will do');
|
||||
});
|
||||
});
|
||||
@@ -92,9 +92,9 @@ describe('viewer send failures', () => {
|
||||
useSettingsStore.setState({ quickReplyEnabled: false });
|
||||
render(<EmailViewer email={makeEmail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
|
||||
expect(screen.queryByPlaceholderText('quick_reply_placeholder')).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('quick_reply_to')).not.toBeInTheDocument();
|
||||
act(() => useSettingsStore.getState().updateSetting('quickReplyEnabled', true));
|
||||
expect(screen.getByPlaceholderText('quick_reply_placeholder')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('quick_reply_to')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports a failed quick reply and keeps the text on Stalwart 1.0', async () => {
|
||||
@@ -103,7 +103,7 @@ describe('viewer send failures', () => {
|
||||
<EmailViewer email={makeEmail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={onQuickReply} />,
|
||||
);
|
||||
|
||||
const box = screen.getByPlaceholderText('quick_reply_placeholder');
|
||||
const box = screen.getByPlaceholderText('quick_reply_to');
|
||||
fireEvent.change(box, { target: { value: 'Thanks!' } });
|
||||
fireEvent.keyDown(box, { key: 'Enter', ctrlKey: true });
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('viewer send failures', () => {
|
||||
await act(async () => { await flushComposers(1000); });
|
||||
expect(onSaveQuickReplyDraft).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('quick_reply_placeholder'), { target: { value: 'On it.' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('quick_reply_to'), { target: { value: 'On it.' } });
|
||||
await act(async () => { await flushComposers(1000); });
|
||||
expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('On it.');
|
||||
});
|
||||
@@ -133,7 +133,7 @@ describe('viewer send failures', () => {
|
||||
render(<EmailViewer email={makeEmail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} />);
|
||||
expect(useUIStore.getState().composingCount).toBe(0);
|
||||
|
||||
fireEvent.focus(screen.getByPlaceholderText('quick_reply_placeholder'));
|
||||
fireEvent.focus(screen.getByPlaceholderText('quick_reply_to'));
|
||||
expect(useUIStore.getState().composingCount).toBe(1);
|
||||
|
||||
fireEvent.click(screen.getByText('cancel'));
|
||||
|
||||
@@ -67,21 +67,28 @@ export function ComposerToolbarMenu({ icon: Icon, label, activeCount = 0, testId
|
||||
interface ItemProps {
|
||||
icon?: AppIcon;
|
||||
onSelect: () => void;
|
||||
/** A line under the label, as on the check items. */
|
||||
description?: string;
|
||||
/** Greyed out: the item can't do its job here yet, and says where to set it up. */
|
||||
muted?: boolean;
|
||||
testId?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ComposerToolbarMenuItem({ icon: Icon, onSelect, testId, children }: ItemProps) {
|
||||
export function ComposerToolbarMenuItem({ icon: Icon, onSelect, description, muted, testId, children }: ItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onSelect}
|
||||
data-testid={testId}
|
||||
className={CONTEXT_MENU_ITEM_CLASS}
|
||||
className={cn(CONTEXT_MENU_ITEM_CLASS, description && "items-start", muted && "text-muted-foreground")}
|
||||
>
|
||||
{Icon ? <Icon className="w-4 h-4 shrink-0" /> : null}
|
||||
<span className="flex-1">{children}</span>
|
||||
{Icon ? <Icon className={cn("w-4 h-4 shrink-0", description && "mt-0.5")} /> : null}
|
||||
<span className="flex-1">
|
||||
<span className="block">{children}</span>
|
||||
{description && <span className="block text-xs text-muted-foreground">{description}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
+520
-329
File diff suppressed because it is too large
Load Diff
@@ -49,8 +49,8 @@ import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
|
||||
import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/plugins/app-side-panel";
|
||||
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
|
||||
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
|
||||
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
|
||||
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils";
|
||||
import { quickReplyEnvelope, quickReplyRecipients } from "@/lib/outgoing/quick-reply-envelope";
|
||||
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, cleanPreview } from "@/lib/utils";
|
||||
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
|
||||
import { itemsToHide, toolbarHideOrder } from "@/lib/toolbar-overflow";
|
||||
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
|
||||
@@ -302,6 +302,8 @@ interface EmailViewerProps {
|
||||
onReplyAllConversationEmail?: (email: Email) => void;
|
||||
/** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */
|
||||
mvInlineComposer?: React.ReactNode;
|
||||
/** A reply or forward to this message is open under it: no quick reply box as well. */
|
||||
replyComposerOpen?: boolean;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
/** Stop a scheduled message from going out; it goes back to Drafts. */
|
||||
@@ -1373,6 +1375,7 @@ export function EmailViewer({
|
||||
onReplyConversationEmail,
|
||||
onReplyAllConversationEmail,
|
||||
mvInlineComposer,
|
||||
replyComposerOpen,
|
||||
onEditDraft,
|
||||
onCancelScheduledForEdit,
|
||||
onCancelScheduledSend,
|
||||
@@ -1396,6 +1399,7 @@ export function EmailViewer({
|
||||
const archiveActionEnabled = useArchiveActionEnabled();
|
||||
const tWindows = useTranslations('pwaWindows');
|
||||
const tComposer = useTranslations('email_composer');
|
||||
const tWords = useTranslations('compose_words');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tCommon = useTranslations('common');
|
||||
const tFiles = useTranslations('files');
|
||||
@@ -1689,6 +1693,13 @@ export function EmailViewer({
|
||||
if (isQuickReplyFocused) quickReplyBoxRef.current?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [isQuickReplyFocused]);
|
||||
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
|
||||
// "Reply to Ben Carter…": who the quick reply goes to, by name.
|
||||
const quickReplyNames = email
|
||||
? quickReplyRecipients(email, identities).map((r) => r.name || r.email).join(', ')
|
||||
: '';
|
||||
const quickReplyPlaceholder = quickReplyNames
|
||||
? tWords('quick_reply_to', { names: quickReplyNames })
|
||||
: t('quick_reply_placeholder');
|
||||
/** The recipients and identity the quick reply would be sent with. */
|
||||
const quickMetadata = (): ComposerMetadata => {
|
||||
if (!email) return { subject: '', to: [], attachments: [] };
|
||||
@@ -3503,7 +3514,7 @@ export function EmailViewer({
|
||||
const latestSrcDocRef = useRef<string | null>(null);
|
||||
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
|
||||
// Not on spam: a reply confirms the address to the sender.
|
||||
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
|
||||
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
|
||||
&& (!effectiveEmailContent.isHtml || iframeReady);
|
||||
// The plain-text body, where reader-above-quote mounts between text and quote.
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -6204,7 +6215,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
void handleSendQuickReply();
|
||||
}
|
||||
}}
|
||||
placeholder={t('quick_reply_placeholder')}
|
||||
placeholder={quickReplyPlaceholder}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg",
|
||||
"hover:border-accent focus:border-primary focus:outline-hidden transition-all",
|
||||
@@ -6216,10 +6227,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
|
||||
{/* Action buttons - show when focused or has text */}
|
||||
{(isQuickReplyFocused || quickReplyText) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('characters_count', { count: quickReplyText.length })}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -6242,9 +6250,10 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
}}
|
||||
disabled={isSendingQuickReply}
|
||||
className="text-muted-foreground"
|
||||
data-testid="quick-reply-full-editor"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 me-1" />
|
||||
{t('more_options')}
|
||||
<Maximize2 className="w-4 h-4 me-1" />
|
||||
{tWords('open_full_editor')}
|
||||
</Button>
|
||||
<QuickReplyPluginActions
|
||||
key={JSON.stringify([email.id, activeAccountId, email.sourceClientAccountId, email.sourceAccountId])}
|
||||
|
||||
@@ -12,10 +12,14 @@ export interface ResizableImageOptions {
|
||||
onEditAlt?: (pos: number) => void;
|
||||
/** The name of the button that does it ("Image description"). */
|
||||
editAltLabel?: string;
|
||||
/** The chip on an image that has no description yet ("Add description"). */
|
||||
addAltLabel?: string;
|
||||
}
|
||||
|
||||
function ResizableImageView({ node, updateAttributes, selected, extension, getPos }: NodeViewProps) {
|
||||
const { onEditAlt, editAltLabel } = extension.options as ResizableImageOptions;
|
||||
const { onEditAlt, editAltLabel, addAltLabel } = extension.options as ResizableImageOptions;
|
||||
// No description and not marked decorative (""): an optional chip asks for one.
|
||||
const undescribed = node.attrs.alt === null || node.attrs.alt === undefined;
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
// A remote image (a draft from another client, pasted web content) stays
|
||||
@@ -93,6 +97,23 @@ function ResizableImageView({ node, updateAttributes, selected, extension, getPo
|
||||
style={{ width: "100%", height: "auto", display: "block" }}
|
||||
draggable={false}
|
||||
/>
|
||||
{undescribed && onEditAlt && addAltLabel && !selected && (
|
||||
// Optional, never in the way: pasting or dropping an image asks nothing.
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
data-testid="image-alt-add"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const pos = typeof getPos === "function" ? getPos() : undefined;
|
||||
if (typeof pos === "number") onEditAlt(pos);
|
||||
}}
|
||||
className="absolute bottom-1 start-1 rounded-full bg-background/90 px-2 py-0.5 text-xs font-medium text-foreground shadow border border-border hover:bg-background"
|
||||
>
|
||||
{addAltLabel}
|
||||
</button>
|
||||
)}
|
||||
{selected && onEditAlt && (
|
||||
// Edit what the image says to screen readers. Reachable with Tab
|
||||
// while the image is selected, and from the toolbar.
|
||||
@@ -140,7 +161,7 @@ export const ResizableImage = Node.create<ResizableImageOptions>({
|
||||
name: "image",
|
||||
|
||||
addOptions() {
|
||||
return { onEditAlt: undefined, editAltLabel: undefined };
|
||||
return { onEditAlt: undefined, editAltLabel: undefined, addAltLabel: undefined };
|
||||
},
|
||||
group: "inline",
|
||||
inline: true,
|
||||
|
||||
@@ -70,8 +70,56 @@ import {
|
||||
Rows3,
|
||||
Columns3,
|
||||
ImageIcon,
|
||||
Ellipsis,
|
||||
} from "@/components/icons";
|
||||
|
||||
/**
|
||||
* Groups of the formatting toolbar that move behind "More formatting" when one
|
||||
* row is too narrow for all of them, the least used first. Each with its
|
||||
* buttons and the separators that go with it.
|
||||
*/
|
||||
const FOLDABLE_GROUPS = [
|
||||
{ id: "history", buttons: 3, separators: 2 },
|
||||
{ id: "align", buttons: 3, separators: 1 },
|
||||
{ id: "headings", buttons: 2, separators: 1 },
|
||||
{ id: "table", buttons: 1, separators: 0 },
|
||||
{ id: "blocks", buttons: 2, separators: 0 },
|
||||
] as const;
|
||||
type FoldableGroup = (typeof FOLDABLE_GROUPS)[number]["id"];
|
||||
/**
|
||||
* Always in the row: font size, bold, italic, underline, strikethrough, the
|
||||
* two colours, the lists, link, an image's description and More (12), with
|
||||
* their 3 separators.
|
||||
*/
|
||||
const FIXED_TOOLBAR = { buttons: 12, separators: 3 };
|
||||
|
||||
/**
|
||||
* How many of FOLDABLE_GROUPS go behind "More formatting" for one row of
|
||||
* `width` px. 0 for a toolbar that isn't laid out (hidden, or not measured).
|
||||
*/
|
||||
export function formattingFoldCount(width: number, sizes: { button: number; gap: number; separator: number; directionButton?: boolean }): number {
|
||||
if (!(width > 0)) return 0;
|
||||
const span = (buttons: number, separators: number) => buttons * (sizes.button + sizes.gap) + separators * (sizes.separator + sizes.gap);
|
||||
const groups = FOLDABLE_GROUPS.map((group) => span(group.buttons + (group.id === "align" && sizes.directionButton ? 1 : 0), group.separators));
|
||||
let needed = span(FIXED_TOOLBAR.buttons, FIXED_TOOLBAR.separators) + groups.reduce((sum, group) => sum + group, 0);
|
||||
// All of it fits without the More button.
|
||||
if (needed - (sizes.button + sizes.gap) <= width + 1) return 0;
|
||||
for (let count = 1; count <= groups.length; count++) {
|
||||
needed -= groups[count - 1];
|
||||
if (needed <= width + 1) return count;
|
||||
}
|
||||
return groups.length;
|
||||
}
|
||||
|
||||
/** The toolbar's inner width and the sizes of what is in it, in px. */
|
||||
function measureFormattingToolbar(toolbar: HTMLElement, directionButton: boolean): number {
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
const style = getComputedStyle(toolbar);
|
||||
const width = toolbar.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0);
|
||||
const button = toolbar.querySelector<HTMLElement>(":scope > [data-toolbar-item], :scope > div > [data-toolbar-item]")?.offsetWidth || 1.75 * rem;
|
||||
return formattingFoldCount(width, { button, gap: 0.125 * rem, separator: 1 + 0.25 * rem, directionButton });
|
||||
}
|
||||
|
||||
export interface InlineImageUpload {
|
||||
src: string;
|
||||
cid?: string;
|
||||
@@ -440,6 +488,7 @@ export function RichTextEditor({
|
||||
}: RichTextEditorProps) {
|
||||
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
|
||||
const tComposer = useTranslations("email_composer");
|
||||
const tUx = useTranslations("compose_ux");
|
||||
const tQuote = useTranslations("email_viewer");
|
||||
const onImageUploadRef = React.useRef(onImageUpload);
|
||||
onImageUploadRef.current = onImageUpload;
|
||||
@@ -464,6 +513,8 @@ export function RichTextEditor({
|
||||
// Images waiting for a description, one dialog at a time: a new image is
|
||||
// found by its src (it has no alt yet), an existing one by its position.
|
||||
const [altQueue, setAltQueue] = useState<AltRequest[]>([]);
|
||||
// Insert link, for the key handler set up with the editor.
|
||||
const addLinkRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -490,6 +541,7 @@ export function RichTextEditor({
|
||||
ResizableImage.configure({
|
||||
onEditAlt: (pos: number) => setAltQueue((queue) => [...queue, { pos }]),
|
||||
editAltLabel: tComposer("image_alt.edit"),
|
||||
addAltLabel: tUx("add_description"),
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
@@ -565,6 +617,12 @@ export function RichTextEditor({
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
// Ctrl+K inserts a link, as in Gmail, Outlook and Word.
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "k" && addLinkRef.current) {
|
||||
event.preventDefault();
|
||||
addLinkRef.current();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view, event) => {
|
||||
@@ -584,11 +642,11 @@ export function RichTextEditor({
|
||||
if (result) {
|
||||
const { state } = view;
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
// No alt yet (never the file name): the user is asked next.
|
||||
// No alt yet (never the file name).
|
||||
const node = state.schema.nodes.image.create({ src: result.src, alt: null, cid: result.cid });
|
||||
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
||||
view.dispatch(tr);
|
||||
setAltQueue((queue) => [...queue, { src: result.src }]);
|
||||
// No dialog: the image offers "Add description" itself.
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -607,7 +665,7 @@ export function RichTextEditor({
|
||||
const node = state.schema.nodes.image.create({ src: result.src, alt: null, cid: result.cid });
|
||||
const tr = state.tr.replaceSelectionWith(node);
|
||||
view.dispatch(tr);
|
||||
setAltQueue((queue) => [...queue, { src: result.src }]);
|
||||
// No dialog: the image offers "Add description" itself.
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -694,6 +752,7 @@ export function RichTextEditor({
|
||||
const { from, to } = editor.state.selection;
|
||||
setLinkDialog({ href: previousUrl ?? "", from, to });
|
||||
}, [editor]);
|
||||
addLinkRef.current = addLink;
|
||||
const applyLink = useCallback((url: string) => {
|
||||
if (!editor || !linkDialog) return;
|
||||
editor
|
||||
@@ -716,6 +775,7 @@ export function RichTextEditor({
|
||||
}, [editor, linkDialog]);
|
||||
|
||||
const tToolbar = useTranslations("email_composer.toolbar");
|
||||
const tWindows = useTranslations("compose_windows");
|
||||
const tCommon = useTranslations("common");
|
||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -734,11 +794,13 @@ export function RichTextEditor({
|
||||
// focus last keeps it, the arrows, Home and End move inside.
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const rovingItemRef = useRef<string | null>(null);
|
||||
// Buttons in the "More formatting" panel are the panel's, with its own keys.
|
||||
const inRow = (el: HTMLElement) => !el.closest("[data-toolbar-more-panel]");
|
||||
const toolbarItems = () =>
|
||||
Array.from(toolbarRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]") ?? [])
|
||||
.filter((el) => !el.disabled);
|
||||
.filter((el) => !el.disabled && inRow(el));
|
||||
const syncToolbarTabStops = (focus?: HTMLButtonElement) => {
|
||||
const all = Array.from(toolbarRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]") ?? []);
|
||||
const all = Array.from(toolbarRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]") ?? []).filter(inRow);
|
||||
const enabled = all.filter((el) => !el.disabled);
|
||||
const current = focus ?? enabled.find((el) => el.dataset.toolbarItem === rovingItemRef.current) ?? enabled[0];
|
||||
if (current) rovingItemRef.current = current.dataset.toolbarItem ?? null;
|
||||
@@ -757,6 +819,36 @@ export function RichTextEditor({
|
||||
return !!stop && document.activeElement === stop;
|
||||
};
|
||||
|
||||
// One row: what doesn't fit goes behind "More formatting" (a wrapped
|
||||
// second row took the body's room in a narrow composer).
|
||||
const [foldCount, setFoldCount] = useState(0);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const moreWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const hasEditor = !!editor;
|
||||
useLayoutEffect(() => {
|
||||
const toolbar = toolbarRef.current;
|
||||
if (!toolbar) return;
|
||||
const measure = () => setFoldCount(measureFormattingToolbar(toolbar, !!rtlEditingSupport));
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
measure();
|
||||
setMoreOpen(false);
|
||||
});
|
||||
observer.observe(toolbar);
|
||||
return () => observer.disconnect();
|
||||
}, [hasEditor, rtlEditingSupport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (moreWrapperRef.current && !moreWrapperRef.current.contains(e.target as Node)) setMoreOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [moreOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fontSizeMenuOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -816,6 +908,15 @@ export function RichTextEditor({
|
||||
const altTarget = findAltTarget(editor, altRequest);
|
||||
const imageSelected = editor.isActive("image");
|
||||
|
||||
// A group that doesn't fit renders in the "More formatting" panel instead, in row order.
|
||||
const folded = new Set<FoldableGroup>(FOLDABLE_GROUPS.slice(0, foldCount).map((group) => group.id));
|
||||
const overflow: React.ReactNode[] = [];
|
||||
const place = (group: FoldableGroup, node: React.ReactNode) => {
|
||||
if (!folded.has(group)) return node;
|
||||
overflow.push(<React.Fragment key={group}>{node}</React.Fragment>);
|
||||
return null;
|
||||
};
|
||||
|
||||
const onToolbarKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Keys inside a popover are the popover's.
|
||||
@@ -836,7 +937,7 @@ export function RichTextEditor({
|
||||
};
|
||||
const onToolbarFocus = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item")) syncToolbarTabStops(target);
|
||||
if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item") && inRow(target)) syncToolbarTabStops(target);
|
||||
};
|
||||
|
||||
const renderPalette = (current: string | null, apply: (color: string) => void, removeLabel: string, remove: () => void) => (
|
||||
@@ -1101,6 +1202,7 @@ export function RichTextEditor({
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
{place("headings", <>
|
||||
<ToolbarButton
|
||||
item="heading-1"
|
||||
pressed={editor.isActive("heading", { level: 1 })}
|
||||
@@ -1119,6 +1221,7 @@ export function RichTextEditor({
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
</>)}
|
||||
|
||||
<ToolbarButton
|
||||
item="bullet-list"
|
||||
@@ -1136,6 +1239,7 @@ export function RichTextEditor({
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
{place("blocks", <>
|
||||
<ToolbarButton
|
||||
item="quote"
|
||||
pressed={editor.isActive("blockquote")}
|
||||
@@ -1152,9 +1256,11 @@ export function RichTextEditor({
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</>)}
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
{place("align", <>
|
||||
<ToolbarButton
|
||||
item="align-left"
|
||||
pressed={editor.isActive({ textAlign: "left" })}
|
||||
@@ -1198,6 +1304,7 @@ export function RichTextEditor({
|
||||
)}
|
||||
|
||||
<ToolbarSeparator />
|
||||
</>)}
|
||||
|
||||
<ToolbarButton
|
||||
item="link"
|
||||
@@ -1222,7 +1329,7 @@ export function RichTextEditor({
|
||||
</ToolbarButton>
|
||||
)}
|
||||
|
||||
<div ref={tableWrapperRef} className="relative">
|
||||
{place("table", <div ref={tableWrapperRef} className="relative">
|
||||
<ToolbarButton
|
||||
ref={tableButtonRef}
|
||||
item="table"
|
||||
@@ -1274,8 +1381,9 @@ export function RichTextEditor({
|
||||
</ToolbarPopover>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{place("history", <>
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
@@ -1304,6 +1412,35 @@ export function RichTextEditor({
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</>)}
|
||||
|
||||
{overflow.length > 0 && (
|
||||
<div ref={moreWrapperRef} className="relative">
|
||||
<ToolbarButton
|
||||
ref={moreButtonRef}
|
||||
item="more-formatting"
|
||||
popup="dialog"
|
||||
expanded={moreOpen}
|
||||
onClick={() => setMoreOpen((v) => !v)}
|
||||
title={tWindows("more_formatting")}
|
||||
>
|
||||
<Ellipsis className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
{moreOpen && (
|
||||
<ToolbarPopover
|
||||
label={tWindows("more_formatting")}
|
||||
role="dialog"
|
||||
triggerRef={moreButtonRef}
|
||||
onClose={() => setMoreOpen(false)}
|
||||
className="p-1 flex w-max max-w-[16rem] flex-wrap items-center gap-0.5"
|
||||
>
|
||||
<div data-toolbar-more-panel="" className="contents">
|
||||
{overflow}
|
||||
</div>
|
||||
</ToolbarPopover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
|
||||
/** Something the composer asks about before a message goes out. */
|
||||
export type SendWarning =
|
||||
| { kind: "subject" }
|
||||
| { kind: "text" }
|
||||
| { kind: "placeholders"; matches: string[] }
|
||||
| { kind: "attachment"; keyword: string }
|
||||
| { kind: "proofreading"; count: number };
|
||||
|
||||
interface SendWarningsDialogProps {
|
||||
/** What to ask about, in the order the composer checks it. Closed when null. */
|
||||
warnings: SendWarning[] | null;
|
||||
/** "Back to editing", Escape or a click outside. */
|
||||
onBack: () => void;
|
||||
/** "Send anyway". `dontAskAgain`: the box under a single warning that has a setting. */
|
||||
onSendAnyway: (dontAskAgain: boolean) => void;
|
||||
}
|
||||
|
||||
/** The warnings that can be switched off in Settings (subject and placeholders). */
|
||||
const HAS_SETTING = new Set<SendWarning["kind"]>(["subject", "placeholders"]);
|
||||
|
||||
/**
|
||||
* One question before sending, whatever it found: a single warning keeps its
|
||||
* own title and text, several are listed in one dialog with one "Send anyway"
|
||||
* (two dialogs in a row asked about each in turn).
|
||||
*/
|
||||
export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarningsDialogProps) {
|
||||
const t = useTranslations("email_composer");
|
||||
const tSend = useTranslations("compose_send");
|
||||
const tProofreading = useTranslations("proofreading");
|
||||
const [dontAskAgain, setDontAskAgain] = useState(false);
|
||||
const [shownFor, setShownFor] = useState<SendWarning[] | null>(null);
|
||||
// A new question starts with the box unticked.
|
||||
if (warnings !== shownFor) {
|
||||
setShownFor(warnings);
|
||||
setDontAskAgain(false);
|
||||
}
|
||||
|
||||
const items = warnings ?? [];
|
||||
const single = items.length === 1 ? items[0] : null;
|
||||
|
||||
const placeholderChips = (matches: string[]) => (
|
||||
<ul className="mt-3 flex flex-wrap items-center gap-1.5" aria-label={t("placeholder_warning.found")}>
|
||||
{matches.slice(0, 5).map((p) => (
|
||||
<li key={p} className="max-w-full truncate rounded bg-muted px-2 py-0.5 font-mono text-xs text-foreground">{p}</li>
|
||||
))}
|
||||
{matches.length > 5 && (
|
||||
<li className="text-xs text-muted-foreground">
|
||||
{t("placeholder_warning.more", { count: matches.length - 5 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
|
||||
let title: string;
|
||||
let description: string | undefined;
|
||||
let backLabel = tSend("back");
|
||||
let sendLabel = tSend("send_anyway");
|
||||
let body: React.ReactNode = null;
|
||||
|
||||
if (single?.kind === "subject") {
|
||||
title = t("empty_subject.title");
|
||||
description = t("empty_subject.message");
|
||||
backLabel = t("empty_subject.back");
|
||||
sendLabel = t("empty_subject.send_anyway");
|
||||
} else if (single?.kind === "text") {
|
||||
title = tSend("empty_text_title");
|
||||
description = tSend("empty_text_message");
|
||||
} else if (single?.kind === "placeholders") {
|
||||
title = t("placeholder_warning.title");
|
||||
description = t("placeholder_warning.message");
|
||||
backLabel = t("placeholder_warning.back");
|
||||
sendLabel = t("placeholder_warning.send_anyway");
|
||||
body = placeholderChips(single.matches);
|
||||
} else if (single?.kind === "attachment") {
|
||||
title = t("forgot_attachment.title");
|
||||
description = t("forgot_attachment.message", { keyword: single.keyword });
|
||||
backLabel = t("forgot_attachment.back");
|
||||
sendLabel = t("forgot_attachment.send_anyway");
|
||||
} else if (single?.kind === "proofreading") {
|
||||
title = tProofreading("send_warning.title");
|
||||
description = tProofreading("send_warning.body", { count: single.count });
|
||||
backLabel = tProofreading("send_warning.review");
|
||||
sendLabel = tProofreading("send_warning.send");
|
||||
} else {
|
||||
title = tSend("warnings_title");
|
||||
body = (
|
||||
<ul className="mt-3 space-y-2 text-sm text-foreground" data-testid="send-warnings-list">
|
||||
{items.map((w) => (
|
||||
<li key={w.kind} className="flex gap-2">
|
||||
<span aria-hidden="true" className="text-muted-foreground">•</span>
|
||||
<div className="min-w-0">
|
||||
{w.kind === "subject" && tSend("warning_subject")}
|
||||
{w.kind === "text" && tSend("warning_text")}
|
||||
{w.kind === "attachment" && tSend("warning_attachment", { keyword: w.keyword })}
|
||||
{w.kind === "proofreading" && tSend("warning_proofreading", { count: w.count })}
|
||||
{w.kind === "placeholders" && (
|
||||
<>
|
||||
{tSend("warning_placeholders")}
|
||||
{placeholderChips(w.matches)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={items.length > 0}
|
||||
onClose={onBack}
|
||||
role="alertdialog"
|
||||
showClose={false}
|
||||
title={title}
|
||||
description={description}
|
||||
testId="send-warnings"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onBack}>{backLabel}</Button>
|
||||
<Button data-testid={single?.kind === "proofreading" ? "proofreading-send-anyway" : "send-warnings-send"} onClick={() => onSendAnyway(dontAskAgain)}>{sendLabel}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{body}
|
||||
{single && HAS_SETTING.has(single.kind) && (
|
||||
<label className="mt-4 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dontAskAgain}
|
||||
onChange={(e) => setDontAskAgain(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{single.kind === "subject" ? t("empty_subject.dont_ask_again") : t("placeholder_warning.dont_ask_again")}
|
||||
</label>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,16 @@ function renderPicker(list: () => Promise<FileNode[]> = async () => TREE) {
|
||||
}
|
||||
|
||||
describe('FilePickerDialog', () => {
|
||||
// The X in the header was labelled "Cancel" like the footer button.
|
||||
it('names the header X "Close" and keeps one Cancel button, both closing', async () => {
|
||||
const { onClose } = renderPicker();
|
||||
await screen.findByText('invoice.pdf');
|
||||
expect(screen.getAllByRole('button', { name: 'cancel' })).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'cancel' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('lists the root without hidden or shared nodes, and offers Shared with me', async () => {
|
||||
renderPicker();
|
||||
expect(await screen.findByText('invoice.pdf')).toBeInTheDocument();
|
||||
|
||||
@@ -45,6 +45,7 @@ function byFolderThenName(a: FileNode, b: FileNode): number {
|
||||
export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogProps) {
|
||||
const t = useTranslations("files");
|
||||
const tComposer = useTranslations("email_composer");
|
||||
const tCommon = useTranslations("common");
|
||||
const [nodes, setNodes] = useState<FileNode[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
@@ -245,7 +246,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("cancel")}
|
||||
aria-label={tCommon("close")}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
|
||||
@@ -22,6 +22,11 @@ interface SubAddressHelperProps {
|
||||
recipientEmails: string[];
|
||||
onSelectTag: (tag: string) => void;
|
||||
disabled?: boolean;
|
||||
/** Opened from elsewhere (the composer's From list) instead of its own button. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** No button of its own: the host opens it. */
|
||||
hideTrigger?: boolean;
|
||||
}
|
||||
|
||||
export function SubAddressHelper({
|
||||
@@ -29,9 +34,17 @@ export function SubAddressHelper({
|
||||
recipientEmails,
|
||||
onSelectTag,
|
||||
disabled = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
hideTrigger = false,
|
||||
}: SubAddressHelperProps) {
|
||||
const t = useTranslations('identities.sub_address');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [ownOpen, setOwnOpen] = useState(false);
|
||||
const isOpen = open ?? ownOpen;
|
||||
const setIsOpen = (next: boolean) => {
|
||||
if (open === undefined) setOwnOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
const [tag, setTag] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
@@ -128,18 +141,20 @@ export function SubAddressHelper({
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Trigger Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
title={t('button_tooltip')}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
<Tag className="w-4 h-4" />
|
||||
</Button>
|
||||
{!hideTrigger && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
title={t('button_tooltip')}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
<Tag className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Popover */}
|
||||
{isOpen && (
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// The "Continue draft" bar and clicking another message while writing: the
|
||||
// bar's X is its own button and deletes the saved draft, with Undo; another
|
||||
// message closes the composer the way its X does (it keeps the draft).
|
||||
|
||||
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
|
||||
const stub = vi.hoisted(() => ({ requestClose: null as null | ((afterClose?: () => void) => void) }));
|
||||
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({
|
||||
Sidebar: ({ onCompose }: { onCompose: () => void }) => <button onClick={onCompose}>stub compose</button>,
|
||||
}));
|
||||
// The composer hands its draft to the host as it unmounts, and offers the
|
||||
// host its close when the test says so.
|
||||
vi.mock('@/components/email/email-composer', async () => {
|
||||
const { useEffect } = await import('react');
|
||||
return {
|
||||
EmailComposer: ({ onSaveState, requestCloseRef }: {
|
||||
onSaveState: (data: Record<string, unknown>) => void;
|
||||
requestCloseRef?: { current: unknown };
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (requestCloseRef && stub.requestClose) requestCloseRef.current = stub.requestClose;
|
||||
return () => {
|
||||
if (requestCloseRef) requestCloseRef.current = null;
|
||||
onSaveState({ subject: 'Parked', draftId: 'draft-7', draftAccountId: 'acc', mode: 'compose' });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return <div>stub composer</div>;
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@/components/search/mv-search-form', () => ({
|
||||
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
|
||||
}));
|
||||
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => device.current,
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({
|
||||
EmailList: ({ onEmailSelect }: { onEmailSelect: (email: { id: string }) => void }) => (
|
||||
<button onClick={() => void onEmailSelect({ id: 'other-mail' })}>stub other mail</button>
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
|
||||
let deleteEmail: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
device.current = { isMobile: false, isTablet: false, isDesktop: true };
|
||||
stub.requestClose = null;
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
deleteEmail = vi.fn().mockResolvedValue(undefined);
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
deleteEmail,
|
||||
getEmail: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
|
||||
useEmailStore.setState({
|
||||
emails: [], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
|
||||
selectedEmailKeys: new Set(), unreadSection: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
|
||||
async function parkDraft() {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await screen.findByText('stub composer');
|
||||
// No close offered: the composer unmounts and parks its draft.
|
||||
fireEvent.click(screen.getByText('stub other mail'));
|
||||
await screen.findByText('email_composer.continue_draft');
|
||||
}
|
||||
|
||||
const discardButton = () => screen.getByRole('button', { name: 'discard_draft' });
|
||||
|
||||
describe('the Continue draft bar', () => {
|
||||
it('has a Discard button of its own, outside the bar button', async () => {
|
||||
await parkDraft();
|
||||
expect(discardButton().parentElement?.closest('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('deletes the saved draft once the Undo window is over', async () => {
|
||||
await parkDraft();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
fireEvent.click(discardButton());
|
||||
expect(screen.queryByText('email_composer.continue_draft')).toBeNull();
|
||||
const undoToast = useToastStore.getState().toasts.find((t) => t.title === 'draft_discarded');
|
||||
expect(undoToast?.action?.label).toBe('undo');
|
||||
expect(deleteEmail).not.toHaveBeenCalled();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(undoToast!.duration! + 10); });
|
||||
expect(deleteEmail).toHaveBeenCalledWith('draft-7', 'acc');
|
||||
});
|
||||
|
||||
it('brings the bar back on Undo and keeps the draft', async () => {
|
||||
await parkDraft();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
fireEvent.click(discardButton());
|
||||
const undoToast = useToastStore.getState().toasts.find((t) => t.title === 'draft_discarded')!;
|
||||
act(() => undoToast.action!.onClick());
|
||||
expect(await screen.findByText('email_composer.continue_draft')).toBeInTheDocument();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
|
||||
expect(deleteEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('another message clicked while writing', () => {
|
||||
it('closes the composer through its own close, which keeps the draft', async () => {
|
||||
const requestClose = vi.fn();
|
||||
stub.requestClose = requestClose;
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await screen.findByText('stub composer');
|
||||
|
||||
fireEvent.click(screen.getByText('stub other mail'));
|
||||
|
||||
await waitFor(() => expect(requestClose).toHaveBeenCalledTimes(1));
|
||||
// Still there until it has saved and closes itself; no bar.
|
||||
expect(screen.getByText('stub composer')).toBeInTheDocument();
|
||||
expect(screen.queryByText('email_composer.continue_draft')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// Reply in the standard layout on a computer: the composer docks under the
|
||||
// message it answers, which stays on screen above it, as in Gmail and Outlook.
|
||||
|
||||
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
|
||||
const stub = vi.hoisted(() => ({ requestClose: null as null | ((afterClose?: () => void) => void) }));
|
||||
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({
|
||||
Sidebar: ({ onCompose }: { onCompose: () => void }) => <button onClick={onCompose}>stub compose</button>,
|
||||
}));
|
||||
// The composer hands its draft to the host as it unmounts, and offers the
|
||||
// host its close when the test says so.
|
||||
vi.mock('@/components/email/email-composer', async () => {
|
||||
const { useEffect } = await import('react');
|
||||
return {
|
||||
EmailComposer: ({ onSaveState, requestCloseRef, replyTo }: {
|
||||
onSaveState: (data: Record<string, unknown>) => void;
|
||||
requestCloseRef?: { current: unknown };
|
||||
replyTo?: { quoteHeaderHtml?: string };
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (requestCloseRef && stub.requestClose) requestCloseRef.current = stub.requestClose;
|
||||
return () => {
|
||||
if (requestCloseRef) requestCloseRef.current = null;
|
||||
onSaveState({ subject: 'Parked', draftId: 'draft-7', draftAccountId: 'acc', mode: 'compose' });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return <div data-testid="stub-composer" data-quote={replyTo?.quoteHeaderHtml ?? ''}>stub composer</div>;
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@/components/search/mv-search-form', () => ({
|
||||
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
|
||||
}));
|
||||
vi.mock('@/components/email/email-viewer', () => ({
|
||||
EmailViewer: ({ onReply, onForward, replyComposerOpen }: { onReply: () => void; onForward: () => void; replyComposerOpen?: boolean }) => (
|
||||
<div data-testid="viewer" data-reply-open={replyComposerOpen ? 'yes' : 'no'}>
|
||||
message viewer
|
||||
<button onClick={() => onReply()}>stub reply</button>
|
||||
<button onClick={() => onForward()}>stub forward</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => device.current,
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({
|
||||
EmailList: ({ onEmailSelect }: { onEmailSelect: (email: { id: string }) => void }) => (
|
||||
<button onClick={() => void onEmailSelect({ id: 'other-mail' })}>stub other mail</button>
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
|
||||
const MESSAGE = {
|
||||
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }],
|
||||
subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10,
|
||||
hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {},
|
||||
} as never;
|
||||
|
||||
beforeEach(() => {
|
||||
device.current = { isMobile: false, isTablet: false, isDesktop: true };
|
||||
stub.requestClose = null;
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
getEmail: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
|
||||
useEmailStore.setState({
|
||||
emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
|
||||
selectedEmailKeys: new Set(), unreadSection: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
|
||||
describe('a reply in the standard layout', () => {
|
||||
for (const action of ['stub reply', 'stub forward']) {
|
||||
it(`docks under the message, which stays on screen (${action})`, async () => {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText(action));
|
||||
const composer = await screen.findByText('stub composer');
|
||||
expect(screen.getByTestId('viewer')).toBeInTheDocument();
|
||||
expect(composer.closest('[data-reply-dock]')).not.toBeNull();
|
||||
// No quick reply box under the message as well.
|
||||
expect(screen.getByTestId('viewer')).toHaveAttribute('data-reply-open', 'yes');
|
||||
});
|
||||
}
|
||||
|
||||
// compose-30: a forward says who the message went to.
|
||||
it('gives a forward a header with To', async () => {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText('stub forward'));
|
||||
await waitFor(() => expect(screen.getByTestId('stub-composer').getAttribute('data-quote')).toContain('forward_to_label'));
|
||||
});
|
||||
|
||||
it('takes the whole pane for a new message', async () => {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await screen.findByText('stub composer');
|
||||
expect(screen.queryByTestId('viewer')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// compose-24: after Send the composer stayed on screen, greyed and without a
|
||||
// word, until the server answered. It closes at once now, "Sending…" and then
|
||||
// "Sent · View" say what happens, and a send that fails brings the text back.
|
||||
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
|
||||
const Component = lazy(async () => ({ default: await load() }));
|
||||
return function Dynamic(props: Record<string, unknown>) {
|
||||
return <Suspense fallback={null}><Component {...props} /></Suspense>;
|
||||
};
|
||||
} }));
|
||||
vi.mock('@/components/email/email-composer', () => ({
|
||||
EmailComposer: ({ onSend, onSendFailed, initialData }: {
|
||||
onSend?: (data: unknown) => Promise<void>;
|
||||
onSendFailed?: (state: unknown) => void;
|
||||
initialData?: { subject?: string } | null;
|
||||
}) => (
|
||||
<div>
|
||||
<span>stub subject: {initialData?.subject ?? ''}</span>
|
||||
<button onClick={() => {
|
||||
// As the composer does: what it handed over comes back on a failure.
|
||||
onSend?.({ to: ['bob@example.com'], cc: [], bcc: [], subject: 'Hi', body: 'Hello' })
|
||||
?.catch(() => onSendFailed?.({ to: 'bob@example.com', cc: '', bcc: '', subject: 'Hi', body: 'Hello', mode: 'compose' }));
|
||||
}}>
|
||||
stub send
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({
|
||||
MobileHeader: ({ onOpenSearch }: { onOpenSearch?: () => void }) => <button onClick={onOpenSearch}>stub open search</button>,
|
||||
}));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => false,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
|
||||
const sendEmail = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'me@example.com',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0 } as never);
|
||||
sendEmail.mockReset().mockResolvedValue({});
|
||||
useToastStore.setState({ toasts: [] });
|
||||
useEmailStore.setState({
|
||||
emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null,
|
||||
selectedEmailKeys: new Set(),
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
sendEmail,
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const toastTitled = (title: string) => useToastStore.getState().toasts.find((t) => t.title === title);
|
||||
|
||||
async function openComposer() {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' }));
|
||||
await screen.findByText('stub send', {}, { timeout: 5000 });
|
||||
}
|
||||
|
||||
describe('sending', () => {
|
||||
it('closes the composer at once and says "Sending…", then "Sent" with View', async () => {
|
||||
let finish: (result: unknown) => void = () => {};
|
||||
sendEmail.mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
await openComposer();
|
||||
|
||||
fireEvent.click(screen.getByText('stub send'));
|
||||
|
||||
// The server hasn't answered yet.
|
||||
await waitFor(() => expect(screen.queryByText('stub send')).not.toBeInTheDocument());
|
||||
expect(sendEmail).toHaveBeenCalled();
|
||||
expect(toastTitled('compose_send.sending')).toBeDefined();
|
||||
|
||||
await act(async () => { finish({ scheduled: false, emailId: 'sent-1' }); });
|
||||
|
||||
await waitFor(() => expect(toastTitled('compose_send.sent')).toBeDefined());
|
||||
expect(toastTitled('compose_send.sent')!.action?.label).toBe('compose_send.view');
|
||||
expect(toastTitled('compose_send.sending')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('opens the composer again with the text when the send fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
sendEmail.mockRejectedValue(new Error('refused'));
|
||||
await openComposer();
|
||||
|
||||
fireEvent.click(screen.getByText('stub send'));
|
||||
|
||||
expect(await screen.findByText('stub subject: Hi', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(toastTitled('compose_send.sending')).toBeUndefined();
|
||||
expect(toastTitled('compose_send.sent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a delayed send to the undo toast', async () => {
|
||||
sendEmail.mockResolvedValue({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: new Date(Date.now() + 5000).toISOString() });
|
||||
await openComposer();
|
||||
|
||||
fireEvent.click(screen.getByText('stub send'));
|
||||
|
||||
await waitFor(() => expect(sendEmail).toHaveBeenCalled());
|
||||
await waitFor(() => expect(toastTitled('compose_send.sending')).toBeUndefined());
|
||||
expect(toastTitled('compose_send.sent')).toBeUndefined();
|
||||
expect(screen.queryByText('stub send')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,18 @@ describe('undo send toast', () => {
|
||||
expect(toast.pauseOnHover).toBe(false);
|
||||
});
|
||||
|
||||
// compose-23: "Email scheduled for sending" with "Undo send" said neither
|
||||
// when it goes nor that the button cancels it.
|
||||
it('says when a scheduled message goes, with Cancel and Send now', async () => {
|
||||
render(<MailApp />);
|
||||
sendDelayedBy(3 * 86_400_000);
|
||||
|
||||
await waitFor(() => expect(useToastStore.getState().toasts.some((t) => t.title === 'compose_send.scheduled_for')).toBe(true));
|
||||
const toast = useToastStore.getState().toasts.find((t) => t.title === 'compose_send.scheduled_for')!;
|
||||
expect(toast.action?.label).toBe('cancel');
|
||||
expect(toast.secondaryAction?.label).toBe('email_viewer.send_now');
|
||||
});
|
||||
|
||||
it('says so when the undo comes too late', async () => {
|
||||
cancelUndoSend.mockRejectedValue(new Error('The requested message is no longer in the queue.'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
+103
-46
@@ -42,8 +42,10 @@ import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type Dr
|
||||
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
|
||||
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { discardWithUndo } from "@/lib/compose/draft-close-toast";
|
||||
import { cancelFailureToast, partlyRefusedWarning, rescheduleFailureToast, rescheduledToast } from "@/lib/send-failure-message";
|
||||
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
|
||||
import { startSendProgress } from "@/lib/outgoing/send-progress-toast";
|
||||
import { formatRejectedRecipients, RescheduleRestoredError } from "@/lib/jmap/client";
|
||||
import { subscribePushStateChanges } from "@/lib/push-events";
|
||||
import { createMailPushListener } from "@/lib/mail-push-listener";
|
||||
@@ -269,6 +271,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// "Confirm before deleting / archiving" (lib/mail-removal-confirm).
|
||||
const tRemove = useTranslations('email_list.confirm_remove');
|
||||
const tCommon = useTranslations('common');
|
||||
const tComposeUx = useTranslations('compose_ux');
|
||||
const tComposeWords = useTranslations('compose_words');
|
||||
const appName = productName(useConfig().appName);
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const tComposer = useTranslations('email_composer');
|
||||
@@ -299,6 +303,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const composerSessionRef = useRef(composerSessionId);
|
||||
composerSessionRef.current = composerSessionId;
|
||||
const composerHostRef = useRef('pane');
|
||||
// The compose session docked under its message (dockedReply).
|
||||
const dockedSessionRef = useRef<number | null>(null);
|
||||
const showComposerRef = useRef(showComposer);
|
||||
showComposerRef.current = showComposer;
|
||||
// Fields a plugin filled in through onBeforeCompose for the composer about
|
||||
@@ -2605,6 +2611,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// the user's message without so much as a toast (#702).
|
||||
let submitted = false;
|
||||
|
||||
// The composer closes at once and "Sending…" shows meanwhile. A send that
|
||||
// fails comes back through onSendFailed, which opens the composer again.
|
||||
setShowComposer(false);
|
||||
setPendingDraft(null);
|
||||
if (isMobile && !useEmailStore.getState().selectedEmail) setActiveView('list');
|
||||
const progress = startSendProgress({
|
||||
sending: t('compose_send.sending'),
|
||||
sent: t('compose_send.sent'),
|
||||
view: t('compose_send.view'),
|
||||
});
|
||||
|
||||
try {
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const originalEmailId = answeredEmailId;
|
||||
@@ -2612,7 +2629,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, composerSendOptions(data));
|
||||
submitted = true;
|
||||
announceSent(result, data);
|
||||
setShowComposer(false);
|
||||
// "Sent" with View. A delayed or scheduled send gets the undo toast, and
|
||||
// a filing error, a refused recipient or the offline outbox say so themselves.
|
||||
if (result.scheduled || result.filingError || result.rejectedRecipients?.length || result.queued) {
|
||||
progress.dismiss();
|
||||
} else {
|
||||
const sentId = result.emailId;
|
||||
const ownerAccountId = result.emailAccountId && result.emailAccountId !== sendClient.getAccountId() ? result.emailAccountId : undefined;
|
||||
progress.sent(sentId && sendClient === client
|
||||
? () => void applyMailDeepLinkRef.current({ kind: 'message', id: sentId, ownerAccountId })
|
||||
: undefined);
|
||||
}
|
||||
// Sending an edited draft destroys it server-side - and every autosave
|
||||
// before that already replaced it under a fresh id (createDraft is a
|
||||
// destroy+create). If the email on display is the draft this session
|
||||
@@ -2697,11 +2724,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delayed sends get the undo toast instead; a filing error or a refused
|
||||
// recipient already warned.
|
||||
// A message kept in the offline outbox said so itself (#523).
|
||||
if (!result.filingError && !result.rejectedRecipients?.length && !result.queued) toast.success(t('notifications.email_sent'));
|
||||
|
||||
// Refresh the current mailbox to update the UI
|
||||
if (!isScheduledView) {
|
||||
await refreshCurrentMailbox(client);
|
||||
@@ -2709,6 +2731,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
if (submitted) progress.sent();
|
||||
else progress.dismiss();
|
||||
// Post-send bookkeeping - refreshing Sent, flagging the original as
|
||||
// answered, re-reading the thread - is cosmetic once the mail is out.
|
||||
if (!submitted) throw error;
|
||||
@@ -2779,6 +2803,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
fromLabel: tQuote('from_label'),
|
||||
dateLabel: tQuote('date_label'),
|
||||
subjectLabel: tQuote('subject_label'),
|
||||
// A forward says who the message went to, as Gmail and Outlook do.
|
||||
toLabel: tComposeWords('forward_to_label'),
|
||||
ccLabel: tComposeWords('forward_cc_label'),
|
||||
},
|
||||
});
|
||||
setComposerQuoteHeader(header);
|
||||
@@ -2786,7 +2813,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
console.warn('[quote-header] plugin transform failed; using default', err);
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
}, [tCommon, tQuote]);
|
||||
}, [tCommon, tQuote, tComposeWords]);
|
||||
|
||||
// Force a clean composer remount on every fresh entry point so prior
|
||||
// compose state can't bleed into the new session (#329 C). The composer is
|
||||
@@ -2999,9 +3026,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
? Math.max(remainingMs - 1000, 1000)
|
||||
: Math.max(sendDelaySeconds, 8) * 1000;
|
||||
|
||||
// A message scheduled for later says when it goes, and offers Cancel
|
||||
// where a send-delay window offers Undo send.
|
||||
toast.success(isUndoWindow
|
||||
? t('email_viewer.sending_in', { seconds: Math.round(remainingMs / 1000) })
|
||||
: t('email_viewer.scheduled_send_created'), {
|
||||
: t('compose_send.scheduled_for', { when: formatScheduledSendAt(pending.sendAt, useSettingsStore.getState().timeFormat) }), {
|
||||
duration: undoDurationMs,
|
||||
pauseOnHover: false,
|
||||
secondaryAction: (pending.emailId && pending.identityId)
|
||||
@@ -3043,7 +3072,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
: undefined,
|
||||
action: {
|
||||
label: t('email_viewer.undo_send'),
|
||||
label: isUndoWindow ? t('email_viewer.undo_send') : tCommon('cancel'),
|
||||
onClick: () => {
|
||||
if (!undoClient) return refuseSignedOut();
|
||||
void (async () => {
|
||||
@@ -4535,7 +4564,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
let delayedUntil: string | undefined;
|
||||
if (sendDelaySeconds > 0) {
|
||||
if (!client.hasDelayedSend()) {
|
||||
const confirmed = await confirmDialog({
|
||||
// As in the composer: only a delay the user picked asks.
|
||||
const confirmed = !useSettingsStore.getState().explicitSettings.includes('sendDelaySeconds') || await confirmDialog({
|
||||
title: t('email_composer.send_delay_unsupported_title'),
|
||||
message: t('email_composer.send_delay_unsupported_confirm'),
|
||||
confirmText: t('email_composer.send'),
|
||||
@@ -4886,7 +4916,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const mvInlineReply = mvShell && showComposer && !isEmbedded && !mvReplyPoppedOut
|
||||
&& (pendingDraft?.mode ?? composerMode) !== 'compose' && Boolean(selectedEmail);
|
||||
const floatingComposer = mvShell && showComposer && !isEmbedded && !mvInlineReply;
|
||||
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : 'pane';
|
||||
// Elsewhere on a computer a reply or forward docks under the message it
|
||||
// answers, which stays readable above it, as in Gmail and Outlook. Not
|
||||
// for a draft being edited: that message is the draft itself.
|
||||
// Decided as it opens and kept for the session: moving it would mount it
|
||||
// anew when the message goes away (another folder opened meanwhile).
|
||||
const dockCandidate = !mvShell && !isMobile && showComposer && !isEmbedded && !floatingComposer
|
||||
&& (pendingDraft?.mode ?? composerMode) !== 'compose';
|
||||
if (dockCandidate && selectedEmail && !selectedEmail.keywords?.$draft) dockedSessionRef.current = composerSessionId;
|
||||
const dockedReply = dockCandidate && dockedSessionRef.current === composerSessionId;
|
||||
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane';
|
||||
composerHostRef.current = composerHost;
|
||||
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
|
||||
|| Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
|
||||
@@ -4915,11 +4954,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
if (!client || !email) return new Error('the mail page is not ready');
|
||||
setCursorOnly(false);
|
||||
|
||||
// If composing, suspend the composer (unmount will trigger onSaveState).
|
||||
// Mountain View's compose window stays open while other mail is read,
|
||||
// as Gmail's does. (#1229)
|
||||
// If composing, close the composer the way its X does: it keeps the
|
||||
// draft and says so. Mountain View's compose window stays open while
|
||||
// other mail is read, as Gmail's does. (#1229)
|
||||
if (showComposer && !floatingComposer) {
|
||||
setShowComposer(false);
|
||||
const requestClose = composerRequestCloseRef.current;
|
||||
if (requestClose) requestClose();
|
||||
else setShowComposer(false);
|
||||
}
|
||||
|
||||
// Find the list-level email for metadata (accountId, scheduled flags, etc.).
|
||||
@@ -5267,6 +5308,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
setComposerPrefill(null);
|
||||
return outcome;
|
||||
}}
|
||||
onSendFailed={(state) => {
|
||||
setPendingDraft(state);
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
onScheduledSendCreated={async () => {
|
||||
if (client) {
|
||||
await refreshScheduledMetadata(client);
|
||||
@@ -5959,40 +6005,45 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
{/* Inline Composer - shown in viewer pane.
|
||||
In Pro/embedded mode the composer is hoisted into its own
|
||||
Pro tab (see the effect below), so we never render it inline. */}
|
||||
{(showComposer && !isEmbedded && !floatingComposer && !mvInlineReply) ? composerNode : (
|
||||
{(showComposer && !isEmbedded && !floatingComposer && !mvInlineReply && !dockedReply) ? composerNode : (
|
||||
<>
|
||||
{/* Pending draft banner */}
|
||||
{pendingDraft && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-start"
|
||||
>
|
||||
<PenSquare className="w-4 h-4 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
|
||||
{pendingDraft.subject && (
|
||||
<span className="text-xs text-muted-foreground ms-2 truncate">{pendingDraft.subject}</span>
|
||||
)}
|
||||
</div>
|
||||
<X
|
||||
className="w-4 h-4 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const confirmed = await confirmDialog({
|
||||
title: t('email_composer.discard_draft_title'),
|
||||
message: t('email_composer.discard_draft_confirm'),
|
||||
confirmText: t('email_composer.discard'),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (confirmed) {
|
||||
setPendingDraft(null);
|
||||
}
|
||||
{pendingDraft && !dockedReply && (
|
||||
<div className="flex items-center bg-primary/10 border-b border-primary/20" data-testid="continue-draft-bar">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
className="flex flex-1 min-w-0 items-center gap-3 ps-4 pe-2 py-2.5 hover:bg-primary/15 transition-colors cursor-pointer text-start"
|
||||
>
|
||||
<PenSquare className="w-4 h-4 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
|
||||
{pendingDraft.subject && (
|
||||
<span className="text-xs text-muted-foreground ms-2 truncate">{pendingDraft.subject}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{/* Its own button, so the keyboard reaches it. Deletes the saved
|
||||
draft too, with Undo instead of a question. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tComposeUx('discard_draft')}
|
||||
title={tComposeUx('discard_draft')}
|
||||
data-testid="continue-draft-discard"
|
||||
onClick={() => {
|
||||
const draft = pendingDraft;
|
||||
setPendingDraft(null);
|
||||
discardWithUndo(() => {
|
||||
if (draft.draftId) void handleDiscardDraft(draft.draftId, { accountId: draft.draftAccountId, localAccountId: draft.draftLocalAccountId });
|
||||
}, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setPendingDraft(draft));
|
||||
}}
|
||||
className="grid h-9 w-9 shrink-0 place-items-center me-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-primary/15 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile Conversation View - shown when thread is selected on mobile */}
|
||||
{isMobile && !conversationThread && deepLinkPane === 'thread' ? (
|
||||
@@ -6132,6 +6183,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
await handleConversationReplyAll(full ?? e);
|
||||
} : undefined}
|
||||
mvInlineComposer={mvInlineReply ? <MvComposeInline onPopOut={handleMvPopOut}>{composerNode}</MvComposeInline> : undefined}
|
||||
replyComposerOpen={dockedReply}
|
||||
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
|
||||
onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined}
|
||||
isFullscreen={fullscreenReading}
|
||||
@@ -6185,6 +6237,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
)}
|
||||
{dockedReply && (
|
||||
<div className="flex basis-[62%] min-h-[18rem] shrink-0 flex-col border-t border-border" data-reply-dock="">
|
||||
{composerNode}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-ch
|
||||
import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
|
||||
import { createComposeHandoff, rememberComposePayload, rememberedComposePayload } from '@/lib/popout/compose-handoff';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { emailHooks } from '@/lib/plugin-hooks';
|
||||
@@ -547,3 +548,90 @@ describe('after this page crashed', () => {
|
||||
expect(rememberedComposePayload('h2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a send held for undo', () => {
|
||||
const closeSelf = vi.fn();
|
||||
const MAILBOXES = [{ id: 'mDrafts', role: 'drafts' }, { id: 'mSent', role: 'sent' }];
|
||||
const startDraft = {
|
||||
id: 'd0', subject: 'Before sending', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }],
|
||||
keywords: { $draft: true }, mailboxIds: { mDrafts: true },
|
||||
bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [],
|
||||
};
|
||||
const restoredDraft = {
|
||||
id: 'e1', subject: 'Back again', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }],
|
||||
keywords: { $draft: true }, mailboxIds: { mDrafts: true },
|
||||
bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [],
|
||||
};
|
||||
let originalEmail: ReturnType<typeof useEmailStore.getState>;
|
||||
let sendAt: string;
|
||||
let cancelUndoSend: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
closeSelf.mockClear();
|
||||
originalEmail = useEmailStore.getState();
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore: vi.fn() } } as never;
|
||||
useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never);
|
||||
identityMocks.multi = { enabled: false, allIdentities: [], groups: [] };
|
||||
const client = { getEmail: vi.fn(async (id: string) => (id === 'e1' ? restoredDraft : startDraft)), getMailboxes: vi.fn(async () => MAILBOXES) };
|
||||
useAuthStore.setState({ client: client as never, getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) } as never);
|
||||
cancelUndoSend = vi.fn(async () => restoredDraft);
|
||||
useEmailStore.setState({
|
||||
sendEmail: vi.fn(async () => {
|
||||
useEmailStore.setState({ pendingUndoSend: { submissionId: 'sub1', emailId: 'e1', identityId: 'id-1', sendAt, isSmime: false } });
|
||||
return { scheduled: true, emailSubmissionId: 'sub1', emailId: 'e1', sendAt };
|
||||
}),
|
||||
cancelUndoSend,
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useEmailStore.setState(originalEmail);
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
type Sending = { onSend: (data: object) => Promise<unknown>; onClose: () => void };
|
||||
const DATA = { to: ['a@example.org'], cc: [], bcc: [], subject: 'Hi', body: 'Text', localAccountId: 'alice' };
|
||||
|
||||
/** Opens the window, sends from it and lets the composer close, as it does after a send. */
|
||||
async function sendFromWindow(seconds: number) {
|
||||
sendAt = new Date(Date.now() + seconds * 1000).toISOString();
|
||||
window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice');
|
||||
render(<PopoutComposeApp />);
|
||||
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
|
||||
const props = composer as unknown as Sending;
|
||||
await act(async () => { await props.onSend(DATA); });
|
||||
act(() => props.onClose());
|
||||
}
|
||||
|
||||
it('stays open with "Sending in" and Undo, and does not hand the undo to the main window', async () => {
|
||||
await sendFromWindow(5);
|
||||
expect(screen.getByTestId('popout-sending').textContent).toContain('sending_in');
|
||||
expect(screen.getByRole('button', { name: 'undo_send' })).toBeInTheDocument();
|
||||
expect(closeSelf).not.toHaveBeenCalled();
|
||||
const sent = heard.find((message) => message.type === 'sent') as Extract<PopoutMessage, { type: 'sent' }> | undefined;
|
||||
expect(sent?.notice.pendingUndoSend ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('closes once the undo time is over', async () => {
|
||||
await sendFromWindow(1.2);
|
||||
expect(closeSelf).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 });
|
||||
});
|
||||
|
||||
it('Undo takes the send back and opens the message again in this window', async () => {
|
||||
await sendFromWindow(30);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'undo_send' }));
|
||||
await waitFor(() => expect(cancelUndoSend).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ submissionId: 'sub1', localAccountId: 'alice' })));
|
||||
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Back again'));
|
||||
expect(closeSelf).not.toHaveBeenCalled();
|
||||
expect(new URLSearchParams(window.location.search).get('draft')).toBe('e1');
|
||||
});
|
||||
|
||||
it('a message scheduled for later still goes to the main window, and the window closes', async () => {
|
||||
await sendFromWindow(3600);
|
||||
expect(screen.queryByTestId('popout-sending')).toBeNull();
|
||||
expect(closeSelf).toHaveBeenCalled();
|
||||
const sent = heard.find((message) => message.type === 'sent') as Extract<PopoutMessage, { type: 'sent' }> | undefined;
|
||||
expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,9 +93,20 @@ afterEach(() => {
|
||||
|
||||
const answers = () => heard.filter((message) => message.type === 'session-answer');
|
||||
|
||||
describe('in an ordinary browser tab', () => {
|
||||
/** A browser tab: not installed, with or without a mouse. */
|
||||
function stubTab(fine: boolean) {
|
||||
delete window.bulwarkDesktop;
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({
|
||||
matches: query.includes('pointer: fine') ? fine : false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
window.matchMedia = globalThis.matchMedia;
|
||||
}
|
||||
|
||||
describe('in a browser tab on a phone or tablet', () => {
|
||||
it('runs nothing: no channel and no lock', async () => {
|
||||
delete window.bulwarkDesktop;
|
||||
stubTab(false);
|
||||
const opened = FakeBroadcastChannel.open.size;
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
@@ -107,6 +118,18 @@ describe('in an ordinary browser tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('in a browser tab on a computer', () => {
|
||||
it('lends its session to a composer popped out of it', async () => {
|
||||
stubTab(true);
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({
|
||||
activeAccountId: 'alice',
|
||||
sessions: [{ accountId: 'alice', authMode: 'oauth' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PopoutHost', () => {
|
||||
it('holds no lock while idle, which would keep the page out of the back/forward cache', async () => {
|
||||
render(<PopoutHost />);
|
||||
@@ -286,6 +309,17 @@ describe('PopoutHost', () => {
|
||||
expect(useToastStore.getState().toasts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('says once that a closed pop-out kept its draft, with Discard', async () => {
|
||||
render(<><PopoutHost /><PopoutHost /></>);
|
||||
await settle();
|
||||
popout.post({ type: 'draft-kept', requestId: 'k1', notice: { draftId: 'd1', localAccountId: 'alice' } });
|
||||
await settle();
|
||||
const toasts = useToastStore.getState().toasts;
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0].title).toBe('email_composer.draft_saved');
|
||||
expect(toasts[0].action?.label).toBe('email_composer.discard');
|
||||
});
|
||||
|
||||
it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
|
||||
@@ -13,8 +13,9 @@ import { toast } from "@/stores/toast-store";
|
||||
import { formatRejectedRecipients } from "@/lib/jmap/client";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
|
||||
import { announceSent } from "@/lib/plugin-after-send";
|
||||
import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type RescuedDraft } from "@/lib/popout/channel";
|
||||
import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend } from "@/lib/plugin-after-send";
|
||||
import { cancelFailureToast } from "@/lib/send-failure-message";
|
||||
import { announceDraftKept, announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type PopoutSentNotice, type RescuedDraft } from "@/lib/popout/channel";
|
||||
import { acknowledgeComposeHandoff, forgetComposePayload, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff";
|
||||
import type { ComposePopoutPayload } from "@/lib/popout/compose-payload";
|
||||
import { returnComposerToMain, type ReturnResult } from "@/lib/popout/return-to-main";
|
||||
@@ -30,6 +31,7 @@ import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { Send } from "@/components/icons";
|
||||
import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell";
|
||||
|
||||
/** A warning with no main window left to show it stays this long before the window goes. */
|
||||
@@ -49,6 +51,91 @@ const MAIN_CHECK_MS = 1000;
|
||||
/** How long a reloaded window waits to hear what became of the text it handed over on the way out. */
|
||||
const RESCUE_ANSWER_MS = 2000;
|
||||
|
||||
/** A send held this long or less is an undo window ("Sending in 5 s"), not a message scheduled for later. */
|
||||
const UNDO_WINDOW_MS = 120_000;
|
||||
/** Undo goes this long before the server lets the message go, as in the main window. */
|
||||
const UNDO_MARGIN_MS = 1000;
|
||||
/** What the plugins are told when the hold runs out gets this long before the window goes. */
|
||||
const RELEASE_WAIT_MS = 1000;
|
||||
|
||||
type HeldSend = NonNullable<PopoutSentNotice["pendingUndoSend"]>;
|
||||
|
||||
/** A draft to open again in this window, after Undo brought the message back to Drafts. */
|
||||
interface ReopenTarget {
|
||||
draftId: string;
|
||||
accountId: string;
|
||||
ownerAccountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A send held for undo, shown in the window it was sent from: that's where the
|
||||
* user is looking, and quick compose is used with the main window out of sight.
|
||||
* The window stays until the hold runs out, then goes.
|
||||
*/
|
||||
function SendingWithUndo({ held, onUndone, onGone }: {
|
||||
held: HeldSend;
|
||||
/** The send was taken back; the message, back in Drafts, when there is one to open. */
|
||||
onUndone: (restored: Email | null) => void;
|
||||
onGone: () => void;
|
||||
}) {
|
||||
const tViewer = useTranslations("email_viewer");
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const sendAt = Date.parse(held.sendAt);
|
||||
const over = !Number.isFinite(sendAt) || now >= sendAt - UNDO_MARGIN_MS;
|
||||
|
||||
useEffect(() => {
|
||||
if (over) return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 250);
|
||||
return () => clearInterval(timer);
|
||||
}, [over]);
|
||||
|
||||
// Too late to undo: the plugins hear of the send now, and the window goes.
|
||||
useEffect(() => {
|
||||
if (!over || busy) return;
|
||||
releaseAnnouncedSend(held.submissionId);
|
||||
const timer = setTimeout(onGone, RELEASE_WAIT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [over, busy, held.submissionId, onGone]);
|
||||
|
||||
const undo = async () => {
|
||||
const auth = useAuthStore.getState();
|
||||
// Never another login: the submission id names another submission there.
|
||||
const client = held.localAccountId ? auth.getClientForAccount(held.localAccountId) : auth.client;
|
||||
if (!client) {
|
||||
toast.error(tViewer("undo_send_signed_out"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
let restored: Email | null;
|
||||
try {
|
||||
restored = await useEmailStore.getState().cancelUndoSend(client, held);
|
||||
} catch (error) {
|
||||
debug.error("Failed to undo the send:", error);
|
||||
const { title, message } = cancelFailureToast(error, tViewer, tViewer("undo_send_failed"));
|
||||
toast.error(title, message ? { message } : undefined);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
// Never left the server: plugins don't hear about it.
|
||||
cancelAnnouncedSend(held.submissionId);
|
||||
onUndone(held.isSmime ? null : restored);
|
||||
};
|
||||
|
||||
const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000));
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending">
|
||||
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
<p role="status" className="text-sm text-foreground">{tViewer("sending_in", { seconds })}</p>
|
||||
{!over && (
|
||||
<Button onClick={() => void undo()} disabled={busy} data-testid="popout-undo-send">
|
||||
{tViewer("undo_send")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } {
|
||||
if (typeof window === "undefined") return { handoffId: null };
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
@@ -214,7 +301,12 @@ function NewMessageGate({ onReady }: { onReady: (payload: ComposePopoutPayload)
|
||||
}
|
||||
|
||||
/** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */
|
||||
function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) {
|
||||
function PopoutComposer({ payload, handoffId, onReopen }: {
|
||||
payload: ComposePopoutPayload;
|
||||
handoffId: string | null;
|
||||
/** Undo brought the message back to Drafts: open it again here. */
|
||||
onReopen: (target: ReopenTarget) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const tQuick = useTranslations("desktopQuickCompose");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
@@ -267,6 +359,16 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
|
||||
|
||||
const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId;
|
||||
const pending = useEmailStore.getState().pendingUndoSend;
|
||||
const withLogin = pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null;
|
||||
// An undo window stays here, where Send was pressed: the window shows it
|
||||
// and closes when it runs out. A message scheduled for later goes to the
|
||||
// main window as before.
|
||||
const remaining = withLogin ? Date.parse(withLogin.sendAt) - Date.now() : NaN;
|
||||
const keepHere = result.scheduled && withLogin && Number.isFinite(remaining) && remaining <= UNDO_WINDOW_MS ? withLogin : null;
|
||||
if (keepHere) {
|
||||
heldRef.current = keepHere;
|
||||
useEmailStore.setState({ pendingUndoSend: null });
|
||||
}
|
||||
const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined;
|
||||
const taken = await announceSentToMainWindow({
|
||||
accountId,
|
||||
@@ -274,7 +376,7 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
|
||||
queued: !!result.queued,
|
||||
filingError: !!result.filingError,
|
||||
rejectedRecipients,
|
||||
pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null,
|
||||
pendingUndoSend: keepHere ? null : withLogin,
|
||||
});
|
||||
// No main window to say so, and this is the only notice these get (#1123).
|
||||
if (!taken && (result.filingError || rejectedRecipients)) {
|
||||
@@ -301,11 +403,36 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
// A send held for undo, kept in this window until it runs out.
|
||||
const heldRef = useRef<HeldSend | null>(null);
|
||||
const [held, setHeld] = useState<HeldSend | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
announceMailChanged(useAuthStore.getState().activeAccountId);
|
||||
if (heldRef.current) {
|
||||
setHeld(heldRef.current);
|
||||
return;
|
||||
}
|
||||
closePopoutWindow();
|
||||
}, []);
|
||||
|
||||
const heldGone = useCallback(() => {
|
||||
announceMailChanged(useAuthStore.getState().activeAccountId);
|
||||
closePopoutWindow();
|
||||
}, []);
|
||||
|
||||
const heldUndone = useCallback((restored: Email | null) => {
|
||||
const sent = heldRef.current;
|
||||
heldRef.current = null;
|
||||
const login = sent?.localAccountId ?? useAuthStore.getState().activeAccountId;
|
||||
announceMailChanged(login);
|
||||
if (!restored || !login) {
|
||||
closePopoutWindow();
|
||||
return;
|
||||
}
|
||||
onReopen({ draftId: restored.id, accountId: login, ownerAccountId: sent?.submissionAccountId });
|
||||
}, [onReopen]);
|
||||
|
||||
const [mainWindow] = useState(watchMainWindow);
|
||||
useEffect(() => () => mainWindow.stop(), [mainWindow]);
|
||||
|
||||
@@ -357,6 +484,9 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
|
||||
onUnloadState: (state: ComposerDraftData) => {
|
||||
if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null });
|
||||
},
|
||||
onDraftKept: (draftId: string, where: { accountId?: string; localAccountId?: string }) => {
|
||||
announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined });
|
||||
},
|
||||
onDraftSaved: (draftId: string, where: { accountId?: string; localAccountId?: string } = {}) => {
|
||||
const active = useAuthStore.getState().activeAccountId;
|
||||
announceMailChanged(active);
|
||||
@@ -373,6 +503,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
|
||||
},
|
||||
}), [handoffId, mainWindow, payload]);
|
||||
|
||||
if (held) return <SendingWithUndo held={held} onUndone={heldUndone} onGone={heldGone} />;
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={ComposerErrorFallback}>
|
||||
<EmailComposer
|
||||
@@ -405,6 +537,26 @@ export function PopoutComposeApp() {
|
||||
// draft is the newest there is. An ordinary reload remembers as it unloads.
|
||||
const [handoffId] = useState(() => (named && draftId && !rememberedComposePayload(named) ? null : named));
|
||||
const [payload, setPayload] = useState<ComposePopoutPayload | null>(null);
|
||||
// After Undo: the message back in Drafts, opened again in a new composer here.
|
||||
const [reopen, setReopen] = useState<ReopenTarget | null>(null);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const handleReopen = useCallback((target: ReopenTarget) => {
|
||||
// A reload starts from the draft, as after a save.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("handoff");
|
||||
url.searchParams.set("draft", target.draftId);
|
||||
url.searchParams.set("account", target.accountId);
|
||||
if (target.ownerAccountId) url.searchParams.set("owner", target.ownerAccountId);
|
||||
else url.searchParams.delete("owner");
|
||||
window.history.replaceState(null, "", `${url.pathname}${url.search}`);
|
||||
setPayload(null);
|
||||
setReopen(target);
|
||||
}, []);
|
||||
const reopened = useCallback((next: ComposePopoutPayload) => {
|
||||
setReopen(null);
|
||||
setGeneration((n) => n + 1);
|
||||
setPayload(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!handoffId) return;
|
||||
@@ -438,7 +590,8 @@ export function PopoutComposeApp() {
|
||||
|
||||
return (
|
||||
<PopoutShell kind="compose" accountId={accountId}>
|
||||
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} />
|
||||
{payload ? <PopoutComposer key={generation} payload={payload} handoffId={generation === 0 ? handoffId : null} onReopen={handleReopen} />
|
||||
: reopen ? <RestoredDraft draftId={reopen.draftId} accountId={reopen.accountId} ownerAccountId={reopen.ownerAccountId} onReady={reopened} />
|
||||
: handoffId ? <PopoutSkeleton label={t("opening")} />
|
||||
: draftId ? <RestoredDraft draftId={draftId} accountId={accountId} ownerAccountId={ownerAccountId} onReady={setPayload} />
|
||||
: <NewMessageGate onReady={setPayload} />}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { showDraftSavedToast } from "@/lib/compose/draft-close-toast";
|
||||
import { newPopoutId, openPopoutChannel, type PopoutChannel, type PopoutMessage, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel";
|
||||
import { takeOverMoved } from "@/lib/popout/moved-undo";
|
||||
import { keepRescuedDraft } from "@/lib/popout/rescued-drafts";
|
||||
@@ -61,8 +62,9 @@ function claimOnce(name: string, work: () => void | Promise<void>): void {
|
||||
/**
|
||||
* The main window's side of pop-outs: lends them its session and renews it
|
||||
* for them, passes on that mail changed, and takes over what a pop-out can't
|
||||
* say any more once it has closed. Only where pop-outs are on offer: an
|
||||
* ordinary browser tab runs none of this.
|
||||
* say any more once it has closed. Only where pop-outs are on offer: a
|
||||
* browser tab on a computer runs it for composers popped out of it, one on a
|
||||
* phone or tablet runs none of this.
|
||||
*/
|
||||
export function PopoutHost() {
|
||||
const available = usePopoutsAvailable();
|
||||
@@ -176,6 +178,28 @@ export function PopoutHost() {
|
||||
case "draft-rescue":
|
||||
startRescue(message.draft);
|
||||
break;
|
||||
case "draft-kept": {
|
||||
// Said here, as the pop-out closes with its Escape or X.
|
||||
const { draftId, accountId, localAccountId } = message.notice;
|
||||
claimOnce(`bulwark-popout-kept:${message.requestId}`, () => {
|
||||
const tr = tRef.current;
|
||||
showDraftSavedToast(() => {
|
||||
const auth = useAuthStore.getState();
|
||||
const login = localAccountId ? auth.getClientForAccount(localAccountId) : auth.client;
|
||||
if (!login) {
|
||||
toast.error(tr("email_composer.discard_signed_out"));
|
||||
return;
|
||||
}
|
||||
void login.deleteEmail(draftId, accountId).catch((error: unknown) => console.error("Failed to discard draft:", error));
|
||||
}, {
|
||||
saved: tr("email_composer.draft_saved"),
|
||||
discard: tr("email_composer.discard"),
|
||||
discarded: tr("compose_ux.draft_discarded"),
|
||||
undo: tr("compose_ux.undo"),
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "rescue-query": {
|
||||
const rescue = rescues.get(message.rescueKey);
|
||||
if (rescue) void rescue.then((draftId) => channel?.post({ type: "rescue-answer", rescueKey: message.rescueKey, draftId }));
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock('../settings-section', async (importOriginal) => ({
|
||||
|
||||
const ROWS = {
|
||||
plainTextMode: 'plain_text_mode.label',
|
||||
autoSelectReplyIdentity: 'auto_select_reply_identity.label',
|
||||
autoSelectReplyIdentity: 'reply_from_received.label',
|
||||
replyIdentityMatch: 'reply_identity_match.label',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ComposingSettings } from '../composing-settings';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import en from '@/locales/en/common.json';
|
||||
|
||||
// "Reply from the address it was sent to" explained catch-all matching,
|
||||
// which belongs to the row below it. The row now says what it does and
|
||||
// when it helps.
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
vi.mock('../settings-section', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../settings-section')>()),
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
describe('ComposingSettings wording', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.getState().resetToDefaults();
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
it('describes replying from the received address without the catch-all note', () => {
|
||||
render(<ComposingSettings />);
|
||||
expect(screen.getByText('reply_from_received.description')).toBeTruthy();
|
||||
expect(screen.queryByText('auto_select_reply_identity.description')).toBeNull();
|
||||
expect(en.compose_words.reply_from_received.description).not.toMatch(/catch-all|Received Address Matching/i);
|
||||
});
|
||||
|
||||
it('calls the send delay "Undo send"', () => {
|
||||
render(<ComposingSettings />);
|
||||
expect(screen.getByText('undo_send.label')).toBeTruthy();
|
||||
expect(screen.queryByText('send_delay.label')).toBeNull();
|
||||
expect(en.compose_words.undo_send.label).toBe('Undo send');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useSettingsStore, undoSendOptions } from '@/stores/settings-store';
|
||||
import type { MentionAddsRecipientTo, ReplyIdentityMatch, SendDelaySeconds } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
@@ -21,6 +21,7 @@ import { X, ChevronRight } from '@/components/icons';
|
||||
export function ComposingSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const tPage = useTranslations('settings.composing_page');
|
||||
const tWords = useTranslations('compose_words');
|
||||
const templatesEnabled = usePolicyStore((s) => s.isFeatureEnabled('templatesEnabled'));
|
||||
// The admin panel can lock or hide these three (restriction-keys.ts).
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
@@ -102,7 +103,7 @@ export function ComposingSettings() {
|
||||
</SettingItem>
|
||||
|
||||
{!isSettingHidden('autoSelectReplyIdentity') && (
|
||||
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')} locked={isSettingLocked('autoSelectReplyIdentity')}>
|
||||
<SettingItem label={tWords('reply_from_received.label')} description={tWords('reply_from_received.description')} locked={isSettingLocked('autoSelectReplyIdentity')}>
|
||||
<ToggleSwitch
|
||||
checked={autoSelectReplyIdentity}
|
||||
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
|
||||
@@ -157,17 +158,15 @@ export function ComposingSettings() {
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="sending" title={tPage('sections.sending')}>
|
||||
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
|
||||
<SettingItem label={tWords('undo_send.label')} description={tWords('undo_send.description')}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Select
|
||||
value={String(sendDelaySeconds)}
|
||||
onChange={(value) => updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)}
|
||||
options={[
|
||||
{ value: '0', label: t('send_delay.off') },
|
||||
{ value: '10', label: t('send_delay.seconds', { seconds: 10 }) },
|
||||
{ value: '30', label: t('send_delay.seconds', { seconds: 30 }) },
|
||||
{ value: '60', label: t('send_delay.seconds', { seconds: 60 }) },
|
||||
]}
|
||||
options={undoSendOptions(sendDelaySeconds).map((seconds) => ({
|
||||
value: String(seconds),
|
||||
label: seconds === 0 ? t('send_delay.off') : t('send_delay.seconds', { seconds }),
|
||||
}))}
|
||||
/>
|
||||
{sendDelaySeconds > 0 && !delayedSendSupported && (
|
||||
<p className="max-w-64 text-end text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TemplatePicker } from '../template-picker';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { resolvePolicy } from '@/lib/admin/policy-scopes';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
|
||||
// compose-18: a template saved from the composer showed its name twice
|
||||
// ("Meeting follow-up / Meeting follow-up") under the heading "Other", with
|
||||
// no other heading for it to stand apart from. The next-intl mock returns keys.
|
||||
|
||||
const own = (id: string, name: string, extra: Partial<EmailTemplate> = {}): EmailTemplate => ({
|
||||
id, name, subject: name, body: `${name} body`, category: '', isFavorite: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', ...extra,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const subject = { email: 'alice@example.org', groups: [] };
|
||||
usePolicyStore.setState({ rawPolicy: DEFAULT_POLICY, policy: resolvePolicy(DEFAULT_POLICY, subject).policy, subject });
|
||||
useTemplateStore.setState({ templates: [own('t1', 'Meeting follow-up')], recentTemplateIds: [], deletedTemplateIds: {} });
|
||||
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
|
||||
});
|
||||
|
||||
describe('the template picker', () => {
|
||||
it('shows a name once, and no "Other" heading without categories', () => {
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
|
||||
|
||||
expect(screen.getAllByText('Meeting follow-up')).toHaveLength(1);
|
||||
expect(screen.queryByText('section_uncategorized')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the subject when it says more than the name', () => {
|
||||
useTemplateStore.setState({ templates: [own('t1', 'Follow-up', { subject: 'Thanks for meeting today' })] });
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
|
||||
|
||||
expect(screen.getByText('Thanks for meeting today')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps "Other" apart from the categories', () => {
|
||||
useTemplateStore.setState({ templates: [own('t1', 'Follow-up'), own('t2', 'Invoice', { category: 'Billing' })] });
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
|
||||
|
||||
expect(screen.getByText('Billing', { selector: 'h3' })).toBeInTheDocument();
|
||||
expect(screen.getByText('section_uncategorized')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { validateTemplateName } from '@/lib/template-utils';
|
||||
|
||||
interface QuickSaveTemplateDialogProps {
|
||||
/** The name to start with: the message's subject. */
|
||||
defaultName: string;
|
||||
onSave: (name: string) => void;
|
||||
/** "Edit details…": the full template editor, with the name typed so far. */
|
||||
onEditDetails: (name: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Save as template" from the composer: a name and Save. The full editor
|
||||
* (category, recipients, sending identity, favourite) is one click further,
|
||||
* behind "Edit details…"; it used to open straight away with nine fields.
|
||||
*/
|
||||
export function QuickSaveTemplateDialog({ defaultName, onSave, onEditDetails, onClose }: QuickSaveTemplateDialogProps) {
|
||||
const tComposer = useTranslations('email_composer');
|
||||
const tSend = useTranslations('compose_send');
|
||||
const tSettings = useTranslations('settings.templates');
|
||||
const tCommon = useTranslations('common');
|
||||
const fieldId = useId();
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const save = () => {
|
||||
const problem = validateTemplateName(name);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
onSave(name.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={tComposer('save_as_template')}
|
||||
size="sm"
|
||||
dirty={name !== defaultName}
|
||||
testId="quick-save-template"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="ghost" className="me-auto" onClick={() => onEditDetails(name)}>
|
||||
{tSend('template_edit_details')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onClose}>{tCommon('cancel')}</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>{tCommon('save')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="mt-4">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-foreground">{tSend('template_name')}</label>
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
className={cn('mt-1', error && 'border-destructive')}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${fieldId}-error` : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${fieldId}-error`} className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{tSettings(`validation.${error}`)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import type { Editor } from '@tiptap/react';
|
||||
interface TemplateFormProps {
|
||||
template?: EmailTemplate;
|
||||
initialData?: {
|
||||
/** A name typed before the full editor opened (Save as template). */
|
||||
name?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
/** The body is the composer's HTML, not plain text (#1506). */
|
||||
@@ -57,7 +59,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
|
||||
const browserList = useTemplateStore((s) => s.templates);
|
||||
const templates = categorySource ?? browserList;
|
||||
|
||||
const [name, setName] = useState(template?.name || '');
|
||||
const [name, setName] = useState(template?.name || initialData?.name || '');
|
||||
const [category, setCategory] = useState(template?.category || '');
|
||||
const [subject, setSubject] = useState(template?.subject || initialData?.subject || '');
|
||||
const [body, setBody] = useState(template?.body || initialData?.body || '');
|
||||
|
||||
@@ -114,7 +114,8 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{template.subject && (
|
||||
{/* Not the name a second time ("Meeting follow-up / Meeting follow-up"). */}
|
||||
{template.subject && template.subject.trim() !== template.name.trim() && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">
|
||||
{template.subject}
|
||||
</p>
|
||||
@@ -131,13 +132,15 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
|
||||
</button>
|
||||
);
|
||||
|
||||
const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource) => {
|
||||
const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource, withHeading = true) => {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="mb-4" key={`${source.key}:${title}`}>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground mb-1 px-3">
|
||||
{title}
|
||||
</h3>
|
||||
{withHeading && (
|
||||
<h3 className="text-xs font-semibold text-muted-foreground mb-1 px-3">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
<div className="space-y-0.5">{items.map((item) => renderTemplateItem(item, source))}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -171,7 +174,9 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
|
||||
{Object.entries(byCategory)
|
||||
.filter(([cat]) => cat !== '')
|
||||
.map(([cat, items]) => renderSection(cat, items, source))}
|
||||
{renderSection(t('section_uncategorized'), byCategory[''] ?? [], source)}
|
||||
{/* "Other" only sets them apart from favourites, recent or categories. */}
|
||||
{renderSection(t('section_uncategorized'), byCategory[''] ?? [], source,
|
||||
favorites.length > 0 || recent.length > 0 || Object.keys(byCategory).some((cat) => cat !== ''))}
|
||||
</>
|
||||
);
|
||||
if (!withHeading) return <div key={source.key}>{body}</div>;
|
||||
|
||||
@@ -208,6 +208,25 @@ describe('toasts and the focused element', () => {
|
||||
expect(toastContainer()).not.toHaveAttribute('data-dodging');
|
||||
});
|
||||
|
||||
// With the focus in the composer's text or the quick reply box, "Email
|
||||
// sent" and "Sending in 5 seconds" moved to the top and covered the
|
||||
// composer's close button and the reader's toolbar. A large field the
|
||||
// stack only overlaps at a corner keeps the toasts at the bottom.
|
||||
it('stays at the bottom over a corner of a large focused field', () => {
|
||||
const h = window.innerHeight;
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.hasAttribute('data-toast-container')) return rect(h - 100, 600, 360, 80);
|
||||
if (this.id === 'body') return rect(h - 400, 300, 700, 360);
|
||||
return rect(10, 10, 80, 36);
|
||||
});
|
||||
render(<><Toasts /><textarea id="body" /></>);
|
||||
act(() => { (document.getElementById('body') as HTMLElement).focus(); });
|
||||
act(() => { toast.success('Email sent'); });
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(toastContainer()).not.toHaveAttribute('data-dodging');
|
||||
expect(toastContainer()).not.toHaveClass('top-5');
|
||||
});
|
||||
|
||||
// On a phone the top of the screen is the header with Back and the message
|
||||
// actions; an Undo toast that moved there blocked them. (#1472)
|
||||
it('stays at the bottom on a phone, clear of the header', () => {
|
||||
|
||||
+13
-5
@@ -361,10 +361,14 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the toast stack, in its usual place at the bottom, overlaps the
|
||||
* element that has the focus. The stack's own box is measured where it is
|
||||
* now; while it sits at the top, its bottom place is worked out from the gap
|
||||
* it left there (data-bottom-gap).
|
||||
* True when the toast stack, in its usual place at the bottom, hides at
|
||||
* least half of the element that has the focus: a button or a field. A
|
||||
* large element it only overlaps at a corner (the composer's text, the
|
||||
* quick reply box) stays usable, and moving the stack to the top for it put
|
||||
* the toasts over the composer's close button and the reader's toolbar. The
|
||||
* stack's own box is measured where it is now; while it sits at the top,
|
||||
* its bottom place is worked out from the gap it left there
|
||||
* (data-bottom-gap).
|
||||
*/
|
||||
export function stackWouldCoverFocus(stack: HTMLElement | null): boolean {
|
||||
if (!stack) return false;
|
||||
@@ -382,7 +386,11 @@ export function stackWouldCoverFocus(stack: HTMLElement | null): boolean {
|
||||
stack.dataset.bottomGap = String(Math.max(0, window.innerHeight - box.bottom));
|
||||
}
|
||||
const f = focused.getBoundingClientRect();
|
||||
return f.right > box.left && f.left < box.right && f.bottom > top && f.top < bottom;
|
||||
const overlapWidth = Math.min(f.right, box.right) - Math.max(f.left, box.left);
|
||||
const overlapHeight = Math.min(f.bottom, bottom) - Math.max(f.top, top);
|
||||
if (overlapWidth <= 0 || overlapHeight <= 0) return false;
|
||||
const area = Math.max(f.width, 0) * Math.max(f.height, 0);
|
||||
return area === 0 || overlapWidth * overlapHeight >= area / 2;
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
window.addEventListener("online", onChange);
|
||||
window.addEventListener("offline", onChange);
|
||||
return () => {
|
||||
window.removeEventListener("online", onChange);
|
||||
window.removeEventListener("offline", onChange);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the browser says it has a network, kept up to date. Also without
|
||||
* offline mode, whose store only follows it while that runs. True on the
|
||||
* server and while the page hydrates.
|
||||
*/
|
||||
export function useBrowserOnline(): boolean {
|
||||
return useSyncExternalStore(subscribe, () => navigator.onLine !== false, () => true);
|
||||
}
|
||||
@@ -7,27 +7,30 @@ import { resolveEmailBlobContext } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { openMessageWindow } from "@/lib/popout/open";
|
||||
import { popoutsAvailable } from "@/lib/popout/support";
|
||||
import { composerPopoutsAvailable, popoutsAvailable } from "@/lib/popout/support";
|
||||
import { isPopoutWindow } from "@/lib/popout/window-role";
|
||||
|
||||
/**
|
||||
* Whether to offer "Open in new window" here. False on the server and in the
|
||||
* first render, so the markup hydrates the same; false in the demo, whose
|
||||
* client has no session to share, and inside a pop-out (for a message: it is
|
||||
* already in its own window).
|
||||
* Whether to offer "Open in new window" here: for a composer, the default,
|
||||
* also in a browser tab on a computer; for a message only in an installed or
|
||||
* the desktop app. False on the server and in the first render, so the
|
||||
* markup hydrates the same; false in the demo, whose client has no session to
|
||||
* share, and inside a pop-out (for a message: it is already in its own window).
|
||||
* The main window's side of pop-outs (PopoutHost) runs wherever a composer can
|
||||
* move out.
|
||||
*/
|
||||
export function usePopoutsAvailable(): boolean {
|
||||
export function usePopoutsAvailable(kind: "compose" | "message" = "compose"): boolean {
|
||||
const isDemoMode = useAuthStore((s) => s.isDemoMode);
|
||||
const [available, setAvailable] = useState(false);
|
||||
useEffect(() => {
|
||||
const check = () => setAvailable(popoutsAvailable() && !isPopoutWindow());
|
||||
const check = () => setAvailable((kind === "message" ? popoutsAvailable() : composerPopoutsAvailable()) && !isPopoutWindow());
|
||||
check();
|
||||
// Installing the running tab turns it into a window without a reload.
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const media = window.matchMedia("(display-mode: standalone)");
|
||||
media.addEventListener?.("change", check);
|
||||
return () => media.removeEventListener?.("change", check);
|
||||
}, []);
|
||||
}, [kind]);
|
||||
return available && !isDemoMode;
|
||||
}
|
||||
|
||||
@@ -37,7 +40,7 @@ export function usePopoutsAvailable(): boolean {
|
||||
* blocks the window; nothing else changes then.
|
||||
*/
|
||||
export function useOpenEmailInWindow(): ((email: Email) => void) | null {
|
||||
const available = usePopoutsAvailable();
|
||||
const available = usePopoutsAvailable("message");
|
||||
const t = useTranslations("pwaWindows");
|
||||
const open = useCallback((email: Email) => {
|
||||
const auth = useAuthStore.getState();
|
||||
|
||||
@@ -31,6 +31,22 @@ describe('findUnfilledPlaceholders', () => {
|
||||
expect(findUnfilledPlaceholders('', 'Treffpunkt: [ÜBERGABEORT]')).toEqual(['[ÜBERGABEORT]']);
|
||||
});
|
||||
|
||||
// A template's "Hi [Name]" went out unchanged: only capitals and
|
||||
// "your/insert" prompts counted.
|
||||
it('finds capitalised words in brackets that a template left to fill in', () => {
|
||||
expect(findUnfilledPlaceholders('', ['Hi [Name],', '[Step] is done. Regards, [First Name] at [Company]. [Date]'].join('\n'), EN)).toEqual([
|
||||
'[Name]',
|
||||
'[Step]',
|
||||
'[First Name]',
|
||||
'[Company]',
|
||||
'[Date]',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves longer bracketed phrases and links alone', () => {
|
||||
expect(findUnfilledPlaceholders('', 'See [Chapter Four Part Two] and [Name](https://example.com).', EN)).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds fill-ins that ask the sender to write something', () => {
|
||||
expect(findUnfilledPlaceholders('', 'Best regards,\n[Your Name]\n[Insert date]', EN)).toEqual([
|
||||
'[Your Name]',
|
||||
@@ -94,7 +110,7 @@ describe('findUnfilledPlaceholders', () => {
|
||||
|
||||
it('skips word lists that are not lists of strings', () => {
|
||||
const broken = [{ before: 'your' }, { before: [1, '', '-', null] }, null, { after: {} }] as unknown as PlaceholderWords[];
|
||||
expect(findUnfilledPlaceholders('', '[Your Name] [TIME]', broken)).toEqual(['[TIME]']);
|
||||
expect(findUnfilledPlaceholders('', '[Your name] [TIME]', broken)).toEqual(['[TIME]']);
|
||||
});
|
||||
|
||||
it('finds the circles that stand in for a name in Japanese and Korean', () => {
|
||||
@@ -104,7 +120,7 @@ describe('findUnfilledPlaceholders', () => {
|
||||
it('leaves bracketed words and headings in other languages alone', () => {
|
||||
const text = [
|
||||
'【重要】お知らせ 【通知】',
|
||||
'[Votre] [Ваше] [您的]',
|
||||
'[votre] [ваше] [您的]',
|
||||
'Le [ton] juste. Vote [tu].',
|
||||
'Siehe [Anlage 2] und [Kapitel 3].',
|
||||
'Termin: ○ und ◯.',
|
||||
|
||||
@@ -57,8 +57,8 @@ describe('api.app.searchSettings', () => {
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].pageId).toBe('composing');
|
||||
expect(results[0].page).toBe('Verfassen');
|
||||
const de = (await import('@/locales/de/common.json')).default as unknown as { settings: { email_behavior: { send_delay: { label: string } } } };
|
||||
expect(results.map((r) => r.label)).toContain(de.settings.email_behavior.send_delay.label);
|
||||
const de = (await import('@/locales/de/common.json')).default as unknown as { compose_words: { undo_send: { label: string } } };
|
||||
expect(results.map((r) => r.label)).toContain(de.compose_words.undo_send.label);
|
||||
});
|
||||
|
||||
it('leaves out pages the server does not offer', async () => {
|
||||
|
||||
@@ -132,6 +132,53 @@ describe('popoutsAvailable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerPopoutsAvailable', () => {
|
||||
async function available() {
|
||||
const { composerPopoutsAvailable } = await import('@/lib/popout/support');
|
||||
return composerPopoutsAvailable();
|
||||
}
|
||||
|
||||
it('is on in a browser tab on a computer: a composer opens as a popup window, as in Gmail', async () => {
|
||||
stubDisplay({ standalone: false });
|
||||
expect(await available()).toBe(true);
|
||||
});
|
||||
|
||||
it('is off in a browser tab on a phone or tablet, where window.open makes a tab', async () => {
|
||||
stubDisplay({ standalone: false, fine: false });
|
||||
expect(await available()).toBe(false);
|
||||
});
|
||||
|
||||
it('is on wherever a message can move out too', async () => {
|
||||
stubDisplay();
|
||||
expect(await available()).toBe(true);
|
||||
stubDisplay({ standalone: false, fine: false });
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} };
|
||||
expect(await available()).toBe(true);
|
||||
});
|
||||
|
||||
it('is off in an installed app on a touch device', async () => {
|
||||
stubDisplay({ fine: false });
|
||||
expect(await available()).toBe(false);
|
||||
});
|
||||
|
||||
it('is off in a tab without what keeps a shared session safe', async () => {
|
||||
stubDisplay({ standalone: false });
|
||||
vi.stubGlobal('BroadcastChannel', undefined);
|
||||
expect(await available()).toBe(false);
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
|
||||
vi.stubGlobal('crypto', {});
|
||||
expect(await available()).toBe(false);
|
||||
});
|
||||
|
||||
it('lets the hook offer a composer its own window from a tab, and still not a message', async () => {
|
||||
stubDisplay({ standalone: false });
|
||||
const { renderHook } = await import('@testing-library/react');
|
||||
const { usePopoutsAvailable } = await import('@/hooks/use-popouts-available');
|
||||
expect(renderHook(() => usePopoutsAvailable()).result.current).toBe(true);
|
||||
expect(renderHook(() => usePopoutsAvailable('message')).result.current).toBe(false);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('opening windows', () => {
|
||||
it('opens a message as a popup in the app, with its login and account', async () => {
|
||||
const { openMessageWindow } = await import('@/lib/popout/open');
|
||||
|
||||
@@ -90,3 +90,35 @@ describe('buildQuoteHeader (#482 — sender address survives HTML rendering)', (
|
||||
expect(h.text).not.toContain('<noname@x.tld>');
|
||||
});
|
||||
});
|
||||
|
||||
// Gmail and Outlook list who the forwarded mail went to.
|
||||
describe('buildQuoteHeader forward recipients', () => {
|
||||
const email = {
|
||||
from: [sender],
|
||||
to: [{ name: 'Anna Becker', email: 'anna@example.com' }, { email: 'team@example.com' }],
|
||||
cc: [{ name: 'Carol <x>', email: 'carol@example.com' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
it('adds To and Cc lines after the subject', async () => {
|
||||
const h = await buildQuoteHeader({ mode: 'forward', email, ...base });
|
||||
expect(h.text).toContain('Subject: Hello\nTo: Anna Becker <anna@example.com>, team@example.com\nCc: Carol <x> <carol@example.com>\n');
|
||||
expect(h.html).toContain('To: Anna Becker <anna@example.com>, team@example.com<br>Cc: Carol <x> <carol@example.com><br>');
|
||||
});
|
||||
|
||||
it('uses the caller\'s To and Cc labels', async () => {
|
||||
const labels = {
|
||||
formatReplyLine: () => '', forwardedSeparator: '--', fromLabel: 'Von', dateLabel: 'Datum', subjectLabel: 'Betreff',
|
||||
toLabel: 'An', ccLabel: 'Kopie',
|
||||
};
|
||||
const h = await buildQuoteHeader({ mode: 'forward', email, labels, ...base });
|
||||
expect(h.text).toContain('\nAn: Anna Becker <anna@example.com>, team@example.com\nKopie: Carol <x> <carol@example.com>\n');
|
||||
});
|
||||
|
||||
it('leaves out a line for an empty list', async () => {
|
||||
const h = await buildQuoteHeader({ mode: 'forward', email: { ...email, cc: [] }, ...base });
|
||||
expect(h.text).toContain('\nTo: ');
|
||||
expect(h.text).not.toContain('Cc:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('settings search index (English messages)', () => {
|
||||
expect(labelsFor('privacy')).toContain('Sender favicons');
|
||||
expect(labelsFor('message_list')).toContain('Clear search when switching folders');
|
||||
expect(labelsFor('privacy')).toContain('Trusted senders');
|
||||
expect(labelsFor('composing')).toContain('Undo send / send delay');
|
||||
expect(labelsFor('composing')).toContain('Undo send');
|
||||
expect(labelsFor('general')).toContain('Time zone');
|
||||
expect(labelsFor('organize')).toContain('Nested tags');
|
||||
});
|
||||
@@ -83,7 +83,7 @@ describe('settings search index (English messages)', () => {
|
||||
it('finds the newer signature settings on their tabs', () => {
|
||||
expect(labelsFor('composing')).toContain('Signature on new mail');
|
||||
expect(labelsFor('composing')).toContain('Signature on replies & forwards');
|
||||
expect(labelsFor('composing')).toContain('Undo send / send delay');
|
||||
expect(labelsFor('composing')).toContain('Undo send');
|
||||
});
|
||||
|
||||
it('marks the rows the Advanced switch hides', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatShortcutCombo, shortcutAlternatives } from '../shortcut-keys';
|
||||
import { GENERAL_SHORTCUTS, SHORTCUT_APPS, sectionsWithGeneral, shortcutAppForPath } from '../keyboard-shortcuts';
|
||||
import { MAIL_SHORTCUTS } from '../keyboard-shortcuts/mail';
|
||||
|
||||
describe('shortcut notation', () => {
|
||||
it('writes Mod as ⌘ on Apple devices and Ctrl elsewhere, Alt as ⌥', () => {
|
||||
@@ -37,3 +38,12 @@ describe('shortcut sections', () => {
|
||||
expect(empty).toEqual([expect.objectContaining({ id: 'global', entries: GENERAL_SHORTCUTS })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the composer section of the shortcut list', () => {
|
||||
// compose-19: "t" types a t while writing, so it isn't a composer key.
|
||||
it('lists the editing keys of other mail apps and no single letter', () => {
|
||||
const keys = MAIL_SHORTCUTS.composer.map((entry) => entry.key);
|
||||
expect(keys).toEqual(expect.arrayContaining(['Mod + S', 'Mod + K', 'Mod + Shift + C', 'Mod + Shift + B']));
|
||||
expect(keys.filter((key) => /^[a-z]$/i.test(key))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { attachmentsForDisplay } from '../attachment-order';
|
||||
|
||||
describe('the order of the composer attachment chips', () => {
|
||||
it('puts failed, then uploading files first, and keeps the rest in the order added', () => {
|
||||
const files = [
|
||||
{ name: 'a.pdf' },
|
||||
{ name: 'b.pdf', uploading: true },
|
||||
{ name: 'c.pdf' },
|
||||
{ name: 'big.zip', uploading: true },
|
||||
{ name: 'broken.png', error: 'failed' },
|
||||
];
|
||||
const shown = attachmentsForDisplay(files);
|
||||
expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'b.pdf', 'big.zip', 'a.pdf', 'c.pdf']);
|
||||
// Each keeps its place in the composer's list, for Remove.
|
||||
expect(shown.map(({ index }) => index)).toEqual([4, 1, 3, 0, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { discardWithUndo, showDraftSavedToast } from '../draft-close-toast';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
|
||||
const labels = { saved: 'Draft saved', discard: 'Discard', discarded: 'Draft discarded', undo: 'Undo' };
|
||||
const shown = () => useToastStore.getState().toasts;
|
||||
|
||||
describe('closing a composer that kept its draft', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
|
||||
it('says "Draft saved" and offers Discard', () => {
|
||||
const discard = vi.fn();
|
||||
showDraftSavedToast(discard, labels);
|
||||
expect(shown()).toHaveLength(1);
|
||||
expect(shown()[0].title).toBe('Draft saved');
|
||||
expect(shown()[0].action?.label).toBe('Discard');
|
||||
expect(discard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes only once the Undo window is over', () => {
|
||||
const discard = vi.fn();
|
||||
showDraftSavedToast(discard, labels);
|
||||
shown()[0].action!.onClick();
|
||||
const undoToast = shown().find((t) => t.title === 'Draft discarded')!;
|
||||
expect(undoToast.action?.label).toBe('Undo');
|
||||
expect(undoToast.pauseOnHover).toBe(false);
|
||||
expect(discard).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(undoToast.duration!);
|
||||
expect(discard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the draft on Undo and puts back what was taken away', () => {
|
||||
const discard = vi.fn();
|
||||
const restore = vi.fn();
|
||||
discardWithUndo(discard, labels, restore);
|
||||
shown()[0].action!.onClick();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(discard).not.toHaveBeenCalled();
|
||||
expect(restore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deletes at once when the page goes away during the Undo window', () => {
|
||||
const discard = vi.fn();
|
||||
discardWithUndo(discard, labels);
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
expect(discard).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(discard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The composer's attachment chips in the order they're shown: failed first,
|
||||
* then still uploading, then the rest, each group in the order added. The
|
||||
* list scrolls past about two rows, so the files that need an eye on them
|
||||
* are the ones always in view. `index` is the place in the composer's list.
|
||||
*/
|
||||
export function attachmentsForDisplay<T extends { error?: unknown; uploading?: boolean }>(
|
||||
attachments: readonly T[],
|
||||
): Array<{ att: T; index: number }> {
|
||||
const rank = (att: T) => (att.error ? 0 : att.uploading ? 1 : 2);
|
||||
return attachments
|
||||
.map((att, index) => ({ att, index }))
|
||||
.sort((a, b) => rank(a.att) - rank(b.att) || a.index - b.index);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { toast, useToastStore } from "@/stores/toast-store";
|
||||
import { UNDO_TOAST_DURATION } from "@/lib/email-action-toast";
|
||||
|
||||
export interface DiscardToastLabels {
|
||||
discarded: string;
|
||||
undo: string;
|
||||
}
|
||||
|
||||
export interface DraftSavedToastLabels extends DiscardToastLabels {
|
||||
saved: string;
|
||||
discard: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Draft discarded" with Undo. `discard` runs once the toast is gone without
|
||||
* an Undo (or the page goes away), so Undo needs nothing from the server:
|
||||
* the draft is still there. `restore` puts back what the caller took off
|
||||
* screen.
|
||||
*/
|
||||
export function discardWithUndo(discard: () => void, labels: DiscardToastLabels, restore?: () => void): void {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const commit = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
window.removeEventListener("pagehide", commit);
|
||||
discard();
|
||||
};
|
||||
const id = toast.success(labels.discarded, {
|
||||
action: {
|
||||
label: labels.undo,
|
||||
onClick: () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
window.removeEventListener("pagehide", commit);
|
||||
restore?.();
|
||||
},
|
||||
},
|
||||
duration: UNDO_TOAST_DURATION,
|
||||
// The toast and the delete end together: a pause on hover would leave
|
||||
// an Undo on screen for a draft that is already gone.
|
||||
pauseOnHover: false,
|
||||
});
|
||||
const shown = useToastStore.getState().toasts.find((t) => t.id === id)?.duration;
|
||||
timer = setTimeout(commit, shown && shown > 0 ? shown : UNDO_TOAST_DURATION);
|
||||
window.addEventListener("pagehide", commit);
|
||||
}
|
||||
|
||||
/** After a composer closed and kept its draft: "Draft saved", with Discard. */
|
||||
export function showDraftSavedToast(discard: () => void, labels: DraftSavedToastLabels): void {
|
||||
toast.success(labels.saved, {
|
||||
action: { label: labels.discard, onClick: () => discardWithUndo(discard, labels) },
|
||||
});
|
||||
}
|
||||
@@ -39,7 +39,11 @@ export const MAIL_SHORTCUTS = {
|
||||
composer: [
|
||||
{ key: "Mod + Enter", description: "shortcuts.composer.send" },
|
||||
{ key: "Mod + Shift + Enter", description: "shortcuts.composer.schedule_send" },
|
||||
{ key: "t", description: "shortcuts.composer.template_picker" },
|
||||
// "t" opens templates only outside a text field, so it isn't listed: in the composer it types a t.
|
||||
{ key: "Mod + S", description: "compose_ux.shortcut_save_draft" },
|
||||
{ key: "Mod + K", description: "compose_ux.shortcut_insert_link" },
|
||||
{ key: "Mod + Shift + C", description: "compose_ux.shortcut_show_cc" },
|
||||
{ key: "Mod + Shift + B", description: "compose_ux.shortcut_show_bcc" },
|
||||
// Proofreading (components/email/proofreading); shown even without a checker.
|
||||
{ key: "Mod + '", description: "proofreading.shortcuts.next_issue" },
|
||||
{ key: "Mod + ;", description: "proofreading.shortcuts.previous_issue" },
|
||||
|
||||
@@ -29,9 +29,7 @@ export interface QuickReplyEnvelope {
|
||||
export function quickReplyEnvelope(email: ReplySource, identities: Identity[]): QuickReplyEnvelope {
|
||||
const ownIdentityEmails = identities.map((i) => i.email).filter(Boolean);
|
||||
const replySource = { from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc };
|
||||
const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
|
||||
.map((r) => r.email)
|
||||
.filter((address): address is string => Boolean(address));
|
||||
const recipients = quickReplyRecipients(email, identities).map((r) => r.email);
|
||||
const primaryIdentity = identities[0];
|
||||
const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
|
||||
? findDraftIdentityId(identities, email.from?.[0])
|
||||
@@ -46,6 +44,18 @@ export function quickReplyEnvelope(email: ReplySource, identities: Identity[]):
|
||||
return { recipients, sendingIdentity, primaryIdentity };
|
||||
}
|
||||
|
||||
/**
|
||||
* The people a quick reply goes to, with their names, by the same rules as
|
||||
* quickReplyEnvelope: for the box's "Reply to Ben Carter…" placeholder.
|
||||
*/
|
||||
export function quickReplyRecipients(email: ReplySource, identities: Identity[]): { name?: string; email: string }[] {
|
||||
const ownIdentityEmails = identities.map((i) => i.email).filter(Boolean);
|
||||
const replySource = { from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc };
|
||||
return buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
|
||||
.filter((r): r is typeof r & { email: string } => Boolean(r.email))
|
||||
.map((r) => ({ name: r.name || undefined, email: r.email }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the text typed into a quick reply as a draft reply, before an
|
||||
* automatic sign-out (lib/compose-flush-registry.ts). Plain text, threaded
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The toast of a send from the moment the composer closes: "Sending…" while
|
||||
* the server takes the message, then "Sent" with View. The composer used to
|
||||
* stay on screen, greyed and without a word, until the server answered.
|
||||
*/
|
||||
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { announce } from '@/lib/announce';
|
||||
import { effectiveToastDuration } from '@/lib/toast-duration';
|
||||
|
||||
export interface SendProgressLabels {
|
||||
sending: string;
|
||||
sent: string;
|
||||
view: string;
|
||||
}
|
||||
|
||||
export interface SendProgress {
|
||||
/** The message is out. `view` opens it; without it there is no View. */
|
||||
sent(view?: () => void): void;
|
||||
/** Another toast says what happened (undo, scheduled, failed, kept offline). */
|
||||
dismiss(): void;
|
||||
}
|
||||
|
||||
export function startSendProgress(labels: SendProgressLabels): SendProgress {
|
||||
const id = toast.progress(labels.sending, { progress: null });
|
||||
let settled = false;
|
||||
return {
|
||||
sent(view) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const action = view ? { label: labels.view, onClick: view } : undefined;
|
||||
toast.update(id, {
|
||||
type: 'success',
|
||||
title: labels.sent,
|
||||
progress: undefined,
|
||||
action,
|
||||
duration: effectiveToastDuration({ type: 'success', action }),
|
||||
});
|
||||
// An update isn't read out as a new toast is.
|
||||
announce(labels.sent);
|
||||
},
|
||||
dismiss() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
toast.dismiss(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,11 @@ const TEMPLATE_PLACEHOLDER = /\{\{\s*[\w.-]+\s*\}\}/g;
|
||||
// Markdown link.
|
||||
const CAPS_BRACKET = /\[[\p{Lu}\d][\p{Lu}\d _./-]{0,28}[\p{Lu}\d]\](?!\()/gu;
|
||||
|
||||
// [Name], [Company], [First Name]: one to three capitalised words, the way
|
||||
// templates name a fill-in. A lowercase word ([Yours truly]) or a number
|
||||
// ([Anlage 2]) makes it ordinary text.
|
||||
const TITLE_BRACKET = /\[\p{Lu}[\p{Ll}'’-]+(?: \p{Lu}[\p{Ll}'’-]+){0,2}\](?!\()/gu;
|
||||
|
||||
/**
|
||||
* The words that mark a fill-in in one language. They live in each locale
|
||||
* file under email_composer.placeholder_words, so a new translation brings
|
||||
@@ -113,7 +118,7 @@ function collect(text: string, patterns: RegExp[]): Match[] {
|
||||
* to check (see PlaceholderWords).
|
||||
*/
|
||||
export function findUnfilledPlaceholders(subject: string, bodyText: string, words: PlaceholderWords[] = []): string[] {
|
||||
const bodyPatterns = [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, CIRCLE_MARKER];
|
||||
const bodyPatterns = [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, TITLE_BRACKET, CIRCLE_MARKER];
|
||||
const prompt = promptBracketPattern(words);
|
||||
const unfinished = unfinishedWordPattern(words);
|
||||
if (prompt) bodyPatterns.push(prompt);
|
||||
|
||||
@@ -105,6 +105,7 @@ export type PopoutMessage =
|
||||
| { type: 'moved'; requestId: string; notice: PopoutMovedNotice }
|
||||
| { type: 'moved-ack'; requestId: string }
|
||||
| { type: 'mail-changed'; accountId: string | null }
|
||||
| { type: 'draft-kept'; requestId: string; notice: DraftKeptNotice }
|
||||
| { type: 'state-change' }
|
||||
| { type: 'draft-rescue'; draft: RescuedDraft }
|
||||
| { type: 'rescue-query'; rescueKey: string }
|
||||
@@ -119,6 +120,15 @@ export type PopoutMessage =
|
||||
| { type: 'main-gone'; windowId: string }
|
||||
| { type: 'account-removed'; accountId: string };
|
||||
|
||||
/** A pop-out closed and kept its draft: the main window says so, with Discard. */
|
||||
export interface DraftKeptNotice {
|
||||
draftId: string;
|
||||
/** The account it lives in, when not the login's own. */
|
||||
accountId?: string;
|
||||
/** The login it was saved through. */
|
||||
localAccountId?: string;
|
||||
}
|
||||
|
||||
export interface PopoutChannel {
|
||||
post(message: PopoutMessage): void;
|
||||
close(): void;
|
||||
@@ -246,6 +256,13 @@ export function announceMailChanged(accountId: string | null): void {
|
||||
channel?.close();
|
||||
}
|
||||
|
||||
/** Tells the main window that a closing pop-out kept its draft. */
|
||||
export function announceDraftKept(notice: DraftKeptNotice): void {
|
||||
const channel = openPopoutChannel(() => {});
|
||||
channel?.post({ type: 'draft-kept', requestId: newId(), notice });
|
||||
channel?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft id a closing window's text was saved under, for the same window
|
||||
* after a reload, whose own copy still names the version that replaced. Null
|
||||
|
||||
+26
-5
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Whether "Open in new window" is on offer: an installed app or the desktop
|
||||
* app, on a device with windows. In a browser tab, a tab does the same job
|
||||
* and the app behaves as it always has.
|
||||
* for a message, and the app behaves as it always has. A composer is the
|
||||
* exception: it moves to a window of its own from a tab too, as in Gmail.
|
||||
*/
|
||||
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
@@ -13,16 +14,36 @@ export function hasDesktopWindows(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.bulwarkDesktop?.windows;
|
||||
}
|
||||
|
||||
/** Display mode is `browser` in Electron popups too, hence the bridge check. */
|
||||
export function popoutsAvailable(): boolean {
|
||||
/** A second window can share this one's session, safely. */
|
||||
function windowsCanShareSessions(): boolean {
|
||||
// Lite has no such route: a static export can't list every message id.
|
||||
if (IS_LITE || typeof window === 'undefined') return false;
|
||||
// Sharing the session and renewing tokens safely across windows need these.
|
||||
if (typeof BroadcastChannel === 'undefined' || !navigator.locks || !canSealSessions()) return false;
|
||||
return typeof BroadcastChannel !== 'undefined' && !!navigator.locks && canSealSessions();
|
||||
}
|
||||
|
||||
/** A device with a mouse or trackpad: a phone or tablet has no second window. */
|
||||
function hasFinePointer(): boolean {
|
||||
return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches;
|
||||
}
|
||||
|
||||
/** Display mode is `browser` in Electron popups too, hence the bridge check. */
|
||||
export function popoutsAvailable(): boolean {
|
||||
if (!windowsCanShareSessions()) return false;
|
||||
if (hasDesktopWindows()) return true;
|
||||
if (!isStandaloneDisplayMode()) return false;
|
||||
// A home-screen app on a phone or tablet has no second window: window.open
|
||||
// leaves for the browser there.
|
||||
if ((window.navigator as Navigator & { standalone?: boolean }).standalone === true) return false;
|
||||
return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches;
|
||||
return hasFinePointer();
|
||||
}
|
||||
|
||||
/**
|
||||
* A composer can move to a window of its own: wherever a message can, and in
|
||||
* a browser tab on a computer too, where it opens as a popup window.
|
||||
*/
|
||||
export function composerPopoutsAvailable(): boolean {
|
||||
if (popoutsAvailable()) return true;
|
||||
if (!windowsCanShareSessions() || isStandaloneDisplayMode()) return false;
|
||||
return hasFinePointer();
|
||||
}
|
||||
|
||||
+38
-2
@@ -20,6 +20,36 @@ export interface QuoteHeaderLabels {
|
||||
fromLabel: string;
|
||||
dateLabel: string;
|
||||
subjectLabel: string;
|
||||
/** The forward header's To and Cc lines; left out when not given. */
|
||||
toLabel?: string;
|
||||
ccLabel?: string;
|
||||
}
|
||||
|
||||
type HeaderAddress = { email?: string; name?: string };
|
||||
|
||||
/** "Name <address>, address", as the forward header's From line writes one. */
|
||||
function formatAddressList(list: HeaderAddress[] | undefined): string {
|
||||
return (list ?? [])
|
||||
.filter((r) => r.email)
|
||||
.map((r) => (r.name && r.name !== r.email ? `${r.name} <${r.email}>` : r.email as string))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The To and Cc lines of a forward header, as Gmail and Outlook write them:
|
||||
* one per list that has anyone on it, for a label the caller gave. Values
|
||||
* are plain text; escape them for HTML.
|
||||
*/
|
||||
export function forwardRecipientLines(
|
||||
email: { to?: HeaderAddress[]; cc?: HeaderAddress[] },
|
||||
labels: { toLabel?: string; ccLabel?: string },
|
||||
): { label: string; value: string }[] {
|
||||
const lines: { label: string; value: string }[] = [];
|
||||
const to = formatAddressList(email.to);
|
||||
const cc = formatAddressList(email.cc);
|
||||
if (labels.toLabel && to) lines.push({ label: labels.toLabel, value: to });
|
||||
if (labels.ccLabel && cc) lines.push({ label: labels.ccLabel, value: cc });
|
||||
return lines;
|
||||
}
|
||||
|
||||
interface BuildArgs {
|
||||
@@ -49,6 +79,8 @@ const DEFAULT_LABELS: QuoteHeaderLabels = {
|
||||
fromLabel: "From",
|
||||
dateLabel: "Date",
|
||||
subjectLabel: "Subject",
|
||||
toLabel: "To",
|
||||
ccLabel: "Cc",
|
||||
};
|
||||
|
||||
function defaultHeader(args: BuildArgs): QuoteHeader {
|
||||
@@ -73,12 +105,16 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
|
||||
const subject = email.subject || "";
|
||||
|
||||
if (mode === "forward") {
|
||||
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`;
|
||||
const recipients = forwardRecipientLines(email, labels);
|
||||
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`
|
||||
+ recipients.map((line) => `${line.label}: ${line.value}\n`).join("");
|
||||
// Escape the interpolated values for the HTML variant: the sender string is
|
||||
// "Name <email>", and the unescaped "<email>" would be parsed as an HTML tag
|
||||
// by the rich-text composer and silently dropped (#482). Subject/name are
|
||||
// likewise user-controlled. Label/separator strings are trusted i18n text.
|
||||
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${escapeHtml(fromStrFull)}<br>${labels.dateLabel}: ${escapeHtml(date)}<br>${labels.subjectLabel}: ${escapeHtml(subject)}<br><br></div>`;
|
||||
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${escapeHtml(fromStrFull)}<br>${labels.dateLabel}: ${escapeHtml(date)}<br>${labels.subjectLabel}: ${escapeHtml(subject)}<br>`
|
||||
+ recipients.map((line) => `${line.label}: ${escapeHtml(line.value)}<br>`).join("")
|
||||
+ `<br></div>`;
|
||||
return { html, text, wrapInBlockquote: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ const ENTRIES: SettingSchemaEntry[] = [
|
||||
opts(`${EB}mention_adds_recipient.`, ['to', 'cc', 'off'])),
|
||||
bool('rtlEditingSupport', 'composing', `${EB}rtl_editing.label`),
|
||||
bool('quickReplyEnabled', 'composing', `${EB}quick_reply.label`),
|
||||
bool('autoSelectReplyIdentity', 'composing', `${EB}auto_select_reply_identity.label`),
|
||||
bool('autoSelectReplyIdentity', 'composing', 'compose_words.reply_from_received.label'),
|
||||
// The page shows it only while the composer may rewrite From.
|
||||
choice('replyIdentityMatch', 'composing', `${EB}reply_identity_match.label`,
|
||||
opts(`${EB}reply_identity_match.`, ['exact', 'domain']), { gate: 'customFromEnabled' }),
|
||||
@@ -280,12 +280,12 @@ const ENTRIES: SettingSchemaEntry[] = [
|
||||
bool('signatureNewMailEnabled', 'composing', `${EB}signature_new_mail.label`),
|
||||
bool('signatureReplyForwardEnabled', 'composing', `${EB}signature_reply_forward.label`),
|
||||
bool('signatureSeparatorEnabled', 'composing', `${EB}signature_separator.label`),
|
||||
// 10/30/60 read "{seconds} seconds" (send_delay.seconds): formatted.
|
||||
choice('sendDelaySeconds', 'composing', `${EB}send_delay.label`, [
|
||||
// 5/10/30 read "{seconds} seconds" (send_delay.seconds): formatted.
|
||||
choice('sendDelaySeconds', 'composing', 'compose_words.undo_send.label', [
|
||||
{ value: 0, label: `${EB}send_delay.off` },
|
||||
{ value: 5, label: null },
|
||||
{ value: 10, label: null },
|
||||
{ value: 30, label: null },
|
||||
{ value: 60, label: null },
|
||||
]),
|
||||
bool('sendConfirmation', 'accessibility', `${A}confirm_send.label`),
|
||||
bool('attachmentReminderEnabled', 'composing', `${EB}attachment_reminder.label`),
|
||||
|
||||
@@ -239,13 +239,13 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
|
||||
{ label: 'proofreading.settings.writing_language' },
|
||||
{ label: 'settings.composing_page.sections.replying' },
|
||||
{ label: 'settings.email_behavior.quick_reply.label' },
|
||||
{ label: 'settings.email_behavior.auto_select_reply_identity.label' },
|
||||
{ label: 'compose_words.reply_from_received.label' },
|
||||
{ label: 'settings.email_behavior.signature_position.label' },
|
||||
{ label: 'settings.email_behavior.signature_new_mail.label' },
|
||||
{ label: 'settings.email_behavior.signature_reply_forward.label' },
|
||||
{ label: 'settings.email_behavior.signature_separator.label', advanced: true },
|
||||
{ label: 'settings.composing_page.sections.sending' },
|
||||
{ label: 'settings.email_behavior.send_delay.label' },
|
||||
{ label: 'compose_words.undo_send.label' },
|
||||
{ label: 'settings.email_behavior.attachment_reminder.label' },
|
||||
{ label: 'settings.email_behavior.empty_subject_warning.label' },
|
||||
{ label: 'settings.email_behavior.placeholder_warning.label' },
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[صورة: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "تم تجاهل المسودة",
|
||||
"undo": "تراجع",
|
||||
"discard_draft": "تجاهل المسودة",
|
||||
"save_failed_close": "تعذّر حفظ المسودة. حاول مرة أخرى أو تجاهلها.",
|
||||
"try_again": "حاول مرة أخرى",
|
||||
"add_description": "إضافة وصف",
|
||||
"attachments_summary": "المرفقات: {count}، {size}",
|
||||
"from_sub_address": "استخدام عنوان فرعي…",
|
||||
"from_edit_sender": "تعديل المرسل…",
|
||||
"shortcut_insert_link": "إدراج رابط",
|
||||
"shortcut_save_draft": "حفظ المسودة",
|
||||
"shortcut_show_cc": "إظهار نسخة",
|
||||
"shortcut_show_bcc": "إظهار نسخة مخفية"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "تأكيد",
|
||||
"cancel": "إلغاء"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "العودة إلى التعديل",
|
||||
"send_anyway": "الإرسال على أي حال",
|
||||
"warnings_title": "هل تريد إرسال هذه الرسالة على أي حال؟",
|
||||
"warning_subject": "بلا موضوع",
|
||||
"warning_text": "بلا نص",
|
||||
"warning_attachment": "تذكر \"{keyword}\" لكن لا يوجد ملف مرفق",
|
||||
"warning_placeholders": "نص مؤقت سيراه المستلمون:",
|
||||
"warning_proofreading": "مشكلات إملائية أو نحوية مفتوحة: {count}",
|
||||
"empty_text_title": "هل تريد الإرسال بدون نص؟",
|
||||
"empty_text_message": "لا تحتوي هذه الرسالة على نص ولا على مرفق. هل تريد إرسالها على أي حال؟",
|
||||
"scheduled_for": "مجدولة في {when}",
|
||||
"sending": "جارٍ الإرسال…",
|
||||
"sent": "تم الإرسال",
|
||||
"view": "عرض",
|
||||
"picker_button": "إضافة من جهات الاتصال",
|
||||
"picker_title": "إضافة مستلمين",
|
||||
"tab_contacts": "جهات الاتصال",
|
||||
"tab_directory": "الدليل",
|
||||
"contacts_empty_title": "لا توجد جهات اتصال بعد",
|
||||
"contacts_empty_hint": "يظهر هنا الأشخاص الذين تحفظهم في جهات الاتصال.",
|
||||
"contacts_no_match": "لا توجد جهة اتصال تطابق بحثك",
|
||||
"template_name": "اسم القالب",
|
||||
"template_edit_details": "تعديل التفاصيل…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "قوالبي",
|
||||
"section_organisation": "المؤسسة",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "لم تُرسل. هناك فحص تشترطه مؤسستك لا يعمل حاليًا. حاول مرة أخرى بعد قليل.",
|
||||
"stopped_working": "لم تُرسل. توقف فحص تشترطه مؤسستك عن العمل. أعد تحميل الصفحة. إذا لم يساعد ذلك، أبلغ المسؤول."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "التراجع عن الإرسال",
|
||||
"description": "يمنحك بضع ثوانٍ لاسترجاع الرسالة بعد الضغط على إرسال."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "الرد من العنوان المستلَم إليه",
|
||||
"description": "الرد من العنوان الذي أُرسلت إليه الرسالة. مفيد إذا كانت لديك عدة عناوين."
|
||||
},
|
||||
"require_tls": "التسليم عبر اتصال آمن فقط",
|
||||
"require_tls_hint": "الرسالة نفسها غير مشفّرة. تُرَد إذا لم يتمكن خادم من استخدام TLS.",
|
||||
"encrypt": "تشفير…",
|
||||
"encrypt_hint": "يتطلب إضافة S/MIME أو PGP. يفتح الإعدادات.",
|
||||
"quick_reply_to": "الرد على {names}…",
|
||||
"open_full_editor": "فتح المحرر الكامل",
|
||||
"forward_to_label": "إلى",
|
||||
"forward_cc_label": "نسخة"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "المرشحات",
|
||||
"remove_filter": "إزالة المرشح {label}"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.",
|
||||
"reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "مزيد من التنسيق",
|
||||
"offline": "دون اتصال",
|
||||
"offline_hint": "يحتاج الإرسال إلى اتصال.",
|
||||
"offline_hint_outbox": "ما ترسله الآن ينتظر في صندوق الصادر ويُرسل عند عودة الاتصال.",
|
||||
"offline_send_saved": "أنت دون اتصال. الرسالة محفوظة في المسودات.",
|
||||
"offline_send_unsaved": "أنت دون اتصال. تبقى الرسالة هنا وتُحفظ في المسودات عند عودة الاتصال."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "استكشاف الأخطاء وإصلاحها",
|
||||
"label": "أدوات المطورين ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[Imatge: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "Esborrany descartat",
|
||||
"undo": "Desfés",
|
||||
"discard_draft": "Descarta l'esborrany",
|
||||
"save_failed_close": "No s'ha pogut desar l'esborrany. Torneu-ho a provar o descarteu-lo.",
|
||||
"try_again": "Torna-ho a provar",
|
||||
"add_description": "Afegeix una descripció",
|
||||
"attachments_summary": "Adjunts: {count}, {size}",
|
||||
"from_sub_address": "Fes servir una subadreça…",
|
||||
"from_edit_sender": "Edita el remitent…",
|
||||
"shortcut_insert_link": "Insereix un enllaç",
|
||||
"shortcut_save_draft": "Desa l'esborrany",
|
||||
"shortcut_show_cc": "Mostra Cc",
|
||||
"shortcut_show_bcc": "Mostra Cco"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirma",
|
||||
"cancel": "Cancel·la"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Torna a l'edició",
|
||||
"send_anyway": "Envia igualment",
|
||||
"warnings_title": "Vols enviar aquest missatge igualment?",
|
||||
"warning_subject": "Sense assumpte",
|
||||
"warning_text": "Sense text",
|
||||
"warning_attachment": "Esmenta «{keyword}», però no hi ha cap fitxer adjunt",
|
||||
"warning_placeholders": "Text de mostra que veuran els destinataris:",
|
||||
"warning_proofreading": "Errors d'ortografia o gramàtica pendents: {count}",
|
||||
"empty_text_title": "Vols enviar-lo sense text?",
|
||||
"empty_text_message": "Aquest missatge no té text ni cap fitxer adjunt. Vols enviar-lo igualment?",
|
||||
"scheduled_for": "Programat per a {when}",
|
||||
"sending": "S'està enviant…",
|
||||
"sent": "Enviat",
|
||||
"view": "Mostra",
|
||||
"picker_button": "Afegeix des dels contactes",
|
||||
"picker_title": "Afegeix destinataris",
|
||||
"tab_contacts": "Contactes",
|
||||
"tab_directory": "Directori",
|
||||
"contacts_empty_title": "Encara no hi ha contactes",
|
||||
"contacts_empty_hint": "Aquí apareixen les persones que deses a Contactes.",
|
||||
"contacts_no_match": "Cap contacte coincideix amb la cerca",
|
||||
"template_name": "Nom de la plantilla",
|
||||
"template_edit_details": "Edita els detalls…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Les meves plantilles",
|
||||
"section_organisation": "Organització",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "No s'ha enviat. Una comprovació que exigeix la vostra organització no s'està executant. Torneu-ho a provar d'aquí a un moment.",
|
||||
"stopped_working": "No s'ha enviat. Una comprovació que exigeix la vostra organització ha deixat de funcionar. Torneu a carregar la pàgina. Si no ho soluciona, aviseu l'administrador."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "Desfés l'enviament",
|
||||
"description": "Us dona uns segons per recuperar un missatge després de prémer Envia."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "Respon des de l'adreça de recepció",
|
||||
"description": "Respon des de l'adreça a la qual es va enviar el correu. Útil si teniu diverses adreces."
|
||||
},
|
||||
"require_tls": "Lliura només per una connexió segura",
|
||||
"require_tls_hint": "El missatge en si no està xifrat. Es retorna si un servidor no pot fer servir TLS.",
|
||||
"encrypt": "Xifra…",
|
||||
"encrypt_hint": "Cal un complement S/MIME o PGP. Obre la configuració.",
|
||||
"quick_reply_to": "Respon a {names}…",
|
||||
"open_full_editor": "Obre l'editor complet",
|
||||
"forward_to_label": "Per a",
|
||||
"forward_cc_label": "CC"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filtres",
|
||||
"remove_filter": "Treu el filtre {label}"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "Aquestes tecles escriuen un caràcter al teu teclat, i deixaria de funcionar a totes les aplicacions. Prova altres tecles.",
|
||||
"reserved": "El sistema o la majoria d'aplicacions fan servir aquestes tecles. Prova altres tecles."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "Més format",
|
||||
"offline": "Sense connexió",
|
||||
"offline_hint": "Per enviar cal connexió.",
|
||||
"offline_hint_outbox": "El que envieu ara espera a la safata de sortida i s'envia quan torneu a tenir connexió.",
|
||||
"offline_send_saved": "No teniu connexió. El missatge està desat a Esborranys.",
|
||||
"offline_send_unsaved": "No teniu connexió. El missatge es queda aquí i es desa a Esborranys quan torneu a tenir connexió."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "Resolució de problemes",
|
||||
"label": "Eines per a desenvolupadors ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[Obrázek: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "Koncept zahozen",
|
||||
"undo": "Zpět",
|
||||
"discard_draft": "Zahodit koncept",
|
||||
"save_failed_close": "Koncept se nepodařilo uložit. Zkuste to znovu, nebo ho zahoďte.",
|
||||
"try_again": "Zkusit znovu",
|
||||
"add_description": "Přidat popis",
|
||||
"attachments_summary": "Přílohy: {count}, {size}",
|
||||
"from_sub_address": "Použít podadresu…",
|
||||
"from_edit_sender": "Upravit odesílatele…",
|
||||
"shortcut_insert_link": "Vložit odkaz",
|
||||
"shortcut_save_draft": "Uložit koncept",
|
||||
"shortcut_show_cc": "Zobrazit kopii",
|
||||
"shortcut_show_bcc": "Zobrazit skrytou kopii"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Potvrdit",
|
||||
"cancel": "Zrušit"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Zpět k úpravám",
|
||||
"send_anyway": "Přesto odeslat",
|
||||
"warnings_title": "Přesto tuto zprávu odeslat?",
|
||||
"warning_subject": "Bez předmětu",
|
||||
"warning_text": "Bez textu",
|
||||
"warning_attachment": "Zmiňuje „{keyword}“, ale není připojen žádný soubor",
|
||||
"warning_placeholders": "Zástupný text, který příjemci uvidí:",
|
||||
"warning_proofreading": "Nevyřešené pravopisné či gramatické chyby: {count}",
|
||||
"empty_text_title": "Odeslat bez textu?",
|
||||
"empty_text_message": "Tato zpráva nemá text ani přílohu. Přesto ji odeslat?",
|
||||
"scheduled_for": "Naplánováno na {when}",
|
||||
"sending": "Odesílání…",
|
||||
"sent": "Odesláno",
|
||||
"view": "Zobrazit",
|
||||
"picker_button": "Přidat z kontaktů",
|
||||
"picker_title": "Přidat příjemce",
|
||||
"tab_contacts": "Kontakty",
|
||||
"tab_directory": "Adresář organizace",
|
||||
"contacts_empty_title": "Zatím žádné kontakty",
|
||||
"contacts_empty_hint": "Zde se zobrazí lidé, které uložíte do Kontaktů.",
|
||||
"contacts_no_match": "Hledání neodpovídá žádný kontakt",
|
||||
"template_name": "Název šablony",
|
||||
"template_edit_details": "Upravit podrobnosti…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Moje šablony",
|
||||
"section_organisation": "Organizace",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "Neodesláno. Kontrola, kterou vaše organizace vyžaduje, právě neběží. Zkuste to za chvíli znovu.",
|
||||
"stopped_working": "Neodesláno. Kontrola, kterou vaše organizace vyžaduje, přestala fungovat. Načtěte stránku znovu. Pokud to nepomůže, obraťte se na správce."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "Vrátit odeslání",
|
||||
"description": "Dává vám několik sekund na to, abyste zprávu po stisknutí Odeslat vzali zpět."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "Automaticky vybírat adresu pro odpověď",
|
||||
"description": "Odpovídat z adresy, na kterou zpráva přišla. Hodí se, pokud máte více adres."
|
||||
},
|
||||
"require_tls": "Doručit jen přes zabezpečené spojení",
|
||||
"require_tls_hint": "Samotná zpráva šifrovaná není. Vrátí se, pokud server nemůže použít TLS.",
|
||||
"encrypt": "Šifrovat…",
|
||||
"encrypt_hint": "Vyžaduje doplněk S/MIME nebo PGP. Otevře Nastavení.",
|
||||
"quick_reply_to": "Odpověď pro {names}…",
|
||||
"open_full_editor": "Otevřít v plném editoru",
|
||||
"forward_to_label": "Komu",
|
||||
"forward_cc_label": "Kopie"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filtry",
|
||||
"remove_filter": "Odebrat filtr {label}"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "Tyto klávesy na vaší klávesnici píší znak, který by pak nefungoval v žádné aplikaci. Zkuste jiné klávesy.",
|
||||
"reserved": "Tyto klávesy používá systém nebo většina aplikací. Zkuste jiné klávesy."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "Další formátování",
|
||||
"offline": "Offline",
|
||||
"offline_hint": "Odeslání potřebuje připojení.",
|
||||
"offline_hint_outbox": "Co teď odešlete, počká mezi zprávami k odeslání a odejde, až budete znovu online.",
|
||||
"offline_send_saved": "Jste offline. Zpráva je uložená v Konceptech.",
|
||||
"offline_send_unsaved": "Jste offline. Zpráva tu zůstane a uloží se do Konceptů, až budete znovu online."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "Řešení potíží",
|
||||
"label": "Nástroje pro vývojáře ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[Billede: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "Kladde kasseret",
|
||||
"undo": "Fortryd",
|
||||
"discard_draft": "Kassér kladde",
|
||||
"save_failed_close": "Kladden kunne ikke gemmes. Prøv igen, eller kassér den.",
|
||||
"try_again": "Prøv igen",
|
||||
"add_description": "Tilføj beskrivelse",
|
||||
"attachments_summary": "Vedhæftede filer: {count}, {size}",
|
||||
"from_sub_address": "Brug en underadresse…",
|
||||
"from_edit_sender": "Rediger afsender…",
|
||||
"shortcut_insert_link": "Indsæt link",
|
||||
"shortcut_save_draft": "Gem kladde",
|
||||
"shortcut_show_cc": "Vis Cc",
|
||||
"shortcut_show_bcc": "Vis Bcc"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bekræft",
|
||||
"cancel": "Annuller"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Tilbage til redigering",
|
||||
"send_anyway": "Send alligevel",
|
||||
"warnings_title": "Send denne besked alligevel?",
|
||||
"warning_subject": "Intet emne",
|
||||
"warning_text": "Ingen tekst",
|
||||
"warning_attachment": "Nævner \"{keyword}\", men ingen fil er vedhæftet",
|
||||
"warning_placeholders": "Pladsholdertekst, som modtagerne vil se:",
|
||||
"warning_proofreading": "Åbne stave- eller grammatikfejl: {count}",
|
||||
"empty_text_title": "Send uden tekst?",
|
||||
"empty_text_message": "Denne besked har ingen tekst og ingen vedhæftet fil. Send den alligevel?",
|
||||
"scheduled_for": "Planlagt til {when}",
|
||||
"sending": "Sender…",
|
||||
"sent": "Sendt",
|
||||
"view": "Vis",
|
||||
"picker_button": "Tilføj fra kontakter",
|
||||
"picker_title": "Tilføj modtagere",
|
||||
"tab_contacts": "Kontakter",
|
||||
"tab_directory": "Adressefortegnelse",
|
||||
"contacts_empty_title": "Ingen kontakter endnu",
|
||||
"contacts_empty_hint": "Personer, du gemmer i Kontakter, vises her.",
|
||||
"contacts_no_match": "Ingen kontakter matcher din søgning",
|
||||
"template_name": "Skabelonens navn",
|
||||
"template_edit_details": "Rediger detaljer…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mine skabeloner",
|
||||
"section_organisation": "Organisation",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "Ikke sendt. En kontrol, som din organisation kræver, kører ikke. Prøv igen om et øjeblik.",
|
||||
"stopped_working": "Ikke sendt. En kontrol, som din organisation kræver, virker ikke længere. Genindlæs siden. Hvis det ikke hjælper, så kontakt din administrator."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "Fortryd afsendelse",
|
||||
"description": "Giver dig et par sekunder til at tage en besked tilbage, efter du har trykket på Send."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "Svar fra modtaget adresse",
|
||||
"description": "Svar fra den adresse, mailen blev sendt til. Nyttigt, hvis du har flere adresser."
|
||||
},
|
||||
"require_tls": "Lever kun via en sikker forbindelse",
|
||||
"require_tls_hint": "Selve beskeden er ikke krypteret. Den sendes retur, hvis en server ikke kan bruge TLS.",
|
||||
"encrypt": "Krypter…",
|
||||
"encrypt_hint": "Kræver en S/MIME- eller PGP-tilføjelse. Åbner Indstillinger.",
|
||||
"quick_reply_to": "Svar til {names}…",
|
||||
"open_full_editor": "Åbn fuld editor",
|
||||
"forward_to_label": "Til",
|
||||
"forward_cc_label": "Cc"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filtre",
|
||||
"remove_filter": "Fjern filteret {label}"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "Disse taster skriver et tegn på dit tastatur, og det ville holde op med at virke i alle apps. Prøv andre taster.",
|
||||
"reserved": "Dit system eller de fleste apps bruger disse taster. Prøv andre taster."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "Mere formatering",
|
||||
"offline": "Offline",
|
||||
"offline_hint": "Det kræver forbindelse at sende.",
|
||||
"offline_hint_outbox": "Det, du sender nu, venter i udbakken og sendes, når du er online igen.",
|
||||
"offline_send_saved": "Du er offline. Beskeden er gemt i Kladder.",
|
||||
"offline_send_unsaved": "Du er offline. Beskeden bliver her og gemmes i Kladder, når du er online igen."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "Fejlfinding",
|
||||
"label": "Udviklerværktøjer ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[Bild: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "Entwurf verworfen",
|
||||
"undo": "Rückgängig",
|
||||
"discard_draft": "Entwurf verwerfen",
|
||||
"save_failed_close": "Der Entwurf konnte nicht gespeichert werden. Versuchen Sie es erneut oder verwerfen Sie ihn.",
|
||||
"try_again": "Erneut versuchen",
|
||||
"add_description": "Beschreibung hinzufügen",
|
||||
"attachments_summary": "Anhänge: {count}, {size}",
|
||||
"from_sub_address": "Unteradresse verwenden…",
|
||||
"from_edit_sender": "Absender bearbeiten…",
|
||||
"shortcut_insert_link": "Link einfügen",
|
||||
"shortcut_save_draft": "Entwurf speichern",
|
||||
"shortcut_show_cc": "Cc anzeigen",
|
||||
"shortcut_show_bcc": "Bcc anzeigen"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Zurück zur Bearbeitung",
|
||||
"send_anyway": "Trotzdem senden",
|
||||
"warnings_title": "Diese Nachricht trotzdem senden?",
|
||||
"warning_subject": "Kein Betreff",
|
||||
"warning_text": "Kein Text",
|
||||
"warning_attachment": "Erwähnt „{keyword}“, aber keine Datei ist angehängt",
|
||||
"warning_placeholders": "Platzhaltertext, den die Empfänger sehen:",
|
||||
"warning_proofreading": "Offene Rechtschreib- oder Grammatikfehler: {count}",
|
||||
"empty_text_title": "Ohne Text senden?",
|
||||
"empty_text_message": "Diese Nachricht hat keinen Text und keinen Anhang. Trotzdem senden?",
|
||||
"scheduled_for": "Geplant für {when}",
|
||||
"sending": "Wird gesendet…",
|
||||
"sent": "Gesendet",
|
||||
"view": "Anzeigen",
|
||||
"picker_button": "Aus Kontakten hinzufügen",
|
||||
"picker_title": "Empfänger hinzufügen",
|
||||
"tab_contacts": "Kontakte",
|
||||
"tab_directory": "Verzeichnis",
|
||||
"contacts_empty_title": "Noch keine Kontakte",
|
||||
"contacts_empty_hint": "Personen, die Sie in Kontakten speichern, erscheinen hier.",
|
||||
"contacts_no_match": "Kein Kontakt passt zu Ihrer Suche",
|
||||
"template_name": "Name der Vorlage",
|
||||
"template_edit_details": "Details bearbeiten…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Meine Vorlagen",
|
||||
"section_organisation": "Organisation",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "Nicht gesendet. Eine Prüfung, die Ihre Organisation vorschreibt, läuft gerade nicht. Versuchen Sie es gleich noch einmal.",
|
||||
"stopped_working": "Nicht gesendet. Eine Prüfung, die Ihre Organisation vorschreibt, funktioniert nicht mehr. Laden Sie die Seite neu. Wenn das nicht hilft, wenden Sie sich an Ihre Administration."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "Senden rückgängig machen",
|
||||
"description": "Gibt Ihnen ein paar Sekunden, um eine Nachricht nach dem Klick auf Senden zurückzuholen."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "Antwortadresse automatisch wählen",
|
||||
"description": "Die Antwort geht von der Adresse aus, an die die E-Mail ging. Nützlich, wenn Sie mehrere Adressen haben."
|
||||
},
|
||||
"require_tls": "Nur über eine sichere Verbindung zustellen",
|
||||
"require_tls_hint": "Die Nachricht selbst wird nicht verschlüsselt. Sie kommt zurück, wenn ein Server kein TLS nutzen kann.",
|
||||
"encrypt": "Verschlüsseln…",
|
||||
"encrypt_hint": "Dafür brauchen Sie eine S/MIME- oder PGP-Erweiterung. Öffnet die Einstellungen.",
|
||||
"quick_reply_to": "Antwort an {names}…",
|
||||
"open_full_editor": "Im vollen Editor öffnen",
|
||||
"forward_to_label": "An",
|
||||
"forward_cc_label": "Cc"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filter",
|
||||
"remove_filter": "Filter {label} entfernen"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "Diese Tasten erzeugen auf Ihrer Tastatur ein Zeichen, das dann in keiner App mehr funktionieren würde. Versuchen Sie andere Tasten.",
|
||||
"reserved": "Ihr System oder die meisten Apps verwenden diese Tasten. Versuchen Sie andere Tasten."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "Weitere Formatierung",
|
||||
"offline": "Offline",
|
||||
"offline_hint": "Zum Senden ist eine Verbindung nötig.",
|
||||
"offline_hint_outbox": "Was Sie jetzt senden, wartet im Postausgang und wird gesendet, sobald Sie wieder online sind.",
|
||||
"offline_send_saved": "Sie sind offline. Die Nachricht ist in den Entwürfen gespeichert.",
|
||||
"offline_send_unsaved": "Sie sind offline. Die Nachricht bleibt hier und wird in den Entwürfen gespeichert, sobald Sie wieder online sind."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "Fehlerbehebung",
|
||||
"label": "Entwicklertools ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[Image: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "Draft discarded",
|
||||
"undo": "Undo",
|
||||
"discard_draft": "Discard draft",
|
||||
"save_failed_close": "The draft could not be saved. Try again, or discard it.",
|
||||
"try_again": "Try again",
|
||||
"add_description": "Add description",
|
||||
"attachments_summary": "Attachments: {count}, {size}",
|
||||
"from_sub_address": "Use a sub-address…",
|
||||
"from_edit_sender": "Edit sender…",
|
||||
"shortcut_insert_link": "Insert link",
|
||||
"shortcut_save_draft": "Save draft",
|
||||
"shortcut_show_cc": "Show Cc",
|
||||
"shortcut_show_bcc": "Show Bcc"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Back to editing",
|
||||
"send_anyway": "Send anyway",
|
||||
"warnings_title": "Send this message anyway?",
|
||||
"warning_subject": "No subject",
|
||||
"warning_text": "No text",
|
||||
"warning_attachment": "Mentions \"{keyword}\", but no file is attached",
|
||||
"warning_placeholders": "Placeholder text that recipients will see:",
|
||||
"warning_proofreading": "Open spelling or grammar issues: {count}",
|
||||
"empty_text_title": "Send without text?",
|
||||
"empty_text_message": "This message has no text and no attachment. Send it anyway?",
|
||||
"scheduled_for": "Scheduled for {when}",
|
||||
"sending": "Sending…",
|
||||
"sent": "Sent",
|
||||
"view": "View",
|
||||
"picker_button": "Add from contacts",
|
||||
"picker_title": "Add recipients",
|
||||
"tab_contacts": "Contacts",
|
||||
"tab_directory": "Directory",
|
||||
"contacts_empty_title": "No contacts yet",
|
||||
"contacts_empty_hint": "People you save in Contacts show up here.",
|
||||
"contacts_no_match": "No contact matches your search",
|
||||
"template_name": "Template name",
|
||||
"template_edit_details": "Edit details…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "My templates",
|
||||
"section_organisation": "Organisation",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "Not sent. A check your organisation requires isn't running. Try again in a moment.",
|
||||
"stopped_working": "Not sent. A check your organisation requires has stopped working. Reload the page. If that doesn't help, tell your administrator."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "Undo send",
|
||||
"description": "Gives you a few seconds to take a message back after you press Send."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "Reply from the address it was sent to",
|
||||
"description": "Reply from the address the mail was sent to. Useful if you have several addresses."
|
||||
},
|
||||
"require_tls": "Only deliver over a secure connection",
|
||||
"require_tls_hint": "The message itself isn't encrypted. It bounces if a server can't use TLS.",
|
||||
"encrypt": "Encrypt…",
|
||||
"encrypt_hint": "Needs an S/MIME or PGP add-on. Opens Settings.",
|
||||
"quick_reply_to": "Reply to {names}…",
|
||||
"open_full_editor": "Open full editor",
|
||||
"forward_to_label": "To",
|
||||
"forward_cc_label": "Cc"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filters",
|
||||
"remove_filter": "Remove filter {label}"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "These keys type a character on your keyboard, and it would stop working in every app. Try other keys.",
|
||||
"reserved": "Your system or most apps use these keys. Try other keys."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "More formatting",
|
||||
"offline": "Offline",
|
||||
"offline_hint": "Sending needs a connection.",
|
||||
"offline_hint_outbox": "What you send now waits in the outbox and goes out when you're back online.",
|
||||
"offline_send_saved": "You're offline. The message is saved in Drafts.",
|
||||
"offline_send_unsaved": "You're offline. The message stays here and is saved in Drafts when you're back online."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "Troubleshooting",
|
||||
"label": "Developer tools ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[Imagen: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "Borrador descartado",
|
||||
"undo": "Deshacer",
|
||||
"discard_draft": "Descartar borrador",
|
||||
"save_failed_close": "No se pudo guardar el borrador. Inténtelo de nuevo o descártelo.",
|
||||
"try_again": "Reintentar",
|
||||
"add_description": "Añadir descripción",
|
||||
"attachments_summary": "Adjuntos: {count}, {size}",
|
||||
"from_sub_address": "Usar una subdirección…",
|
||||
"from_edit_sender": "Editar remitente…",
|
||||
"shortcut_insert_link": "Insertar enlace",
|
||||
"shortcut_save_draft": "Guardar borrador",
|
||||
"shortcut_show_cc": "Mostrar CC",
|
||||
"shortcut_show_bcc": "Mostrar CCO"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Volver a editar",
|
||||
"send_anyway": "Enviar de todos modos",
|
||||
"warnings_title": "¿Enviar este mensaje de todos modos?",
|
||||
"warning_subject": "Sin asunto",
|
||||
"warning_text": "Sin texto",
|
||||
"warning_attachment": "Menciona «{keyword}», pero no hay ningún archivo adjunto",
|
||||
"warning_placeholders": "Texto de marcador que verán los destinatarios:",
|
||||
"warning_proofreading": "Errores de ortografía o gramática pendientes: {count}",
|
||||
"empty_text_title": "¿Enviar sin texto?",
|
||||
"empty_text_message": "Este mensaje no tiene texto ni archivos adjuntos. ¿Enviarlo de todos modos?",
|
||||
"scheduled_for": "Programado para {when}",
|
||||
"sending": "Enviando…",
|
||||
"sent": "Enviado",
|
||||
"view": "Ver",
|
||||
"picker_button": "Añadir desde contactos",
|
||||
"picker_title": "Añadir destinatarios",
|
||||
"tab_contacts": "Contactos",
|
||||
"tab_directory": "Directorio",
|
||||
"contacts_empty_title": "Aún no hay contactos",
|
||||
"contacts_empty_hint": "Aquí aparecen las personas que guardas en Contactos.",
|
||||
"contacts_no_match": "Ningún contacto coincide con la búsqueda",
|
||||
"template_name": "Nombre de la plantilla",
|
||||
"template_edit_details": "Editar detalles…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mis plantillas",
|
||||
"section_organisation": "Organización",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "No enviado. Una comprobación que exige tu organización no está en marcha. Vuelve a intentarlo en un momento.",
|
||||
"stopped_working": "No enviado. Una comprobación que exige tu organización ha dejado de funcionar. Vuelve a cargar la página. Si no se soluciona, avisa al administrador."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "Deshacer envío",
|
||||
"description": "Le da unos segundos para recuperar un mensaje después de pulsar Enviar."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "Seleccionar dirección de respuesta automáticamente",
|
||||
"description": "Responder desde la dirección a la que se envió el correo. Útil si tiene varias direcciones."
|
||||
},
|
||||
"require_tls": "Entregar solo por una conexión segura",
|
||||
"require_tls_hint": "El mensaje en sí no se cifra. Se devuelve si un servidor no puede usar TLS.",
|
||||
"encrypt": "Cifrar…",
|
||||
"encrypt_hint": "Necesita un complemento S/MIME o PGP. Abre la Configuración.",
|
||||
"quick_reply_to": "Responder a {names}…",
|
||||
"open_full_editor": "Abrir el editor completo",
|
||||
"forward_to_label": "Para",
|
||||
"forward_cc_label": "CC"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filtros",
|
||||
"remove_filter": "Quitar el filtro {label}"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "Estas teclas escriben un carácter en tu teclado, y dejaría de funcionar en todas las aplicaciones. Prueba otras teclas.",
|
||||
"reserved": "Tu sistema o la mayoría de las aplicaciones usan estas teclas. Prueba otras teclas."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "Más formato",
|
||||
"offline": "Sin conexión",
|
||||
"offline_hint": "Para enviar hace falta conexión.",
|
||||
"offline_hint_outbox": "Lo que envíes ahora espera en la bandeja de salida y se envía cuando vuelvas a tener conexión.",
|
||||
"offline_send_saved": "No tienes conexión. El mensaje está guardado en Borradores.",
|
||||
"offline_send_unsaved": "No tienes conexión. El mensaje se queda aquí y se guarda en Borradores cuando vuelvas a tener conexión."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "Solución de problemas",
|
||||
"label": "Herramientas para desarrolladores ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[Pilt: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "Mustand loobutud",
|
||||
"undo": "Võta tagasi",
|
||||
"discard_draft": "Loobu mustandist",
|
||||
"save_failed_close": "Mustandit ei saanud salvestada. Proovige uuesti või loobuge sellest.",
|
||||
"try_again": "Proovi uuesti",
|
||||
"add_description": "Lisa kirjeldus",
|
||||
"attachments_summary": "Manused: {count}, {size}",
|
||||
"from_sub_address": "Kasuta alamaadressi…",
|
||||
"from_edit_sender": "Muuda saatjat…",
|
||||
"shortcut_insert_link": "Lisa link",
|
||||
"shortcut_save_draft": "Salvesta mustand",
|
||||
"shortcut_show_cc": "Näita koopiat",
|
||||
"shortcut_show_bcc": "Näita pimekoopiat"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Kinnita",
|
||||
"cancel": "Tühista"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Tagasi muutmise juurde",
|
||||
"send_anyway": "Saada ikkagi",
|
||||
"warnings_title": "Kas saata see kiri ikkagi?",
|
||||
"warning_subject": "Teema puudub",
|
||||
"warning_text": "Tekst puudub",
|
||||
"warning_attachment": "Mainib sõna „{keyword}“, kuid ühtegi faili pole lisatud",
|
||||
"warning_placeholders": "Kohatäitetekst, mida saajad näevad:",
|
||||
"warning_proofreading": "Lahendamata õigekirja- või grammatikavead: {count}",
|
||||
"empty_text_title": "Kas saata ilma tekstita?",
|
||||
"empty_text_message": "Selles kirjas pole teksti ega manust. Kas saata see ikkagi?",
|
||||
"scheduled_for": "Ajastatud: {when}",
|
||||
"sending": "Saatmine…",
|
||||
"sent": "Saadetud",
|
||||
"view": "Vaata",
|
||||
"picker_button": "Lisa kontaktidest",
|
||||
"picker_title": "Lisa saajaid",
|
||||
"tab_contacts": "Kontaktid",
|
||||
"tab_directory": "Kataloog",
|
||||
"contacts_empty_title": "Kontakte veel pole",
|
||||
"contacts_empty_hint": "Siin kuvatakse inimesed, kelle salvestate kontaktidesse.",
|
||||
"contacts_no_match": "Otsingule ei vasta ükski kontakt",
|
||||
"template_name": "Malli nimi",
|
||||
"template_edit_details": "Muuda üksikasju…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Minu mallid",
|
||||
"section_organisation": "Organisatsioon",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "Ei saadetud. Teie organisatsiooni nõutud kontroll praegu ei tööta. Proovige hetke pärast uuesti.",
|
||||
"stopped_working": "Ei saadetud. Teie organisatsiooni nõutud kontroll ei tööta enam. Laadige leht uuesti. Kui see ei aita, teatage administraatorile."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "Saatmise tühistamine",
|
||||
"description": "Annab sulle pärast Saada vajutamist mõne sekundi, et kiri tagasi võtta."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "Vasta kirja saanud aadressilt",
|
||||
"description": "Vasta aadressilt, kuhu kiri saadeti. Kasulik, kui sul on mitu aadressi."
|
||||
},
|
||||
"require_tls": "Toimeta kohale ainult turvalise ühenduse kaudu",
|
||||
"require_tls_hint": "Kiri ise ei ole krüpteeritud. See tuleb tagasi, kui server ei saa TLS-i kasutada.",
|
||||
"encrypt": "Krüpteeri…",
|
||||
"encrypt_hint": "Vajab S/MIME või PGP lisa. Avab seaded.",
|
||||
"quick_reply_to": "Vastus saajale {names}…",
|
||||
"open_full_editor": "Ava täielik redaktor",
|
||||
"forward_to_label": "Saajale",
|
||||
"forward_cc_label": "Koopia"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filtrid",
|
||||
"remove_filter": "Eemalda filter {label}"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "Need klahvid kirjutavad teie klaviatuuril märgi, mis lakkaks kõigis rakendustes töötamast. Proovige teisi klahve.",
|
||||
"reserved": "Neid klahve kasutab süsteem või enamik rakendusi. Proovige teisi klahve."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "Rohkem vormindust",
|
||||
"offline": "Võrguta",
|
||||
"offline_hint": "Saatmiseks on vaja ühendust.",
|
||||
"offline_hint_outbox": "Mida praegu saadad, ootab väljuvate kirjade kastis ja läheb teele, kui oled jälle võrgus.",
|
||||
"offline_send_saved": "Oled võrguta. Kiri on salvestatud mustanditesse.",
|
||||
"offline_send_unsaved": "Oled võrguta. Kiri jääb siia ja salvestatakse mustanditesse, kui oled jälle võrgus."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "Tõrkeotsing",
|
||||
"label": "Arendaja tööriistad ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[تصویر: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "پیشنویس دور انداخته شد",
|
||||
"undo": "واگرد",
|
||||
"discard_draft": "دور انداختن پیشنویس",
|
||||
"save_failed_close": "پیشنویس ذخیره نشد. دوباره تلاش کنید یا آن را دور بیندازید.",
|
||||
"try_again": "تلاش دوباره",
|
||||
"add_description": "افزودن توضیح",
|
||||
"attachments_summary": "پیوستها: {count}، {size}",
|
||||
"from_sub_address": "استفاده از زیرنشانی…",
|
||||
"from_edit_sender": "ویرایش فرستنده…",
|
||||
"shortcut_insert_link": "درج پیوند",
|
||||
"shortcut_save_draft": "ذخیرهٔ پیشنویس",
|
||||
"shortcut_show_cc": "نمایش رونوشت",
|
||||
"shortcut_show_bcc": "نمایش رونوشت پنهان"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "تأیید",
|
||||
"cancel": "انصراف"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "بازگشت به ویرایش",
|
||||
"send_anyway": "ارسال به هر حال",
|
||||
"warnings_title": "این پیام به هر حال ارسال شود؟",
|
||||
"warning_subject": "بدون موضوع",
|
||||
"warning_text": "بدون متن",
|
||||
"warning_attachment": "به «{keyword}» اشاره میکند، اما هیچ فایلی پیوست نشده است",
|
||||
"warning_placeholders": "متن جایگزینی که گیرندگان خواهند دید:",
|
||||
"warning_proofreading": "خطاهای املایی یا دستوری باز: {count}",
|
||||
"empty_text_title": "بدون متن ارسال شود؟",
|
||||
"empty_text_message": "این پیام متن و پیوست ندارد. به هر حال ارسال شود؟",
|
||||
"scheduled_for": "زمانبندیشده برای {when}",
|
||||
"sending": "در حال ارسال…",
|
||||
"sent": "ارسال شد",
|
||||
"view": "مشاهده",
|
||||
"picker_button": "افزودن از مخاطبین",
|
||||
"picker_title": "افزودن گیرندگان",
|
||||
"tab_contacts": "مخاطبین",
|
||||
"tab_directory": "فهرست سازمانی",
|
||||
"contacts_empty_title": "هنوز مخاطبی وجود ندارد",
|
||||
"contacts_empty_hint": "افرادی که در مخاطبین ذخیره میکنید اینجا نمایش داده میشوند.",
|
||||
"contacts_no_match": "هیچ مخاطبی با جستجوی شما مطابقت ندارد",
|
||||
"template_name": "نام قالب",
|
||||
"template_edit_details": "ویرایش جزئیات…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "قالبهای من",
|
||||
"section_organisation": "سازمان",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "ارسال نشد. بررسیای که سازمان شما الزامی کرده در حال اجرا نیست. چند لحظه دیگر دوباره تلاش کنید.",
|
||||
"stopped_working": "ارسال نشد. بررسیای که سازمان شما الزامی کرده دیگر کار نمیکند. صفحه را دوباره بارگیری کنید. اگر کمکی نکرد، به مدیر سیستم خبر دهید."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "لغو ارسال",
|
||||
"description": "پس از زدن ارسال، چند ثانیه فرصت میدهد تا پیام را پس بگیرید."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "پاسخ از آدرس دریافتی",
|
||||
"description": "پاسخ از نشانیای که نامه به آن فرستاده شده بود. اگر چند نشانی دارید مفید است."
|
||||
},
|
||||
"require_tls": "تحویل فقط از راه اتصال امن",
|
||||
"require_tls_hint": "خود پیام رمزگذاری نمیشود. اگر سروری نتواند از TLS استفاده کند، برگشت میخورد.",
|
||||
"encrypt": "رمزگذاری…",
|
||||
"encrypt_hint": "به افزونهٔ S/MIME یا PGP نیاز دارد. تنظیمات را باز میکند.",
|
||||
"quick_reply_to": "پاسخ به {names}…",
|
||||
"open_full_editor": "باز کردن ویرایشگر کامل",
|
||||
"forward_to_label": "به",
|
||||
"forward_cc_label": "رونوشت"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "فیلترها",
|
||||
"remove_filter": "حذف فیلتر {label}"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "این کلیدها روی صفحهکلید شما نویسهای مینویسند و آن نویسه دیگر در هیچ برنامهای کار نمیکند. کلیدهای دیگری را امتحان کنید.",
|
||||
"reserved": "سیستم یا بیشتر برنامهها از این کلیدها استفاده میکنند. کلیدهای دیگری را امتحان کنید."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "قالببندی بیشتر",
|
||||
"offline": "آفلاین",
|
||||
"offline_hint": "ارسال به اتصال نیاز دارد.",
|
||||
"offline_hint_outbox": "آنچه اکنون میفرستید در صندوق خروجی میماند و وقتی دوباره آنلاین شدید فرستاده میشود.",
|
||||
"offline_send_saved": "آفلاین هستید. پیام در پیشنویسها ذخیره شده است.",
|
||||
"offline_send_unsaved": "آفلاین هستید. پیام همینجا میماند و وقتی دوباره آنلاین شدید در پیشنویسها ذخیره میشود."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "عیبیابی",
|
||||
"label": "ابزارهای توسعهدهنده ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[Image : {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "Brouillon supprimé",
|
||||
"undo": "Annuler",
|
||||
"discard_draft": "Supprimer le brouillon",
|
||||
"save_failed_close": "Le brouillon n'a pas pu être enregistré. Réessayez ou supprimez-le.",
|
||||
"try_again": "Réessayer",
|
||||
"add_description": "Ajouter une description",
|
||||
"attachments_summary": "Pièces jointes : {count}, {size}",
|
||||
"from_sub_address": "Utiliser une sous-adresse…",
|
||||
"from_edit_sender": "Modifier l'expéditeur…",
|
||||
"shortcut_insert_link": "Insérer un lien",
|
||||
"shortcut_save_draft": "Enregistrer le brouillon",
|
||||
"shortcut_show_cc": "Afficher Cc",
|
||||
"shortcut_show_bcc": "Afficher Cci"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmer",
|
||||
"cancel": "Annuler"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Retour à l'édition",
|
||||
"send_anyway": "Envoyer quand même",
|
||||
"warnings_title": "Envoyer ce message quand même ?",
|
||||
"warning_subject": "Pas d'objet",
|
||||
"warning_text": "Pas de texte",
|
||||
"warning_attachment": "Mentionne « {keyword} », mais aucun fichier n'est joint",
|
||||
"warning_placeholders": "Texte de remplacement que les destinataires verront :",
|
||||
"warning_proofreading": "Fautes d'orthographe ou de grammaire restantes : {count}",
|
||||
"empty_text_title": "Envoyer sans texte ?",
|
||||
"empty_text_message": "Ce message n'a ni texte ni pièce jointe. L'envoyer quand même ?",
|
||||
"scheduled_for": "Programmé pour {when}",
|
||||
"sending": "Envoi…",
|
||||
"sent": "Envoyé",
|
||||
"view": "Afficher",
|
||||
"picker_button": "Ajouter depuis les contacts",
|
||||
"picker_title": "Ajouter des destinataires",
|
||||
"tab_contacts": "Contacts",
|
||||
"tab_directory": "Annuaire",
|
||||
"contacts_empty_title": "Aucun contact pour l'instant",
|
||||
"contacts_empty_hint": "Les personnes enregistrées dans Contacts s'affichent ici.",
|
||||
"contacts_no_match": "Aucun contact ne correspond à votre recherche",
|
||||
"template_name": "Nom du modèle",
|
||||
"template_edit_details": "Modifier les détails…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mes modèles",
|
||||
"section_organisation": "Organisation",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "Non envoyé. Une vérification exigée par votre organisation ne fonctionne pas. Réessayez dans un instant.",
|
||||
"stopped_working": "Non envoyé. Une vérification exigée par votre organisation ne fonctionne plus. Rechargez la page. Si cela ne suffit pas, prévenez votre administrateur."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "Annuler l'envoi",
|
||||
"description": "Vous laisse quelques secondes pour reprendre un message après avoir cliqué sur Envoyer."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "Sélection automatique de l'adresse de réponse",
|
||||
"description": "Répondre depuis l'adresse à laquelle le message a été envoyé. Utile si vous avez plusieurs adresses."
|
||||
},
|
||||
"require_tls": "Livrer uniquement par une connexion sécurisée",
|
||||
"require_tls_hint": "Le message lui-même n'est pas chiffré. Il est renvoyé si un serveur ne peut pas utiliser TLS.",
|
||||
"encrypt": "Chiffrer…",
|
||||
"encrypt_hint": "Nécessite une extension S/MIME ou PGP. Ouvre les Paramètres.",
|
||||
"quick_reply_to": "Répondre à {names}…",
|
||||
"open_full_editor": "Ouvrir l'éditeur complet",
|
||||
"forward_to_label": "À",
|
||||
"forward_cc_label": "Cc"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filtres",
|
||||
"remove_filter": "Retirer le filtre {label}"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "Ces touches produisent un caractère sur votre clavier, et il ne fonctionnerait plus dans aucune application. Essayez d'autres touches.",
|
||||
"reserved": "Votre système ou la plupart des applications utilisent ces touches. Essayez d'autres touches."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "Plus de mise en forme",
|
||||
"offline": "Hors ligne",
|
||||
"offline_hint": "L'envoi nécessite une connexion.",
|
||||
"offline_hint_outbox": "Ce que vous envoyez maintenant attend dans la boîte d'envoi et part dès que vous êtes de nouveau en ligne.",
|
||||
"offline_send_saved": "Vous êtes hors ligne. Le message est enregistré dans les Brouillons.",
|
||||
"offline_send_unsaved": "Vous êtes hors ligne. Le message reste ici et sera enregistré dans les Brouillons dès que vous serez de nouveau en ligne."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "Dépannage",
|
||||
"label": "Outils de développement ({shortcut})",
|
||||
|
||||
@@ -1176,6 +1176,21 @@
|
||||
"plain_text": "[תמונה: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "הטיוטה נמחקה",
|
||||
"undo": "ביטול",
|
||||
"discard_draft": "מחיקת הטיוטה",
|
||||
"save_failed_close": "לא ניתן היה לשמור את הטיוטה. נסו שוב או מחקו אותה.",
|
||||
"try_again": "ניסיון נוסף",
|
||||
"add_description": "הוספת תיאור",
|
||||
"attachments_summary": "קבצים מצורפים: {count}, {size}",
|
||||
"from_sub_address": "שימוש בכתובת משנה…",
|
||||
"from_edit_sender": "עריכת השולח…",
|
||||
"shortcut_insert_link": "הוספת קישור",
|
||||
"shortcut_save_draft": "שמירת טיוטה",
|
||||
"shortcut_show_cc": "הצגת עותק",
|
||||
"shortcut_show_bcc": "הצגת עותק מוסתר"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "אשר",
|
||||
"cancel": "לְבַטֵל"
|
||||
@@ -3920,6 +3935,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "חזור לעריכה",
|
||||
"send_anyway": "שלח בכל זאת",
|
||||
"warnings_title": "לשלוח את ההודעה בכל זאת?",
|
||||
"warning_subject": "אין נושא",
|
||||
"warning_text": "אין טקסט",
|
||||
"warning_attachment": "מוזכר \"{keyword}\", אבל לא צורף קובץ",
|
||||
"warning_placeholders": "טקסט ממלא מקום שהנמענים יראו:",
|
||||
"warning_proofreading": "שגיאות כתיב או דקדוק פתוחות: {count}",
|
||||
"empty_text_title": "לשלוח ללא טקסט?",
|
||||
"empty_text_message": "להודעה הזו אין טקסט ואין קובץ מצורף. לשלוח אותה בכל זאת?",
|
||||
"scheduled_for": "מתוזמן ל-{when}",
|
||||
"sending": "שולח…",
|
||||
"sent": "נשלח",
|
||||
"view": "הצג",
|
||||
"picker_button": "הוסף מאנשי הקשר",
|
||||
"picker_title": "הוספת נמענים",
|
||||
"tab_contacts": "אנשי קשר",
|
||||
"tab_directory": "ספרייה",
|
||||
"contacts_empty_title": "אין עדיין אנשי קשר",
|
||||
"contacts_empty_hint": "אנשים שתשמרו באנשי הקשר יופיעו כאן.",
|
||||
"contacts_no_match": "אין איש קשר שתואם לחיפוש",
|
||||
"template_name": "שם התבנית",
|
||||
"template_edit_details": "עריכת פרטים…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "התבניות שלי",
|
||||
"section_organisation": "ארגון",
|
||||
@@ -6685,6 +6725,24 @@
|
||||
"not_running": "לא נשלח. בדיקה שהארגון שלך מחייב לא פועלת כרגע. נסו שוב בעוד רגע.",
|
||||
"stopped_working": "לא נשלח. בדיקה שהארגון שלך מחייב הפסיקה לפעול. טענו את הדף מחדש. אם זה לא עוזר, פנו למנהל המערכת."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "ביטול שליחה",
|
||||
"description": "נותן לך כמה שניות להחזיר הודעה אחרי שלחצת על שלח."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "בחירה אוטומטית בכתובת תשובה",
|
||||
"description": "השב מהכתובת שאליה נשלח הדואר. שימושי אם יש לך כמה כתובות."
|
||||
},
|
||||
"require_tls": "מסירה רק דרך חיבור מאובטח",
|
||||
"require_tls_hint": "ההודעה עצמה אינה מוצפנת. היא תוחזר אם שרת לא יכול להשתמש ב-TLS.",
|
||||
"encrypt": "הצפנה…",
|
||||
"encrypt_hint": "דורש תוסף S/MIME או PGP. פותח את ההגדרות.",
|
||||
"quick_reply_to": "תשובה אל {names}…",
|
||||
"open_full_editor": "פתיחה בעורך המלא",
|
||||
"forward_to_label": "אל",
|
||||
"forward_cc_label": "עותק"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "מסננים",
|
||||
"remove_filter": "הסר את המסנן {label}"
|
||||
@@ -7439,6 +7497,14 @@
|
||||
"types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.",
|
||||
"reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "עוד עיצוב",
|
||||
"offline": "אין חיבור",
|
||||
"offline_hint": "כדי לשלוח צריך חיבור.",
|
||||
"offline_hint_outbox": "מה ששולחים עכשיו ממתין בתיבת הדואר היוצא ונשלח כשחוזרים לרשת.",
|
||||
"offline_send_saved": "אין חיבור. ההודעה שמורה בטיוטות.",
|
||||
"offline_send_unsaved": "אין חיבור. ההודעה נשארת כאן ותישמר בטיוטות כשתחזרו לרשת."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "פתרון בעיות",
|
||||
"label": "כלים למפתחים ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[Kép: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "Piszkozat elvetve",
|
||||
"undo": "Visszavonás",
|
||||
"discard_draft": "Piszkozat elvetése",
|
||||
"save_failed_close": "A piszkozatot nem sikerült menteni. Próbálja újra, vagy vesse el.",
|
||||
"try_again": "Újra",
|
||||
"add_description": "Leírás hozzáadása",
|
||||
"attachments_summary": "Mellékletek: {count}, {size}",
|
||||
"from_sub_address": "Alcím használata…",
|
||||
"from_edit_sender": "Feladó szerkesztése…",
|
||||
"shortcut_insert_link": "Hivatkozás beszúrása",
|
||||
"shortcut_save_draft": "Piszkozat mentése",
|
||||
"shortcut_show_cc": "Másolat megjelenítése",
|
||||
"shortcut_show_bcc": "Titkos másolat megjelenítése"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Megerősítés",
|
||||
"cancel": "Mégse"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Vissza a szerkesztéshez",
|
||||
"send_anyway": "Küldés így is",
|
||||
"warnings_title": "Elküldi így is ezt az üzenetet?",
|
||||
"warning_subject": "Nincs tárgy",
|
||||
"warning_text": "Nincs szöveg",
|
||||
"warning_attachment": "Említi ezt: „{keyword}”, de nincs csatolt fájl",
|
||||
"warning_placeholders": "Helykitöltő szöveg, amelyet a címzettek látni fognak:",
|
||||
"warning_proofreading": "Nyitott helyesírási vagy nyelvtani hibák: {count}",
|
||||
"empty_text_title": "Küldés szöveg nélkül?",
|
||||
"empty_text_message": "Ennek az üzenetnek nincs szövege és melléklete. Elküldi így is?",
|
||||
"scheduled_for": "Ütemezve: {when}",
|
||||
"sending": "Küldés…",
|
||||
"sent": "Elküldve",
|
||||
"view": "Megtekintés",
|
||||
"picker_button": "Hozzáadás a névjegyekből",
|
||||
"picker_title": "Címzettek hozzáadása",
|
||||
"tab_contacts": "Névjegyek",
|
||||
"tab_directory": "Címtár",
|
||||
"contacts_empty_title": "Még nincsenek névjegyek",
|
||||
"contacts_empty_hint": "A névjegyekbe mentett személyek itt jelennek meg.",
|
||||
"contacts_no_match": "Egy névjegy sem felel meg a keresésnek",
|
||||
"template_name": "Sablon neve",
|
||||
"template_edit_details": "Részletek szerkesztése…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Saját sablonok",
|
||||
"section_organisation": "Szervezet",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "Nincs elküldve. Egy, a szervezete által előírt ellenőrzés most nem fut. Próbálja újra egy pillanat múlva.",
|
||||
"stopped_working": "Nincs elküldve. Egy, a szervezete által előírt ellenőrzés leállt. Töltse be újra az oldalt. Ha ez nem segít, szóljon a rendszergazdának."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "Küldés visszavonása",
|
||||
"description": "A Küldés megnyomása után néhány másodpercet ad az üzenet visszavonására."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "Válasz a fogadott címről",
|
||||
"description": "Válasz arról a címről, amelyre a levél érkezett. Hasznos, ha több címe van."
|
||||
},
|
||||
"require_tls": "Kézbesítés csak biztonságos kapcsolaton át",
|
||||
"require_tls_hint": "Maga az üzenet nincs titkosítva. Visszapattan, ha egy szerver nem tud TLS-t használni.",
|
||||
"encrypt": "Titkosítás…",
|
||||
"encrypt_hint": "S/MIME vagy PGP bővítmény kell hozzá. Megnyitja a Beállításokat.",
|
||||
"quick_reply_to": "Válasz neki: {names}…",
|
||||
"open_full_editor": "Megnyitás a teljes szerkesztőben",
|
||||
"forward_to_label": "Címzett",
|
||||
"forward_cc_label": "Másolat"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Szűrők",
|
||||
"remove_filter": "{label} szűrő eltávolítása"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "Ezek a billentyűk karaktert írnak az Ön billentyűzetén, amely többé egyik alkalmazásban sem működne. Próbáljon más billentyűket.",
|
||||
"reserved": "Ezeket a billentyűket a rendszer vagy a legtöbb alkalmazás használja. Próbáljon más billentyűket."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "További formázás",
|
||||
"offline": "Offline",
|
||||
"offline_hint": "A küldéshez kapcsolat kell.",
|
||||
"offline_hint_outbox": "Amit most elküld, a postázandók között vár, és elmegy, amint újra online lesz.",
|
||||
"offline_send_saved": "Offline állapotban van. Az üzenet a Piszkozatok között van mentve.",
|
||||
"offline_send_unsaved": "Offline állapotban van. Az üzenet itt marad, és a Piszkozatok közé kerül, amint újra online lesz."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "Hibaelhárítás",
|
||||
"label": "Fejlesztői eszközök ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[Immagine: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "Bozza eliminata",
|
||||
"undo": "Annulla",
|
||||
"discard_draft": "Elimina bozza",
|
||||
"save_failed_close": "Impossibile salvare la bozza. Riprova o eliminala.",
|
||||
"try_again": "Riprova",
|
||||
"add_description": "Aggiungi descrizione",
|
||||
"attachments_summary": "Allegati: {count}, {size}",
|
||||
"from_sub_address": "Usa un sottoindirizzo…",
|
||||
"from_edit_sender": "Modifica mittente…",
|
||||
"shortcut_insert_link": "Inserisci link",
|
||||
"shortcut_save_draft": "Salva bozza",
|
||||
"shortcut_show_cc": "Mostra Cc",
|
||||
"shortcut_show_bcc": "Mostra Ccn"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Torna alla modifica",
|
||||
"send_anyway": "Invia comunque",
|
||||
"warnings_title": "Inviare comunque questo messaggio?",
|
||||
"warning_subject": "Nessun oggetto",
|
||||
"warning_text": "Nessun testo",
|
||||
"warning_attachment": "Menziona \"{keyword}\", ma non c'è nessun file allegato",
|
||||
"warning_placeholders": "Testo segnaposto che i destinatari vedranno:",
|
||||
"warning_proofreading": "Errori di ortografia o grammatica aperti: {count}",
|
||||
"empty_text_title": "Inviare senza testo?",
|
||||
"empty_text_message": "Questo messaggio non ha testo né allegati. Inviarlo comunque?",
|
||||
"scheduled_for": "Programmato per {when}",
|
||||
"sending": "Invio in corso…",
|
||||
"sent": "Inviato",
|
||||
"view": "Visualizza",
|
||||
"picker_button": "Aggiungi dai contatti",
|
||||
"picker_title": "Aggiungi destinatari",
|
||||
"tab_contacts": "Contatti",
|
||||
"tab_directory": "Rubrica aziendale",
|
||||
"contacts_empty_title": "Ancora nessun contatto",
|
||||
"contacts_empty_hint": "Le persone salvate in Contatti compaiono qui.",
|
||||
"contacts_no_match": "Nessun contatto corrisponde alla ricerca",
|
||||
"template_name": "Nome del modello",
|
||||
"template_edit_details": "Modifica dettagli…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "I miei modelli",
|
||||
"section_organisation": "Organizzazione",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "Non inviato. Un controllo richiesto dalla tua organizzazione non è attivo. Riprova tra un momento.",
|
||||
"stopped_working": "Non inviato. Un controllo richiesto dalla tua organizzazione ha smesso di funzionare. Ricarica la pagina. Se non basta, avvisa l'amministratore."
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "Annulla invio",
|
||||
"description": "Ti dà qualche secondo per riprendere un messaggio dopo aver premuto Invia."
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "Seleziona automaticamente l'indirizzo di risposta",
|
||||
"description": "Rispondi dall'indirizzo a cui è stata inviata l'email. Utile se hai più indirizzi."
|
||||
},
|
||||
"require_tls": "Consegna solo tramite una connessione sicura",
|
||||
"require_tls_hint": "Il messaggio in sé non è cifrato. Viene respinto se un server non può usare TLS.",
|
||||
"encrypt": "Cifra…",
|
||||
"encrypt_hint": "Serve un componente aggiuntivo S/MIME o PGP. Apre le Impostazioni.",
|
||||
"quick_reply_to": "Rispondi a {names}…",
|
||||
"open_full_editor": "Apri l'editor completo",
|
||||
"forward_to_label": "A",
|
||||
"forward_cc_label": "Cc"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "Filtri",
|
||||
"remove_filter": "Rimuovi il filtro {label}"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "Questi tasti scrivono un carattere sulla tua tastiera, che smetterebbe di funzionare in tutte le app. Prova altri tasti.",
|
||||
"reserved": "Il sistema o la maggior parte delle app usano questi tasti. Prova altri tasti."
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "Altre opzioni di formattazione",
|
||||
"offline": "Offline",
|
||||
"offline_hint": "Per inviare serve una connessione.",
|
||||
"offline_hint_outbox": "Quello che invii ora aspetta nella posta in uscita e parte quando torni online.",
|
||||
"offline_send_saved": "Sei offline. Il messaggio è salvato nelle Bozze.",
|
||||
"offline_send_unsaved": "Sei offline. Il messaggio resta qui e viene salvato nelle Bozze quando torni online."
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "Risoluzione dei problemi",
|
||||
"label": "Strumenti per sviluppatori ({shortcut})",
|
||||
|
||||
@@ -1233,6 +1233,21 @@
|
||||
"plain_text": "[画像: {alt}]"
|
||||
}
|
||||
},
|
||||
"compose_ux": {
|
||||
"draft_discarded": "下書きを破棄しました",
|
||||
"undo": "元に戻す",
|
||||
"discard_draft": "下書きを破棄",
|
||||
"save_failed_close": "下書きを保存できませんでした。もう一度試すか、破棄してください。",
|
||||
"try_again": "再試行",
|
||||
"add_description": "説明を追加",
|
||||
"attachments_summary": "添付ファイル: {count} 件、{size}",
|
||||
"from_sub_address": "サブアドレスを使用…",
|
||||
"from_edit_sender": "差出人を編集…",
|
||||
"shortcut_insert_link": "リンクを挿入",
|
||||
"shortcut_save_draft": "下書きを保存",
|
||||
"shortcut_show_cc": "Cc を表示",
|
||||
"shortcut_show_bcc": "Bcc を表示"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "確認",
|
||||
"cancel": "キャンセル"
|
||||
@@ -4027,6 +4042,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "編集に戻る",
|
||||
"send_anyway": "そのまま送信",
|
||||
"warnings_title": "このメッセージをそのまま送信しますか?",
|
||||
"warning_subject": "件名がありません",
|
||||
"warning_text": "本文がありません",
|
||||
"warning_attachment": "「{keyword}」とありますが、ファイルが添付されていません",
|
||||
"warning_placeholders": "受信者に表示されるプレースホルダー:",
|
||||
"warning_proofreading": "未解決のスペル・文法の問題: {count}",
|
||||
"empty_text_title": "本文なしで送信しますか?",
|
||||
"empty_text_message": "このメッセージには本文も添付ファイルもありません。そのまま送信しますか?",
|
||||
"scheduled_for": "{when} に送信予定",
|
||||
"sending": "送信中…",
|
||||
"sent": "送信しました",
|
||||
"view": "表示",
|
||||
"picker_button": "連絡先から追加",
|
||||
"picker_title": "宛先を追加",
|
||||
"tab_contacts": "連絡先",
|
||||
"tab_directory": "ディレクトリ",
|
||||
"contacts_empty_title": "連絡先はまだありません",
|
||||
"contacts_empty_hint": "連絡先に保存した人がここに表示されます。",
|
||||
"contacts_no_match": "検索に一致する連絡先はありません",
|
||||
"template_name": "テンプレート名",
|
||||
"template_edit_details": "詳細を編集…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "マイテンプレート",
|
||||
"section_organisation": "組織",
|
||||
@@ -6684,6 +6724,24 @@
|
||||
"not_running": "送信されませんでした。組織で必須のチェックが動作していません。しばらくしてからもう一度お試しください。",
|
||||
"stopped_working": "送信されませんでした。組織で必須のチェックが動作しなくなりました。ページを再読み込みしてください。解決しない場合は管理者に連絡してください。"
|
||||
},
|
||||
"compose_words": {
|
||||
"undo_send": {
|
||||
"label": "送信取り消し",
|
||||
"description": "送信を押した後、数秒間メッセージを取り消せます。"
|
||||
},
|
||||
"reply_from_received": {
|
||||
"label": "返信元アドレスを自動選択",
|
||||
"description": "メールの宛先になったアドレスから返信します。複数のアドレスがある場合に便利です。"
|
||||
},
|
||||
"require_tls": "安全な接続でのみ配信",
|
||||
"require_tls_hint": "メッセージ自体は暗号化されません。サーバーが TLS を使えない場合は返送されます。",
|
||||
"encrypt": "暗号化…",
|
||||
"encrypt_hint": "S/MIME または PGP のアドオンが必要です。設定を開きます。",
|
||||
"quick_reply_to": "{names} に返信…",
|
||||
"open_full_editor": "フルエディターで開く",
|
||||
"forward_to_label": "宛先",
|
||||
"forward_cc_label": "CC"
|
||||
},
|
||||
"plugin_list": {
|
||||
"filters": "フィルター",
|
||||
"remove_filter": "フィルター「{label}」を解除"
|
||||
@@ -7438,6 +7496,14 @@
|
||||
"types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。",
|
||||
"reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。"
|
||||
},
|
||||
"compose_windows": {
|
||||
"more_formatting": "その他の書式",
|
||||
"offline": "オフライン",
|
||||
"offline_hint": "送信には接続が必要です。",
|
||||
"offline_hint_outbox": "今送信したものは送信トレイで待機し、オンラインに戻ると送信されます。",
|
||||
"offline_send_saved": "オフラインです。メッセージは下書きに保存されています。",
|
||||
"offline_send_unsaved": "オフラインです。メッセージはここに残り、オンラインに戻ると下書きに保存されます。"
|
||||
},
|
||||
"desktopDevTools": {
|
||||
"section_title": "トラブルシューティング",
|
||||
"label": "デベロッパー ツール ({shortcut})",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user