mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 16:16:49 +00:00
Merge branch 'feat/do-ux-compose-send' into feat/do-ux-compose-fixes
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -370,17 +370,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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -546,6 +547,22 @@ describe('composer with the @ recipient list', () => {
|
||||
expect(to.value).toBe('Sarah Kim <sarah@example.com>');
|
||||
});
|
||||
|
||||
// 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.
|
||||
it('lets Escape in the directory picker leave the composer alone', async () => {
|
||||
// Every other capability answers nothing.
|
||||
|
||||
@@ -329,6 +329,21 @@ describe('composer-send-menu', () => {
|
||||
expect(follows(schedule, plugin)).toBe(true);
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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>'
|
||||
|
||||
+161
-242
@@ -55,6 +55,8 @@ import { matchesCombo } from "@/lib/plugin-sandbox/shortcuts";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { forwardRecipientLines } from "@/lib/quote-header";
|
||||
import { ScheduleSendDialog } from "./schedule-send-dialog";
|
||||
import { SendWarningsDialog, type SendWarning } from "./send-warnings-dialog";
|
||||
import { QuickSaveTemplateDialog } from "@/components/templates/quick-save-template-dialog";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu";
|
||||
@@ -370,6 +372,11 @@ interface EmailComposerProps {
|
||||
/** The id plugins know this composer by, for onAfterEmailSend. */
|
||||
composerId?: string;
|
||||
}) => void | false | Promise<void | false>;
|
||||
/**
|
||||
* onSend failed. A host that closes the composer as soon as onSend starts
|
||||
* gets the message back here, to open it again: it must never vanish.
|
||||
*/
|
||||
onSendFailed?: (state: ComposerDraftData) => void;
|
||||
onScheduledSendCreated?: () => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
/**
|
||||
@@ -574,6 +581,7 @@ const OFFLINE_SEND_ERRORS: Record<string, string> = {
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onSendFailed,
|
||||
onScheduledSendCreated,
|
||||
onClose,
|
||||
requestCloseRef,
|
||||
@@ -601,7 +609,6 @@ export function EmailComposer({
|
||||
const tViewer = useTranslations('email_viewer');
|
||||
const tClassification = useTranslations('classification');
|
||||
const tEdit = useTranslations('desktopEdit');
|
||||
const tProofreading = useTranslations('proofreading');
|
||||
const tOfflineDrafts = useTranslations('offlineDrafts');
|
||||
const tOffline = useTranslations('offline');
|
||||
const tOfflineNotes = useTranslations('offlineConflicts');
|
||||
@@ -1197,6 +1204,9 @@ export function EmailComposer({
|
||||
const [filesPicker, setFilesPicker] = useState<{ client: IJMAPClient; accountId?: string } | null>(null);
|
||||
const [showDirectoryPicker, setShowDirectoryPicker] = useState(false);
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
// Save as template asks for a name only; "Edit details…" opens the full
|
||||
// editor with the name typed so far (null: the name dialog).
|
||||
const [saveTemplateDetails, setSaveTemplateDetails] = useState<string | null>(null);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
// The formatting toolbar is folded away on a phone until asked for.
|
||||
const [showFormattingOnMobile, setShowFormattingOnMobile] = useState(false);
|
||||
@@ -1210,19 +1220,11 @@ export function EmailComposer({
|
||||
const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close');
|
||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
|
||||
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
|
||||
const [showEmptySubjectWarning, setShowEmptySubjectWarning] = useState(false);
|
||||
const [emptySubjectDelayedUntil, setEmptySubjectDelayedUntil] = useState<string | undefined>();
|
||||
const [emptySubjectDontAskAgain, setEmptySubjectDontAskAgain] = useState(false);
|
||||
const [showPlaceholderWarning, setShowPlaceholderWarning] = useState(false);
|
||||
// A proofreading checker asked to warn before sending with open issues.
|
||||
const [proofreadingWarning, setProofreadingWarning] = useState<{ count: number; delayedUntil?: string } | null>(null);
|
||||
// What the checks before sending found (no subject, no text, placeholders,
|
||||
// a missing attachment, open proofreading issues), asked about in one dialog.
|
||||
const [sendWarnings, setSendWarnings] = useState<{ items: SendWarning[]; delayedUntil?: string } | null>(null);
|
||||
// Open proofreading issues, once a checker asked to warn about them.
|
||||
const proofreadingConfirmedRef = useRef(false);
|
||||
const [placeholderWarningMatches, setPlaceholderWarningMatches] = useState<string[]>([]);
|
||||
const [placeholderWarningDelayedUntil, setPlaceholderWarningDelayedUntil] = useState<string | undefined>();
|
||||
const [placeholderDontAskAgain, setPlaceholderDontAskAgain] = useState(false);
|
||||
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
|
||||
// Asks before sending at once when the account can't hold a message back.
|
||||
const { dialogProps: noDelayDialogProps, confirm: confirmNoDelay } = useConfirmDialog();
|
||||
@@ -1243,6 +1245,7 @@ export function EmailComposer({
|
||||
if (!(await confirmDiscardTemplate(saveTemplateDirty))) return;
|
||||
setSaveTemplateDirty(false);
|
||||
setShowSaveAsTemplate(false);
|
||||
setSaveTemplateDetails(null);
|
||||
};
|
||||
|
||||
const { client: activeClient } = useAuthStore();
|
||||
@@ -1786,6 +1789,9 @@ export function EmailComposer({
|
||||
// Whether a Sent mailbox is known so the on-demand server search is worth
|
||||
// offering (falls back to hiding the "search the server" row otherwise).
|
||||
const canSearchServer = useContactStore((s) => s.sentMailboxId != null);
|
||||
// The recipient picker: the user's contacts, and the server's directory.
|
||||
const hasContacts = useContactStore((s) => (s.contacts?.length ?? 0) > 0);
|
||||
const tSend = useTranslations('compose_send');
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
@@ -2810,6 +2816,13 @@ export function EmailComposer({
|
||||
// Plugin entries make the Send and Attach buttons menus even where the app
|
||||
// itself has nothing else to put there.
|
||||
const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
|
||||
// With nothing but "Schedule send" in Send's menu, the arrow opens the
|
||||
// schedule dialog itself: a menu with one item was one click too many.
|
||||
const sendArrowSchedules = !hasSendMenuActions && !!composerClient?.hasDelayedSend();
|
||||
const handleSendArrow = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (sendArrowSchedules) openScheduleDialog();
|
||||
else toggleSendMenu(e);
|
||||
};
|
||||
const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0;
|
||||
// Ticked plugin options count in the Options button's badge like the app's own.
|
||||
const pluginOptionsOn = usePluginActions('composer-options-menu').filter((a) => a.checked).length;
|
||||
@@ -3604,14 +3617,14 @@ export function EmailComposer({
|
||||
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
|
||||
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
|
||||
// A missing subject no longer blocks Send (#684) - users hit the disabled
|
||||
// button without understanding why. It is confirmed in a dialog instead.
|
||||
const canSend = toAddresses.length > 0 && hasContent;
|
||||
// button without understanding why. It is confirmed in a dialog instead,
|
||||
// and so is a message with no text and no file.
|
||||
const canSend = toAddresses.length > 0;
|
||||
|
||||
const getSendTooltip = (): string | undefined => {
|
||||
if (isWaitingForUploads) return t('validation.attachments_uploading');
|
||||
if (canSend) return undefined;
|
||||
if (toAddresses.length === 0) return t('validation.recipient_required');
|
||||
if (!hasContent) return t('validation.body_required');
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -3706,6 +3719,8 @@ export function EmailComposer({
|
||||
const confirmingSendRef = useRef(false);
|
||||
// The user confirmed sending without a subject for this message.
|
||||
const emptySubjectConfirmedRef = useRef(false);
|
||||
// ... and without text.
|
||||
const emptyTextConfirmedRef = useRef(false);
|
||||
// Placeholders the user chose to send as they are. The warning comes back
|
||||
// only for one that is not in here.
|
||||
const confirmedPlaceholdersRef = useRef<Set<string>>(new Set());
|
||||
@@ -3750,9 +3765,9 @@ export function EmailComposer({
|
||||
else editor.commands.focus();
|
||||
};
|
||||
|
||||
const handleSend = async ({ skipAttachmentCheck = false, skipSubjectCheck = false, delayedUntil }: {
|
||||
skipAttachmentCheck?: boolean;
|
||||
skipSubjectCheck?: boolean;
|
||||
const handleSend = async ({ warningsConfirmed = false, delayedUntil }: {
|
||||
/** "Send anyway" in the warnings dialog: don't check those again. */
|
||||
warningsConfirmed?: boolean;
|
||||
delayedUntil?: string;
|
||||
} = {}) => {
|
||||
if (isSendingRef.current) return;
|
||||
@@ -3800,7 +3815,6 @@ export function EmailComposer({
|
||||
if (!canSend) {
|
||||
const errors: { to?: boolean; body?: boolean } = {};
|
||||
if (toAddresses.length === 0) errors.to = true;
|
||||
if (!hasContent) errors.body = true;
|
||||
setValidationErrors(errors);
|
||||
|
||||
if (errors.to) {
|
||||
@@ -3839,69 +3853,69 @@ export function EmailComposer({
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty subject confirmation (#684). Asked once per message: after "Send
|
||||
// anyway" and a trip back to add an attachment, it asked again.
|
||||
if (!skipSubjectCheck && !emptySubjectConfirmedRef.current && emptySubjectWarningEnabled && !subject.trim()) {
|
||||
setEmptySubjectDontAskAgain(false);
|
||||
setEmptySubjectDelayedUntil(delayedUntil);
|
||||
setShowEmptySubjectWarning(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unfilled placeholders: template fields left as {{name}}, fill-ins like
|
||||
// [TIME] from AI drafts, and markers like TODO. The signature is left
|
||||
// out, or a placeholder in it would stop every message.
|
||||
if (placeholderWarningEnabled) {
|
||||
const authored = plainTextMode
|
||||
? plainTextBodyWithoutSignature(body, signatureIdentity)
|
||||
: stripEmbeddedSignatureHtml(body);
|
||||
const bodyText = extractUserAuthoredText(authored, {
|
||||
plainTextMode,
|
||||
forwardedSeparator: tQuote('forwarded_separator'),
|
||||
});
|
||||
// The fill-in words of the UI language, and of English: AI drafts and
|
||||
// templates are often English whatever the UI language is.
|
||||
const found = findUnfilledPlaceholders(subject, bodyText, [
|
||||
enMessages.email_composer.placeholder_words,
|
||||
t.raw('placeholder_words'),
|
||||
]);
|
||||
if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) {
|
||||
setPlaceholderWarningMatches(found);
|
||||
setPlaceholderDontAskAgain(false);
|
||||
setPlaceholderWarningDelayedUntil(delayedUntil);
|
||||
setShowPlaceholderWarning(true);
|
||||
return;
|
||||
// What the user may have missed, asked about in one dialog (two in a
|
||||
// row used to ask about each in turn). Answered with "Send anyway", the
|
||||
// subject, the text, the placeholders listed and the proofreading issues
|
||||
// aren't asked about again for this message.
|
||||
if (!warningsConfirmed) {
|
||||
const warnings: SendWarning[] = [];
|
||||
// Empty subject confirmation (#684).
|
||||
if (!emptySubjectConfirmedRef.current && emptySubjectWarningEnabled && !subject.trim()) {
|
||||
warnings.push({ kind: 'subject' });
|
||||
}
|
||||
// No text and no file: Send stays enabled and asks instead.
|
||||
if (!emptyTextConfirmedRef.current && !hasContent) {
|
||||
warnings.push({ kind: 'text' });
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment reminder check
|
||||
if (!skipAttachmentCheck && attachmentReminderEnabled) {
|
||||
const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
|
||||
if (!hasAttachments) {
|
||||
// Scan only the user-authored text: the quoted original of a
|
||||
// reply/forward often mentions an attachment itself, which used to fire
|
||||
// the reminder even when the user typed no keyword and added nothing (#570).
|
||||
const bodyText = extractUserAuthoredText(body, {
|
||||
// Unfilled placeholders: template fields left as {{name}}, fill-ins like
|
||||
// [TIME] from AI drafts, and markers like TODO. The signature is left
|
||||
// out, or a placeholder in it would stop every message.
|
||||
if (placeholderWarningEnabled) {
|
||||
const authored = plainTextMode
|
||||
? plainTextBodyWithoutSignature(body, signatureIdentity)
|
||||
: stripEmbeddedSignatureHtml(body);
|
||||
const bodyText = extractUserAuthoredText(authored, {
|
||||
plainTextMode,
|
||||
forwardedSeparator: tQuote('forwarded_separator'),
|
||||
});
|
||||
const searchText = `${subject} ${bodyText}`.toLowerCase();
|
||||
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||
if (matched) {
|
||||
setAttachmentWarningKeyword(matched);
|
||||
setAttachmentWarningDelayedUntil(delayedUntil);
|
||||
setShowAttachmentWarning(true);
|
||||
return;
|
||||
// The fill-in words of the UI language, and of English: AI drafts and
|
||||
// templates are often English whatever the UI language is.
|
||||
const found = findUnfilledPlaceholders(subject, bodyText, [
|
||||
enMessages.email_composer.placeholder_words,
|
||||
t.raw('placeholder_words'),
|
||||
]);
|
||||
if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) {
|
||||
warnings.push({ kind: 'placeholders', matches: found });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open proofreading issues, when a checker asked for the warning (the
|
||||
// LanguageTool plugin's "warn before sending" option). Once per message.
|
||||
if (!proofreadingConfirmedRef.current) {
|
||||
const open = proofreading.sendWarningIssues().length;
|
||||
if (open > 0) {
|
||||
setProofreadingWarning({ count: open, delayedUntil });
|
||||
// Attachment reminder check
|
||||
if (attachmentReminderEnabled) {
|
||||
const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
|
||||
if (!hasAttachments) {
|
||||
// Scan only the user-authored text: the quoted original of a
|
||||
// reply/forward often mentions an attachment itself, which used to fire
|
||||
// the reminder even when the user typed no keyword and added nothing (#570).
|
||||
const bodyText = extractUserAuthoredText(body, {
|
||||
plainTextMode,
|
||||
forwardedSeparator: tQuote('forwarded_separator'),
|
||||
});
|
||||
const searchText = `${subject} ${bodyText}`.toLowerCase();
|
||||
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||
if (matched) warnings.push({ kind: 'attachment', keyword: matched });
|
||||
}
|
||||
}
|
||||
|
||||
// Open proofreading issues, when a checker asked for the warning (the
|
||||
// LanguageTool plugin's "warn before sending" option).
|
||||
if (!proofreadingConfirmedRef.current) {
|
||||
const open = proofreading.sendWarningIssues().length;
|
||||
if (open > 0) warnings.push({ kind: 'proofreading', count: open });
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
setSendWarnings({ items: warnings, delayedUntil });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -4061,6 +4075,9 @@ export function EmailComposer({
|
||||
const lastStage = (html: string | undefined, text: string) =>
|
||||
finalizeOutgoing({ html: html || undefined, text }, outgoingContext, 'last');
|
||||
const inlineAttachments = rewritten?.attachments ?? [];
|
||||
// The message as it is now, once onSend has it: the host may close the
|
||||
// composer before the send is through.
|
||||
let handedToHost: ComposerDraftData | null = null;
|
||||
|
||||
try {
|
||||
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
|
||||
@@ -4260,6 +4277,13 @@ export function EmailComposer({
|
||||
// Read when the close is reported: the host tears the composer down
|
||||
// while onSend is still running. Reset below if the send fails.
|
||||
sentRef.current = true;
|
||||
handedToHost = {
|
||||
...snapshotStateRef.current(),
|
||||
// The save before the send may have moved the draft to a new id.
|
||||
draftId: draftIdRef.current,
|
||||
draftAccountId: draftAccountIdRef.current,
|
||||
draftLocalAccountId: draftLocalAccountIdRef.current,
|
||||
};
|
||||
const outcome = await onSend?.({
|
||||
to: outgoing.to,
|
||||
cc: outgoing.cc,
|
||||
@@ -4335,6 +4359,8 @@ export function EmailComposer({
|
||||
explicitCloseRef.current = false;
|
||||
sentRef.current = false;
|
||||
debug.error('Failed to send email:', err);
|
||||
// A host that closed the composer at Send opens it again with the text.
|
||||
if (handedToHost) onSendFailed?.(handedToHost);
|
||||
if (isLabelRequiredError(err)) {
|
||||
setLabelInvalid(true);
|
||||
toast.error(tClassification('required_toast'));
|
||||
@@ -4660,9 +4686,7 @@ export function EmailComposer({
|
||||
filesPicker ||
|
||||
showSaveAsTemplate ||
|
||||
showScheduleDialog ||
|
||||
showAttachmentWarning ||
|
||||
showEmptySubjectWarning ||
|
||||
showPlaceholderWarning ||
|
||||
sendWarnings ||
|
||||
showCloseDialog
|
||||
) return;
|
||||
|
||||
@@ -4868,14 +4892,14 @@ export function EmailComposer({
|
||||
<Button
|
||||
type="button"
|
||||
onMouseDown={(e) => { if (showSendMenu) e.stopPropagation(); }}
|
||||
onClick={toggleSendMenu}
|
||||
onClick={handleSendArrow}
|
||||
disabled={!canSend || isSending || isSavingAndClosing}
|
||||
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
size="sm"
|
||||
className="h-11 min-w-11 rounded-s-none px-2"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSendMenu}
|
||||
aria-haspopup={sendArrowSchedules ? 'dialog' : 'menu'}
|
||||
aria-expanded={sendArrowSchedules ? undefined : showSendMenu}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -5084,15 +5108,15 @@ export function EmailComposer({
|
||||
onMoveChip={handleMoveChip}
|
||||
/>
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
{activeClient?.supportsPrincipals?.() && (
|
||||
{(hasContacts || activeClient?.supportsPrincipals?.()) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDirectoryPicker(true)}
|
||||
className="h-7 w-7 p-0"
|
||||
title={t('select_from_directory')}
|
||||
aria-label={t('select_from_directory')}
|
||||
title={tSend('picker_button')}
|
||||
aria-label={tSend('picker_button')}
|
||||
data-testid="composer-directory-picker"
|
||||
>
|
||||
<BookUser className="w-4 h-4" />
|
||||
@@ -5750,13 +5774,13 @@ export function EmailComposer({
|
||||
<Button
|
||||
type="button"
|
||||
onMouseDown={(e) => { if (showSendMenu) e.stopPropagation(); }}
|
||||
onClick={toggleSendMenu}
|
||||
onClick={handleSendArrow}
|
||||
disabled={!canSend || isSending || isSavingAndClosing}
|
||||
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
className="rounded-s-none px-2"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSendMenu}
|
||||
aria-haspopup={sendArrowSchedules ? 'dialog' : 'menu'}
|
||||
aria-expanded={sendArrowSchedules ? undefined : showSendMenu}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -5802,7 +5826,18 @@ export function EmailComposer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSaveAsTemplate && (
|
||||
{showSaveAsTemplate && saveTemplateDetails === null && (
|
||||
<QuickSaveTemplateDialog
|
||||
defaultName={subject.trim()}
|
||||
onClose={() => setShowSaveAsTemplate(false)}
|
||||
onEditDetails={(name) => setSaveTemplateDetails(name)}
|
||||
onSave={(name) => {
|
||||
addTemplate({ name, subject, body, isHTML: !plainTextMode, category: '', isFavorite: false });
|
||||
setShowSaveAsTemplate(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showSaveAsTemplate && saveTemplateDetails !== null && (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={() => void closeSaveAsTemplate()}
|
||||
@@ -5815,6 +5850,7 @@ export function EmailComposer({
|
||||
<TemplateForm
|
||||
categorySource={composingPersonalTemplates}
|
||||
initialData={{
|
||||
name: saveTemplateDetails,
|
||||
subject,
|
||||
body,
|
||||
isHTML: !plainTextMode,
|
||||
@@ -5826,6 +5862,7 @@ export function EmailComposer({
|
||||
addTemplate(data);
|
||||
setSaveTemplateDirty(false);
|
||||
setShowSaveAsTemplate(false);
|
||||
setSaveTemplateDetails(null);
|
||||
}}
|
||||
onCancel={() => void closeSaveAsTemplate()}
|
||||
onDirtyChange={setSaveTemplateDirty}
|
||||
@@ -5856,163 +5893,45 @@ export function EmailComposer({
|
||||
disabled={!canSend || isSending || isSavingAndClosing}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
isOpen={showAttachmentWarning}
|
||||
onClose={() => setShowAttachmentWarning(false)}
|
||||
role="alertdialog"
|
||||
showClose={false}
|
||||
title={t('forgot_attachment.title')}
|
||||
description={t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
||||
{t('forgot_attachment.back')}
|
||||
</Button>
|
||||
<Button onClick={() => { setShowAttachmentWarning(false); handleSend({ skipAttachmentCheck: true, skipSubjectCheck: true, delayedUntil: attachmentWarningDelayedUntil }); setAttachmentWarningDelayedUntil(undefined); }}>
|
||||
{t('forgot_attachment.send_anyway')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
isOpen={showEmptySubjectWarning}
|
||||
onClose={() => setShowEmptySubjectWarning(false)}
|
||||
role="alertdialog"
|
||||
showClose={false}
|
||||
title={t('empty_subject.title')}
|
||||
description={t('empty_subject.message')}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowEmptySubjectWarning(false);
|
||||
setEmptySubjectDelayedUntil(undefined);
|
||||
// After the focus trap tears down it restores focus to the
|
||||
// Send button, so claim the subject field on the next tick.
|
||||
setTimeout(() => subjectInputRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
{t('empty_subject.back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (emptySubjectDontAskAgain) updateSetting('emptySubjectWarningEnabled', false);
|
||||
emptySubjectConfirmedRef.current = true;
|
||||
setShowEmptySubjectWarning(false);
|
||||
handleSend({ skipSubjectCheck: true, delayedUntil: emptySubjectDelayedUntil });
|
||||
setEmptySubjectDelayedUntil(undefined);
|
||||
}}
|
||||
>
|
||||
{t('empty_subject.send_anyway')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className="mt-4 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={emptySubjectDontAskAgain}
|
||||
onChange={(e) => setEmptySubjectDontAskAgain(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{t('empty_subject.dont_ask_again')}
|
||||
</label>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog {...noDelayDialogProps} />
|
||||
<ConfirmDialog {...sendConfirmDialogProps} />
|
||||
|
||||
<Dialog
|
||||
isOpen={showPlaceholderWarning}
|
||||
onClose={() => setShowPlaceholderWarning(false)}
|
||||
role="alertdialog"
|
||||
showClose={false}
|
||||
title={t('placeholder_warning.title')}
|
||||
description={t('placeholder_warning.message')}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPlaceholderWarning(false);
|
||||
setPlaceholderWarningDelayedUntil(undefined);
|
||||
// The focus trap hands focus back to the Send button as it
|
||||
// tears down, so select on the next tick.
|
||||
const first = placeholderWarningMatches[0];
|
||||
setTimeout(() => { if (first) selectPlaceholder(first); }, 0);
|
||||
}}
|
||||
>
|
||||
{t('placeholder_warning.back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (placeholderDontAskAgain) updateSetting('placeholderWarningEnabled', false);
|
||||
placeholderWarningMatches.forEach((p) => confirmedPlaceholdersRef.current.add(p));
|
||||
setShowPlaceholderWarning(false);
|
||||
handleSend({ skipSubjectCheck: true, delayedUntil: placeholderWarningDelayedUntil });
|
||||
setPlaceholderWarningDelayedUntil(undefined);
|
||||
}}
|
||||
>
|
||||
{t('placeholder_warning.send_anyway')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ul className="mt-3 flex flex-wrap items-center gap-1.5" aria-label={t('placeholder_warning.found')}>
|
||||
{placeholderWarningMatches.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>
|
||||
))}
|
||||
{placeholderWarningMatches.length > 5 && (
|
||||
<li className="text-xs text-muted-foreground">
|
||||
{t('placeholder_warning.more', { count: placeholderWarningMatches.length - 5 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<label className="mt-4 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={placeholderDontAskAgain}
|
||||
onChange={(e) => setPlaceholderDontAskAgain(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{t('placeholder_warning.dont_ask_again')}
|
||||
</label>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
isOpen={proofreadingWarning !== null}
|
||||
onClose={() => setProofreadingWarning(null)}
|
||||
role="alertdialog"
|
||||
showClose={false}
|
||||
title={tProofreading('send_warning.title')}
|
||||
description={tProofreading('send_warning.body', { count: proofreadingWarning?.count ?? 0 })}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setProofreadingWarning(null);
|
||||
// After the focus trap hands focus back, go to the first issue.
|
||||
setTimeout(() => proofreading.navigate(1), 0);
|
||||
}}
|
||||
>
|
||||
{tProofreading('send_warning.review')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="proofreading-send-anyway"
|
||||
onClick={() => {
|
||||
const delayed = proofreadingWarning?.delayedUntil;
|
||||
proofreadingConfirmedRef.current = true;
|
||||
setProofreadingWarning(null);
|
||||
handleSend({ skipSubjectCheck: true, skipAttachmentCheck: true, delayedUntil: delayed });
|
||||
}}
|
||||
>
|
||||
{tProofreading('send_warning.send')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
<SendWarningsDialog
|
||||
warnings={sendWarnings?.items ?? null}
|
||||
onBack={() => {
|
||||
const first = sendWarnings?.items[0];
|
||||
setSendWarnings(null);
|
||||
// The focus trap hands focus back to the Send button as it tears
|
||||
// down: go to what the first warning is about on the next tick.
|
||||
setTimeout(() => {
|
||||
if (first?.kind === 'subject') subjectInputRef.current?.focus();
|
||||
else if (first?.kind === 'placeholders') selectPlaceholder(first.matches[0]);
|
||||
else if (first?.kind === 'proofreading') proofreading.navigate(1);
|
||||
else if (first?.kind === 'text') {
|
||||
if (plainTextModeRef.current) bodyRef.current?.focus();
|
||||
else editorRef.current?.commands.focus();
|
||||
}
|
||||
}, 0);
|
||||
}}
|
||||
onSendAnyway={(dontAskAgain) => {
|
||||
if (!sendWarnings) return;
|
||||
const { items, delayedUntil } = sendWarnings;
|
||||
for (const warning of items) {
|
||||
if (warning.kind === 'subject') {
|
||||
emptySubjectConfirmedRef.current = true;
|
||||
if (dontAskAgain) updateSetting('emptySubjectWarningEnabled', false);
|
||||
} else if (warning.kind === 'text') {
|
||||
emptyTextConfirmedRef.current = true;
|
||||
} else if (warning.kind === 'placeholders') {
|
||||
warning.matches.forEach((p) => confirmedPlaceholdersRef.current.add(p));
|
||||
if (dontAskAgain) updateSetting('placeholderWarningEnabled', false);
|
||||
} else if (warning.kind === 'proofreading') {
|
||||
proofreadingConfirmedRef.current = true;
|
||||
}
|
||||
}
|
||||
setSendWarnings(null);
|
||||
handleSend({ warningsConfirmed: true, delayedUntil });
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -44,6 +44,7 @@ import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-
|
||||
import { toast } from "@/stores/toast-store";
|
||||
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";
|
||||
@@ -2547,6 +2548,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;
|
||||
@@ -2554,7 +2566,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
|
||||
@@ -2639,11 +2661,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);
|
||||
@@ -2651,6 +2668,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;
|
||||
@@ -2941,9 +2960,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)
|
||||
@@ -2985,7 +3006,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 () => {
|
||||
@@ -5248,6 +5269,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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3896,6 +3896,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": "المؤسسة",
|
||||
|
||||
@@ -3896,6 +3896,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ó",
|
||||
|
||||
@@ -3896,6 +3896,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",
|
||||
|
||||
@@ -3896,6 +3896,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",
|
||||
|
||||
@@ -3896,6 +3896,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",
|
||||
|
||||
@@ -3896,6 +3896,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",
|
||||
|
||||
@@ -3896,6 +3896,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",
|
||||
|
||||
@@ -3896,6 +3896,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",
|
||||
|
||||
@@ -3896,6 +3896,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": "سازمان",
|
||||
|
||||
@@ -3896,6 +3896,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",
|
||||
|
||||
@@ -3793,6 +3793,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": "ארגון",
|
||||
|
||||
@@ -3896,6 +3896,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",
|
||||
|
||||
@@ -3896,6 +3896,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",
|
||||
|
||||
@@ -3896,6 +3896,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": "組織",
|
||||
|
||||
@@ -3896,6 +3896,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": "조직",
|
||||
|
||||
@@ -3896,6 +3896,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Atpakaļ pie rediģēšanas",
|
||||
"send_anyway": "Sūtīt tik un tā",
|
||||
"warnings_title": "Vai tomēr sūtīt šo vēstuli?",
|
||||
"warning_subject": "Nav temata",
|
||||
"warning_text": "Nav teksta",
|
||||
"warning_attachment": "Piemin “{keyword}”, bet neviens fails nav pievienots",
|
||||
"warning_placeholders": "Vietturu teksts, ko redzēs saņēmēji:",
|
||||
"warning_proofreading": "Neatrisinātas pareizrakstības vai gramatikas kļūdas: {count}",
|
||||
"empty_text_title": "Sūtīt bez teksta?",
|
||||
"empty_text_message": "Šai vēstulei nav ne teksta, ne pielikuma. Vai tomēr to sūtīt?",
|
||||
"scheduled_for": "Ieplānots: {when}",
|
||||
"sending": "Sūta…",
|
||||
"sent": "Nosūtīts",
|
||||
"view": "Skatīt",
|
||||
"picker_button": "Pievienot no kontaktiem",
|
||||
"picker_title": "Pievienot saņēmējus",
|
||||
"tab_contacts": "Kontakti",
|
||||
"tab_directory": "Katalogs",
|
||||
"contacts_empty_title": "Vēl nav kontaktu",
|
||||
"contacts_empty_hint": "Šeit redzami cilvēki, kurus saglabājat kontaktos.",
|
||||
"contacts_no_match": "Meklēšanai neatbilst neviens kontakts",
|
||||
"template_name": "Veidnes nosaukums",
|
||||
"template_edit_details": "Rediģēt detaļas…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Manas veidnes",
|
||||
"section_organisation": "Organizācija",
|
||||
|
||||
@@ -3896,6 +3896,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": "Байгууллага",
|
||||
|
||||
@@ -3896,6 +3896,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Tilbake til redigering",
|
||||
"send_anyway": "Send likevel",
|
||||
"warnings_title": "Sende denne meldingen likevel?",
|
||||
"warning_subject": "Ingen emne",
|
||||
"warning_text": "Ingen tekst",
|
||||
"warning_attachment": "Nevner «{keyword}», men ingen fil er lagt ved",
|
||||
"warning_placeholders": "Plassholdertekst som mottakerne vil se:",
|
||||
"warning_proofreading": "Åpne stave- eller grammatikkfeil: {count}",
|
||||
"empty_text_title": "Sende uten tekst?",
|
||||
"empty_text_message": "Denne meldingen har verken tekst eller vedlegg. Sende den likevel?",
|
||||
"scheduled_for": "Planlagt til {when}",
|
||||
"sending": "Sender…",
|
||||
"sent": "Sendt",
|
||||
"view": "Vis",
|
||||
"picker_button": "Legg til fra kontakter",
|
||||
"picker_title": "Legg til mottakere",
|
||||
"tab_contacts": "Kontakter",
|
||||
"tab_directory": "Katalog",
|
||||
"contacts_empty_title": "Ingen kontakter ennå",
|
||||
"contacts_empty_hint": "Personer du lagrer i Kontakter, vises her.",
|
||||
"contacts_no_match": "Ingen kontakter samsvarer med søket",
|
||||
"template_name": "Navn på mal",
|
||||
"template_edit_details": "Rediger detaljer…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mine maler",
|
||||
"section_organisation": "Organisasjon",
|
||||
|
||||
@@ -3896,6 +3896,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Terug naar bewerken",
|
||||
"send_anyway": "Toch verzenden",
|
||||
"warnings_title": "Dit bericht toch verzenden?",
|
||||
"warning_subject": "Geen onderwerp",
|
||||
"warning_text": "Geen tekst",
|
||||
"warning_attachment": "Noemt ‘{keyword}’, maar er is geen bestand bijgevoegd",
|
||||
"warning_placeholders": "Tijdelijke tekst die ontvangers zien:",
|
||||
"warning_proofreading": "Openstaande spel- of grammaticafouten: {count}",
|
||||
"empty_text_title": "Verzenden zonder tekst?",
|
||||
"empty_text_message": "Dit bericht heeft geen tekst en geen bijlage. Toch verzenden?",
|
||||
"scheduled_for": "Gepland voor {when}",
|
||||
"sending": "Bezig met verzenden…",
|
||||
"sent": "Verzonden",
|
||||
"view": "Bekijken",
|
||||
"picker_button": "Toevoegen uit contacten",
|
||||
"picker_title": "Ontvangers toevoegen",
|
||||
"tab_contacts": "Contacten",
|
||||
"tab_directory": "Adresboek van de organisatie",
|
||||
"contacts_empty_title": "Nog geen contacten",
|
||||
"contacts_empty_hint": "Mensen die u in Contacten opslaat, verschijnen hier.",
|
||||
"contacts_no_match": "Geen contact komt overeen met uw zoekopdracht",
|
||||
"template_name": "Naam van sjabloon",
|
||||
"template_edit_details": "Details bewerken…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mijn sjablonen",
|
||||
"section_organisation": "Organisatie",
|
||||
|
||||
@@ -3896,6 +3896,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Wróć do edycji",
|
||||
"send_anyway": "Wyślij mimo to",
|
||||
"warnings_title": "Wysłać tę wiadomość mimo to?",
|
||||
"warning_subject": "Brak tematu",
|
||||
"warning_text": "Brak treści",
|
||||
"warning_attachment": "Wspomina „{keyword}”, ale nie dołączono pliku",
|
||||
"warning_placeholders": "Tekst zastępczy, który zobaczą odbiorcy:",
|
||||
"warning_proofreading": "Nierozwiązane błędy pisowni lub gramatyki: {count}",
|
||||
"empty_text_title": "Wysłać bez treści?",
|
||||
"empty_text_message": "Ta wiadomość nie ma treści ani załącznika. Wysłać ją mimo to?",
|
||||
"scheduled_for": "Zaplanowano na {when}",
|
||||
"sending": "Wysyłanie…",
|
||||
"sent": "Wysłano",
|
||||
"view": "Pokaż",
|
||||
"picker_button": "Dodaj z kontaktów",
|
||||
"picker_title": "Dodaj odbiorców",
|
||||
"tab_contacts": "Kontakty",
|
||||
"tab_directory": "Katalog",
|
||||
"contacts_empty_title": "Brak kontaktów",
|
||||
"contacts_empty_hint": "Tutaj pojawią się osoby zapisane w Kontaktach.",
|
||||
"contacts_no_match": "Żaden kontakt nie pasuje do wyszukiwania",
|
||||
"template_name": "Nazwa szablonu",
|
||||
"template_edit_details": "Edytuj szczegóły…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Moje szablony",
|
||||
"section_organisation": "Organizacja",
|
||||
|
||||
@@ -3896,6 +3896,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Voltar à edição",
|
||||
"send_anyway": "Enviar mesmo assim",
|
||||
"warnings_title": "Enviar esta mensagem mesmo assim?",
|
||||
"warning_subject": "Sem assunto",
|
||||
"warning_text": "Sem texto",
|
||||
"warning_attachment": "Menciona \"{keyword}\", mas nenhum arquivo foi anexado",
|
||||
"warning_placeholders": "Texto de espaço reservado que os destinatários verão:",
|
||||
"warning_proofreading": "Erros de ortografia ou gramática em aberto: {count}",
|
||||
"empty_text_title": "Enviar sem texto?",
|
||||
"empty_text_message": "Esta mensagem não tem texto nem anexo. Enviar mesmo assim?",
|
||||
"scheduled_for": "Agendado para {when}",
|
||||
"sending": "Enviando…",
|
||||
"sent": "Enviado",
|
||||
"view": "Ver",
|
||||
"picker_button": "Adicionar dos contatos",
|
||||
"picker_title": "Adicionar destinatários",
|
||||
"tab_contacts": "Contatos",
|
||||
"tab_directory": "Diretório",
|
||||
"contacts_empty_title": "Nenhum contato ainda",
|
||||
"contacts_empty_hint": "As pessoas que você salvar em Contatos aparecem aqui.",
|
||||
"contacts_no_match": "Nenhum contato corresponde à sua pesquisa",
|
||||
"template_name": "Nome do modelo",
|
||||
"template_edit_details": "Editar detalhes…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Meus modelos",
|
||||
"section_organisation": "Organização",
|
||||
|
||||
@@ -3896,6 +3896,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Înapoi la editare",
|
||||
"send_anyway": "Trimite oricum",
|
||||
"warnings_title": "Trimiteți totuși acest mesaj?",
|
||||
"warning_subject": "Fără subiect",
|
||||
"warning_text": "Fără text",
|
||||
"warning_attachment": "Menționează „{keyword}”, dar niciun fișier nu este atașat",
|
||||
"warning_placeholders": "Text substituent pe care îl vor vedea destinatarii:",
|
||||
"warning_proofreading": "Greșeli de ortografie sau gramatică nerezolvate: {count}",
|
||||
"empty_text_title": "Trimiteți fără text?",
|
||||
"empty_text_message": "Acest mesaj nu are text și nici atașament. Îl trimiteți totuși?",
|
||||
"scheduled_for": "Programat pentru {when}",
|
||||
"sending": "Se trimite…",
|
||||
"sent": "Trimis",
|
||||
"view": "Vezi",
|
||||
"picker_button": "Adaugă din contacte",
|
||||
"picker_title": "Adaugă destinatari",
|
||||
"tab_contacts": "Contacte",
|
||||
"tab_directory": "Director",
|
||||
"contacts_empty_title": "Încă nu există contacte",
|
||||
"contacts_empty_hint": "Aici apar persoanele pe care le salvați în Contacte.",
|
||||
"contacts_no_match": "Niciun contact nu corespunde căutării",
|
||||
"template_name": "Numele șablonului",
|
||||
"template_edit_details": "Editează detaliile…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Șabloanele mele",
|
||||
"section_organisation": "Organizație",
|
||||
|
||||
@@ -3896,6 +3896,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": "Организация",
|
||||
|
||||
@@ -3896,6 +3896,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Späť na úpravu",
|
||||
"send_anyway": "Odoslať aj tak",
|
||||
"warnings_title": "Odoslať túto správu aj tak?",
|
||||
"warning_subject": "Bez predmetu",
|
||||
"warning_text": "Bez textu",
|
||||
"warning_attachment": "Spomína „{keyword}“, ale nie je pripojený žiadny súbor",
|
||||
"warning_placeholders": "Zástupný text, ktorý uvidia príjemcovia:",
|
||||
"warning_proofreading": "Neopravené pravopisné alebo gramatické chyby: {count}",
|
||||
"empty_text_title": "Odoslať bez textu?",
|
||||
"empty_text_message": "Táto správa nemá text ani prílohu. Odoslať ju aj tak?",
|
||||
"scheduled_for": "Naplánované na {when}",
|
||||
"sending": "Odosielanie…",
|
||||
"sent": "Odoslané",
|
||||
"view": "Zobraziť",
|
||||
"picker_button": "Pridať z kontaktov",
|
||||
"picker_title": "Pridať príjemcov",
|
||||
"tab_contacts": "Kontakty",
|
||||
"tab_directory": "Adresár organizácie",
|
||||
"contacts_empty_title": "Zatiaľ žiadne kontakty",
|
||||
"contacts_empty_hint": "Tu sa zobrazia ľudia, ktorých uložíte do Kontaktov.",
|
||||
"contacts_no_match": "Vyhľadávaniu nezodpovedá žiadny kontakt",
|
||||
"template_name": "Názov šablóny",
|
||||
"template_edit_details": "Upraviť podrobnosti…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Moje šablóny",
|
||||
"section_organisation": "Organizácia",
|
||||
|
||||
@@ -1147,6 +1147,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": "องค์กร",
|
||||
|
||||
@@ -3896,6 +3896,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Düzenlemeye dön",
|
||||
"send_anyway": "Yine de gönder",
|
||||
"warnings_title": "Bu ileti yine de gönderilsin mi?",
|
||||
"warning_subject": "Konu yok",
|
||||
"warning_text": "Metin yok",
|
||||
"warning_attachment": "\"{keyword}\" geçiyor ama hiç dosya eklenmemiş",
|
||||
"warning_placeholders": "Alıcıların göreceği yer tutucu metin:",
|
||||
"warning_proofreading": "Açık yazım veya dilbilgisi sorunları: {count}",
|
||||
"empty_text_title": "Metin olmadan gönderilsin mi?",
|
||||
"empty_text_message": "Bu iletide metin ve ek yok. Yine de gönderilsin mi?",
|
||||
"scheduled_for": "{when} için zamanlandı",
|
||||
"sending": "Gönderiliyor…",
|
||||
"sent": "Gönderildi",
|
||||
"view": "Görüntüle",
|
||||
"picker_button": "Kişilerden ekle",
|
||||
"picker_title": "Alıcı ekle",
|
||||
"tab_contacts": "Kişiler",
|
||||
"tab_directory": "Dizin",
|
||||
"contacts_empty_title": "Henüz kişi yok",
|
||||
"contacts_empty_hint": "Kişiler'e kaydettiğiniz kişiler burada görünür.",
|
||||
"contacts_no_match": "Aramanızla eşleşen kişi yok",
|
||||
"template_name": "Şablon adı",
|
||||
"template_edit_details": "Ayrıntıları düzenle…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Şablonlarım",
|
||||
"section_organisation": "Kuruluş",
|
||||
|
||||
@@ -3896,6 +3896,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": "Організація",
|
||||
|
||||
@@ -3908,6 +3908,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"compose_send": {
|
||||
"back": "Quay lại chỉnh sửa",
|
||||
"send_anyway": "Vẫn gửi",
|
||||
"warnings_title": "Vẫn gửi thư này?",
|
||||
"warning_subject": "Không có tiêu đề",
|
||||
"warning_text": "Không có nội dung",
|
||||
"warning_attachment": "Có nhắc đến \"{keyword}\" nhưng chưa đính kèm tệp nào",
|
||||
"warning_placeholders": "Văn bản giữ chỗ mà người nhận sẽ thấy:",
|
||||
"warning_proofreading": "Lỗi chính tả hoặc ngữ pháp chưa sửa: {count}",
|
||||
"empty_text_title": "Gửi mà không có nội dung?",
|
||||
"empty_text_message": "Thư này không có nội dung và không có tệp đính kèm. Vẫn gửi?",
|
||||
"scheduled_for": "Đã lên lịch gửi lúc {when}",
|
||||
"sending": "Đang gửi…",
|
||||
"sent": "Đã gửi",
|
||||
"view": "Xem",
|
||||
"picker_button": "Thêm từ danh bạ",
|
||||
"picker_title": "Thêm người nhận",
|
||||
"tab_contacts": "Danh bạ",
|
||||
"tab_directory": "Danh bạ tổ chức",
|
||||
"contacts_empty_title": "Chưa có liên hệ nào",
|
||||
"contacts_empty_hint": "Những người bạn lưu trong Danh bạ sẽ hiện ở đây.",
|
||||
"contacts_no_match": "Không có liên hệ nào khớp với tìm kiếm",
|
||||
"template_name": "Tên mẫu",
|
||||
"template_edit_details": "Sửa chi tiết…"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mẫu của tôi",
|
||||
"section_organisation": "Tổ chức",
|
||||
|
||||
@@ -3896,6 +3896,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": "組織",
|
||||
|
||||
@@ -3896,6 +3896,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": "组织",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// isLoading is the message list's: while it is set, the list fades under
|
||||
// "Loading emails…". A send set it too, so every send reloaded the list on
|
||||
// screen until the message was out (compose-24).
|
||||
|
||||
describe('the message list during a send', () => {
|
||||
let client: IJMAPClient;
|
||||
let finishSend: (value: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
client = {
|
||||
getAccountId: () => 'acc-1',
|
||||
pruneEmailSubmissions: vi.fn().mockResolvedValue(0),
|
||||
sendEmail: vi.fn(() => new Promise((resolve) => { finishSend = resolve; })),
|
||||
getAllMailboxesWithState: vi.fn().mockResolvedValue({ mailboxes: [], states: {} }),
|
||||
} as unknown as IJMAPClient;
|
||||
useAuthStore.setState({ client: null });
|
||||
useEmailStore.setState({ isLoading: false, pendingUndoSend: null });
|
||||
});
|
||||
|
||||
it('is not put in its loading state while the message goes out', async () => {
|
||||
const sending = useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body');
|
||||
|
||||
expect(useEmailStore.getState().isLoading).toBe(false);
|
||||
|
||||
finishSend({ scheduled: false, emailId: 'sent-1' });
|
||||
await sending;
|
||||
expect(useEmailStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps loading a folder that a send finished in the middle of', async () => {
|
||||
useEmailStore.setState({ isLoading: true });
|
||||
const sending = useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body');
|
||||
finishSend({ scheduled: false, emailId: 'sent-1' });
|
||||
await sending;
|
||||
|
||||
expect(useEmailStore.getState().isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4399,7 +4399,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const outbox = options?.outbox ? getOutboxHooks() : null;
|
||||
const queueArgs = () => [to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options];
|
||||
if (outbox?.shouldQueue(client)) return outbox.queueEmail(client, queueArgs());
|
||||
set({ isLoading: true, error: null });
|
||||
// Not isLoading: that is the message list's own, and a send put the list
|
||||
// under "Loading emails…" until the message was out.
|
||||
set({ error: null });
|
||||
try {
|
||||
// Files attached while offline are uploaded now that the server answers.
|
||||
const sendAttachments = attachments && getOutboxHooks() ? await getOutboxHooks()!.resolveHeldAttachments(client, attachments) : attachments;
|
||||
@@ -4414,7 +4416,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
if (useAuthStore.getState().client === client) void get().fetchMailboxes(client);
|
||||
// Refresh handled by UI layer for immediate feedback
|
||||
set({
|
||||
isLoading: false,
|
||||
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
|
||||
? {
|
||||
submissionId: result.emailSubmissionId,
|
||||
@@ -4435,12 +4436,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// (the client says so: SendNotStartedError): keep it. Any other
|
||||
// failure may have come after the server took the message.
|
||||
if (outbox && error instanceof Error && error.name === 'SendNotStartedError' && outbox.shouldQueue(client)) {
|
||||
set({ isLoading: false });
|
||||
return outbox.queueEmail(client, queueArgs());
|
||||
}
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to send email",
|
||||
isLoading: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -4451,7 +4450,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// encrypted with the user present, so the finished message can wait.
|
||||
const outbox = options?.outbox ? getOutboxHooks() : null;
|
||||
if (outbox?.shouldQueue(client)) return outbox.queueRaw(client, rawMimeBlob, { identityId, delayedUntil, envelopeRecipients });
|
||||
set({ isLoading: true, error: null });
|
||||
// Not isLoading, as in sendEmail.
|
||||
set({ error: null });
|
||||
try {
|
||||
const mailboxes = await client.getMailboxes();
|
||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||
@@ -4460,7 +4460,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients);
|
||||
void client.pruneEmailSubmissions();
|
||||
set({
|
||||
isLoading: false,
|
||||
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
|
||||
? { submissionId: result.emailSubmissionId, emailId: result.emailId, identityId, sendAt: result.sendAt, isSmime: true, submissionAccountId: result.submissionAccountId }
|
||||
: get().pendingUndoSend,
|
||||
@@ -4469,7 +4468,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to send email",
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user