mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/desktop-offline' into feat/do-ux-p2-mail-fix
# Conflicts: # components/email/email-viewer.tsx # components/email/thread-conversation-view.tsx # components/mail/mail-app.tsx # components/ui/toast.tsx # lib/keyboard-shortcuts/mail.ts # lib/window-controls-overlay.ts
This commit is contained in:
@@ -115,6 +115,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { findMeetingLink } from "@/lib/event-links";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { useDesktopWindowName } from "@/lib/desktop/window-name";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
@@ -161,6 +162,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const tOffline = useTranslations("calendar_offline");
|
||||
useDocumentTitle(tSidebar("calendar"));
|
||||
useDesktopWindowName(tSidebar("calendar"), { withAccount: true });
|
||||
const skeletonLabels = useSkeletonLabels();
|
||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||
const { demoMode } = useConfig();
|
||||
|
||||
@@ -60,6 +60,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { useDesktopWindowName } from "@/lib/desktop/window-name";
|
||||
import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
@@ -110,6 +111,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const tShell = useTranslations("shell");
|
||||
const tMerge = useTranslations("ux_pim_cimport");
|
||||
useDocumentTitle(tSidebar("contacts"));
|
||||
useDesktopWindowName(tSidebar("contacts"), { withAccount: true });
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
|
||||
@@ -253,6 +253,12 @@ describe('recipient fields (SR-06, CV-02)', () => {
|
||||
expect(screen.getByRole('list', { name: 'to:' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// compose-p2-18: the ring outside the input was cut by the field's scroller.
|
||||
it("draws the To field's keyboard focus ring inside it", () => {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
expect(screen.getByRole('combobox', { name: 'to:' })).toHaveClass('focus-inset');
|
||||
});
|
||||
|
||||
it('labels the subject field', () => {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
expect(screen.getByLabelText('subject_label')).toBe(screen.getByTestId('composer-subject'));
|
||||
@@ -394,6 +400,21 @@ describe('moving a recipient by keyboard (MO-07)', () => {
|
||||
expect(liveRegion()).toHaveTextContent('recipient_moved:Anna Berg|cc');
|
||||
});
|
||||
|
||||
// compose-p2-16: a click did nothing visible, and Escape then closed the composer.
|
||||
it('opens the chip menu on a click, and Escape closes only the menu', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<EmailComposer initialData={{ ...DRAFT, to: 'anna@example.com' }} onClose={onClose} />);
|
||||
const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem');
|
||||
fireEvent.click(chip.querySelector('.truncate')!);
|
||||
|
||||
const move = await screen.findByTestId('recipient-move-bcc');
|
||||
fireEvent.keyDown(move, { key: 'Escape' });
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('recipient-move-bcc')).toBeNull());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens the chip menu with Shift+F10 too', async () => {
|
||||
render(<EmailComposer initialData={{ ...DRAFT, to: 'anna@example.com' }} />);
|
||||
const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem');
|
||||
|
||||
@@ -259,6 +259,16 @@ describe('attachment list', () => {
|
||||
expect(row.className).toContain('max-h-[');
|
||||
});
|
||||
|
||||
// compose-p2-11: 5.25rem showed two rows of 2rem chips and half a third.
|
||||
it('is as high as whole rows of chips, so none is cut in half', () => {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
|
||||
const heights = [...chipRow().className.matchAll(/max-h-\[([\d.]+)rem\]/g)].map((m) => Number(m[1]));
|
||||
expect(heights.length).toBeGreaterThan(0);
|
||||
// n rows of 2rem chips with 0.5rem gaps: 2.5n - 0.5.
|
||||
for (const height of heights) expect(Number.isInteger((height + 0.5) / 2.5)).toBe(true);
|
||||
});
|
||||
|
||||
it('says how many files and how much they weigh', () => {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { StrictMode, useState } from 'react';
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { EmailComposer, type ComposerDraftData } from '@/components/email/email-composer';
|
||||
@@ -134,9 +134,10 @@ async function openComposer(strict = false) {
|
||||
add: async (...files: File[]) => {
|
||||
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files } }); await advance();
|
||||
},
|
||||
dialog: (control: Control) => {
|
||||
// The bin asks nothing (compose-p2-10).
|
||||
discard: (control: Control) => {
|
||||
fireEvent.click(screen.getByTestId(control === 'toolbar' ? 'composer-discard' : 'composer-close'));
|
||||
return within(screen.getByRole('alertdialog'));
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
},
|
||||
reopen: async () => { act(() => reopen()); await advance(); },
|
||||
};
|
||||
@@ -167,7 +168,7 @@ describe('explicit Discard with pending attachment uploads', () => {
|
||||
void upload.mock.results[0].value.then(settled, settled);
|
||||
expect([...resources.files.values()].map(f => f.name)).toEqual(['A.txt']);
|
||||
expect(resources.progress.size).toBe(1);
|
||||
fireEvent.click(c.dialog(control).getByRole('button', { name: /^Discard$/ })); await advance();
|
||||
c.discard(control); await advance();
|
||||
await c.reopen(); await c.add(file('B.txt')); await complete(1);
|
||||
expectNewComposer();
|
||||
|
||||
@@ -197,17 +198,6 @@ describe('explicit Discard with pending attachment uploads', () => {
|
||||
expect(resources.files.size).toBe(0); expect(resources.progress.size).toBe(0);
|
||||
expectNewComposer(); expect(c.closed).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps active and queued uploads when the close/discard dialog is cancelled', async () => {
|
||||
const uploaded = vi.spyOn(emailHooks.onAfterAttachmentUpload, 'emit');
|
||||
const c = await openComposer(strict); await c.add(file('A.txt'), file('C.txt'));
|
||||
const dialog = c.dialog(control);
|
||||
expect(UploadXHR.instances[0].abort).not.toHaveBeenCalled();
|
||||
fireEvent.click(dialog.getByRole('button', { name: /^Cancel$/ })); await advance();
|
||||
await complete(0); expect(UploadXHR.instances[1].body?.name).toBe('C.txt'); await complete(1);
|
||||
expect(uploaded.mock.calls.map(([attachment]) => attachment.name)).toEqual(['A.txt', 'C.txt']);
|
||||
expect(c.closed).not.toHaveBeenCalled(); expect(resources.files.size).toBe(0); expect(resources.progress.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['saveFile', 'getFile', 'deleteFile'] as const)('cancels while asynchronous %s is pending', async method => {
|
||||
@@ -234,7 +224,7 @@ describe('explicit Discard with pending attachment uploads', () => {
|
||||
await c.add(file('A.txt'), file('C.txt'));
|
||||
if (method === 'deleteFile') await complete(0);
|
||||
expect(entered).toHaveBeenCalledOnce();
|
||||
fireEvent.click(c.dialog('toolbar').getByRole('button', { name: /^Discard$/ })); await advance();
|
||||
c.discard('toolbar'); await advance();
|
||||
await c.reopen();
|
||||
await act(async () => { gate.resolve(); }); await advance();
|
||||
await c.add(file('B.txt')); await complete(UploadXHR.instances.findIndex(xhr => xhr.body?.name === 'B.txt'));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
@@ -7,6 +7,8 @@ import { EmailComposer } from '../email-composer';
|
||||
|
||||
const editorKeyDown = vi.hoisted(() => vi.fn());
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
const discardWithUndo = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/compose/draft-close-toast', () => ({ discardWithUndo, showDraftSavedToast: vi.fn() }));
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', {
|
||||
@@ -209,27 +211,56 @@ const REOPENED_DRAFT = {
|
||||
|
||||
describe('composer Discard button', () => {
|
||||
beforeEach(() => {
|
||||
discardWithUndo.mockClear();
|
||||
toastError.mockClear();
|
||||
updateSetting.mockClear();
|
||||
editorKeyDown.mockClear();
|
||||
});
|
||||
|
||||
it('deletes a reopened draft without unsaved changes after confirming', async () => {
|
||||
// compose-p2-10: no question; "Draft discarded" with Undo, as the toast's Discard.
|
||||
it('closes at once and deletes a reopened draft once Undo is over', () => {
|
||||
const onDiscardDraft = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<EmailComposer initialData={REOPENED_DRAFT} onDiscardDraft={onDiscardDraft} onClose={onClose} />);
|
||||
|
||||
fireEvent.click(screen.getByText('discard'));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('discard_draft_title')).toBeInTheDocument();
|
||||
// The user already chose to discard; there is nothing to save.
|
||||
expect(within(dialog).queryByText('save')).toBeNull();
|
||||
|
||||
fireEvent.click(within(dialog).getByText('discard'));
|
||||
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(discardWithUndo).toHaveBeenCalledTimes(1);
|
||||
const [commit, labels] = discardWithUndo.mock.calls[0];
|
||||
expect(labels).toEqual({ discarded: 'draft_discarded', undo: 'undo' });
|
||||
// Not before the Undo time is over.
|
||||
expect(onDiscardDraft).not.toHaveBeenCalled();
|
||||
commit();
|
||||
expect(onDiscardDraft).toHaveBeenCalledWith('draft-1', { accountId: undefined, localAccountId: undefined });
|
||||
});
|
||||
|
||||
it('brings the text back on Undo, and deletes nothing', () => {
|
||||
const onDiscardDraft = vi.fn();
|
||||
const onRestoreDiscarded = vi.fn();
|
||||
render(<EmailComposer initialData={REOPENED_DRAFT} onDiscardDraft={onDiscardDraft} onRestoreDiscarded={onRestoreDiscarded} onClose={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByText('discard'));
|
||||
const restore = discardWithUndo.mock.calls[0][2] as () => void;
|
||||
restore();
|
||||
|
||||
expect(onRestoreDiscarded).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Lunch', draftId: 'draft-1', to: 'bob@example.com' }));
|
||||
expect(onDiscardDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has the main window say it for a composer in its own window', () => {
|
||||
const onDraftDiscarded = vi.fn();
|
||||
const onDiscardDraft = vi.fn();
|
||||
render(<EmailComposer initialData={REOPENED_DRAFT} onDiscardDraft={onDiscardDraft} ownWindow={{ onDraftDiscarded }} onClose={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByText('discard'));
|
||||
|
||||
expect(onDraftDiscarded).toHaveBeenCalledWith('draft-1', { accountId: undefined, localAccountId: undefined });
|
||||
expect(discardWithUndo).not.toHaveBeenCalled();
|
||||
expect(onDiscardDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes an empty, never saved composer without asking', () => {
|
||||
const onDiscardDraft = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
@@ -112,6 +112,15 @@ function target(shown = true): ComposerPopOut & { handed: ComposerDraftData[]; c
|
||||
|
||||
const DRAFT: ComposerDraftData = { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null };
|
||||
|
||||
describe('"Move to the main window" in quick compose', () => {
|
||||
it('shows an arrow going in, not the arrows of leaving full screen', () => {
|
||||
mount({ popOut: vi.fn(() => target()), popOutToMainLabel: 'Move to the main window' });
|
||||
const svg = screen.getByTestId('composer-back-to-main').querySelector('svg');
|
||||
expect(svg?.getAttribute('class')).toContain('tabler-icon-login');
|
||||
expect(svg?.getAttribute('class')).not.toContain('minimize');
|
||||
});
|
||||
});
|
||||
|
||||
describe('"Open in new window" on a composer', () => {
|
||||
it('is not there without the host offering it: browsers stay as they are', () => {
|
||||
mount({});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, createEvent, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act, createEvent, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
@@ -332,7 +332,7 @@ describe('composer-send-menu', () => {
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
it('opens the schedule dialog straight from the arrow when it is the only entry', async () => {
|
||||
useAuthStore.setState({
|
||||
client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
|
||||
});
|
||||
@@ -343,7 +343,24 @@ describe('composer-send-menu', () => {
|
||||
fireEvent.click(arrow);
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull();
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('schedule_send_description');
|
||||
// After the send checks, which found nothing here (compose-p2-14).
|
||||
expect(await screen.findByRole('dialog')).toHaveTextContent('schedule_send_description');
|
||||
});
|
||||
|
||||
// compose-p2-14: the checks opened on top of the picked time and said
|
||||
// "Send anyway" though nothing was sent then.
|
||||
it('runs the send checks before the schedule dialog, and says "Schedule anyway"', async () => {
|
||||
useAuthStore.setState({
|
||||
client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
|
||||
});
|
||||
render(<EmailComposer initialData={{ ...DRAFT, subject: '' }} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'schedule_send' })[0]);
|
||||
const warnings = await screen.findByRole('alertdialog');
|
||||
expect(screen.queryByText('schedule_send_description')).toBeNull();
|
||||
fireEvent.click(within(warnings).getByRole('button', { name: 'schedule_anyway' }));
|
||||
|
||||
expect(await screen.findByText('schedule_send_description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a plain Send button without plugin entries or scheduled send', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { render, act, fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
@@ -9,17 +9,27 @@ import { useSettingsStore } from '@/stores/settings-store';
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
const editorFocus = vi.hoisted(() => vi.fn());
|
||||
const caret = vi.hoisted(() => ({ textEnd: null as number | null, selected: [] as number[], focused: 0 }));
|
||||
|
||||
// Reports a stub editor as ready, the way the real one does once mounted.
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: ({ onEditorReady }: { onEditorReady?: (editor: unknown) => void }) => {
|
||||
React.useEffect(() => {
|
||||
onEditorReady?.({ commands: { focus: editorFocus, setContent: () => {} }, isDestroyed: false });
|
||||
const chain = {
|
||||
setTextSelection: (pos: number) => { caret.selected.push(pos); return chain; },
|
||||
focus: () => { caret.focused += 1; return chain; },
|
||||
run: () => true,
|
||||
};
|
||||
onEditorReady?.({ commands: { focus: editorFocus, setContent: () => {} }, isDestroyed: false, isEditable: true, state: {}, chain: () => chain, on: () => {}, off: () => {} });
|
||||
}, [onEditorReady]);
|
||||
return React.createElement('div', { 'data-testid': 'rich-text-editor' });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/email/reply-quote-collapse', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/components/email/reply-quote-collapse')>()),
|
||||
userTextEnd: () => caret.textEnd,
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
@@ -234,3 +244,41 @@ describe('composer initial focus', () => {
|
||||
expect(document.activeElement?.tagName).toBe('TEXTAREA');
|
||||
});
|
||||
});
|
||||
|
||||
// compose-p2-9: the white space under the text is the message too.
|
||||
describe('a click in the empty space under the text', () => {
|
||||
beforeEach(() => {
|
||||
editorFocus.mockClear();
|
||||
caret.textEnd = null;
|
||||
caret.selected = [];
|
||||
caret.focused = 0;
|
||||
useSettingsStore.setState({ plainTextMode: false });
|
||||
});
|
||||
|
||||
const emptySpace = () => screen.getByTestId('rich-text-editor').parentElement!.parentElement!;
|
||||
|
||||
it('puts the caret at the end of the text above the folded quote', () => {
|
||||
caret.textEnd = 7;
|
||||
render(<EmailComposer initialData={{ ...DATA, mode: 'reply' }} mode="reply" />);
|
||||
editorFocus.mockClear();
|
||||
const event = fireEvent.mouseDown(emptySpace(), { button: 0 });
|
||||
expect(event).toBe(false);
|
||||
expect(caret.selected).toEqual([7]);
|
||||
expect(caret.focused).toBe(1);
|
||||
});
|
||||
|
||||
it('puts the caret at the end of the body when nothing is folded', () => {
|
||||
render(<EmailComposer initialData={{ ...DATA, mode: 'compose' }} mode="compose" />);
|
||||
fireEvent.mouseDown(emptySpace(), { button: 0 });
|
||||
expect(editorFocus).toHaveBeenCalledWith('end');
|
||||
});
|
||||
|
||||
it('leaves clicks on buttons alone', () => {
|
||||
caret.textEnd = 7;
|
||||
render(<EmailComposer initialData={{ ...DATA, mode: 'reply' }} mode="reply" />);
|
||||
const button = document.createElement('button');
|
||||
emptySpace().appendChild(button);
|
||||
fireEvent.mouseDown(button, { button: 0 });
|
||||
expect(caret.selected).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,8 +147,9 @@ vi.mock('@/stores/contact-store', () => {
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
const toastSuccess = vi.hoisted(() => ({ calls: [] as unknown[][] }));
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
toast: { info: () => {}, error: () => {}, success: (...args: unknown[]) => { toastSuccess.calls.push(args); } },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
@@ -341,6 +342,17 @@ describe('applying a template to a reply (#911)', () => {
|
||||
expect(screen.queryByTestId('quick-save-template')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// compose-p2-15: the dialog closed with no sign the template was kept.
|
||||
it('says "Template saved"', async () => {
|
||||
useTemplateStore.setState({ templates: [] });
|
||||
toastSuccess.calls = [];
|
||||
await openReply();
|
||||
const dialog = await openNameDialog();
|
||||
fireEvent.change(within(dialog).getByLabelText('template_name'), { target: { value: 'Quick answer' } });
|
||||
fireEvent.click(within(dialog).getByText('save'));
|
||||
await waitFor(() => expect(toastSuccess.calls).toContainEqual(['template_saved']));
|
||||
});
|
||||
|
||||
it('carries the name over into the full editor', async () => {
|
||||
await openReply();
|
||||
const dialog = await openNameDialog();
|
||||
|
||||
@@ -232,6 +232,8 @@ describe('full screen', () => {
|
||||
expect(root).toHaveAttribute('data-maximized');
|
||||
expect(root.className).toMatch(/(^|\s)fixed(\s|$)/);
|
||||
expect(root.className).toContain('inset-0');
|
||||
// The folder sidebar sits at z-50 (compose-p2-5): at 40 it covered the composer's left part.
|
||||
expect(root.className).toMatch(/(^|\s)z-50(\s|$)/);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'restore' }));
|
||||
expect(root).not.toHaveAttribute('data-maximized');
|
||||
|
||||
@@ -5,7 +5,7 @@ import Paragraph from '@tiptap/extension-paragraph';
|
||||
import { trailingParagraphBreakRule } from '../paragraph-breaks';
|
||||
import { QuotedHtml, buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html';
|
||||
import { SignatureBlock, buildSignatureBlock } from '../signature-block';
|
||||
import { ReplyQuoteCollapse, markReplyQuoteHeader } from '../reply-quote-collapse';
|
||||
import { ReplyQuoteCollapse, markReplyQuoteHeader, userTextEnd } from '../reply-quote-collapse';
|
||||
|
||||
const HEADER = markReplyQuoteHeader('<div>On Monday, Bob wrote:<br></div>');
|
||||
const ORIGINAL = '<p>Original message</p><img src="cid:logo"><blockquote>Earlier reply</blockquote>';
|
||||
@@ -168,3 +168,49 @@ describe('reply quote visibility', () => {
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// compose-p2-3: a click under the folded quote used to put the caret on the
|
||||
// empty last line, below the hidden original, where the text went unseen.
|
||||
describe('a click in a reply with a folded quote', () => {
|
||||
const click = (e: Editor, pos: number) => {
|
||||
let handled = false;
|
||||
e.view.someProp('handleClick', (handler) => { handled = handler(e.view, pos, new MouseEvent('click')) || handled; return handled; });
|
||||
return handled;
|
||||
};
|
||||
const lastPos = (e: Editor) => e.state.doc.content.size - 1;
|
||||
|
||||
it('puts the caret at the end of the text above the quote', () => {
|
||||
const e = mount();
|
||||
expect(e.state.doc.lastChild?.type.name).toBe('paragraph');
|
||||
expect(click(e, lastPos(e))).toBe(true);
|
||||
expect(e.state.selection.from).toBe(1 + 'My response'.length);
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('keeps the click where it was once the quote is shown', () => {
|
||||
const e = mount();
|
||||
toggle().click();
|
||||
expect(click(e, lastPos(e))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a click on a line the user can see below the quote', () => {
|
||||
const e = mount(`<p>My response</p>${HEADER}${QUOTE}<p>Postscript</p>`);
|
||||
expect(click(e, lastPos(e) - 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a click in the text above the quote', () => {
|
||||
const e = mount();
|
||||
expect(click(e, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['above', 'below'])('ends the text before a signature %s the quote', (position) => {
|
||||
const signature = buildSignatureBlock('<b>My signature</b>');
|
||||
const e = mount(`<p>My response</p>${position === 'above' ? signature : ''}${HEADER}${QUOTE}${position === 'below' ? signature : ''}`);
|
||||
expect(userTextEnd(e.state)).toBe(1 + 'My response'.length);
|
||||
});
|
||||
|
||||
it('has no end of text in a body with neither quote nor signature', () => {
|
||||
const e = mount('<p>Just text</p>');
|
||||
expect(userTextEnd(e.state)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,11 +29,50 @@ describe('Insert link', () => {
|
||||
|
||||
fireEvent.click(screen.getByTitle('link'));
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
const dialog = screen.getByRole('dialog', { name: 'insert_link' });
|
||||
fireEvent.change(dialog.querySelector('input')!, { target: { value: 'example.com/docs' } });
|
||||
screen.getByRole('dialog', { name: 'insert_link' });
|
||||
expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('docs');
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'example.com/docs' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'save' }));
|
||||
|
||||
expect(editor.getHTML()).toContain('href="https://example.com/docs"');
|
||||
expect(editor.getHTML()).toContain('<a target="_blank" rel="noopener noreferrer nofollow" href="https://example.com/docs">docs</a> here');
|
||||
});
|
||||
|
||||
// compose-p2-7: with nothing selected Save used to add nothing.
|
||||
it('inserts the text as a link when nothing is selected', async () => {
|
||||
const editor = await setup('<p>See it</p>');
|
||||
act(() => { editor.commands.setTextSelection(4); });
|
||||
|
||||
fireEvent.click(screen.getByTitle('link'));
|
||||
expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('');
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'link_text' }), { target: { value: 'our docs' } });
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'example.com/docs' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'save' }));
|
||||
|
||||
expect(editor.getHTML()).toMatch(/See<a [^>]*href="https:\/\/example\.com\/docs"[^>]*>our docs<\/a> it/);
|
||||
});
|
||||
|
||||
it('shows the address as the link when the text is left empty', async () => {
|
||||
const editor = await setup('<p>See it</p>');
|
||||
act(() => { editor.commands.setTextSelection(4); });
|
||||
|
||||
fireEvent.click(screen.getByTitle('link'));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'https://example.com' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'save' }));
|
||||
|
||||
expect(editor.getHTML()).toMatch(/See<a [^>]*href="https:\/\/example\.com"[^>]*>https:\/\/example\.com<\/a> it/);
|
||||
});
|
||||
|
||||
it('edits the text of a link the caret is in', async () => {
|
||||
const editor = await setup('<p><a href="https://example.com">docs</a> here</p>');
|
||||
act(() => { editor.commands.setTextSelection(2); });
|
||||
|
||||
fireEvent.click(screen.getByTitle('link'));
|
||||
expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('docs');
|
||||
expect(screen.getByRole('textbox', { name: 'link' })).toHaveValue('https://example.com');
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'link_text' }), { target: { value: 'the docs' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'save' }));
|
||||
|
||||
expect(editor.getHTML()).toMatch(/<a [^>]*href="https:\/\/example\.com"[^>]*>the docs<\/a> here/);
|
||||
});
|
||||
|
||||
it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// compose-p2-6: on a phone the conversation listed a reply draft as an
|
||||
// ordinary message, expanded at the top with Reply, Reply all and Forward.
|
||||
// It is marked Draft and offers Edit and Discard instead.
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
|
||||
const { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
|
||||
|
||||
const base = {
|
||||
threadId: 't1', mailboxIds: { inbox: true }, to: [{ email: 'me@example.org' }],
|
||||
subject: 's', size: 1, hasAttachment: false, htmlBody: [], textBody: [], attachments: [], bodyValues: {},
|
||||
};
|
||||
const received = { ...base, id: 'e1', keywords: { $seen: true }, from: [{ name: 'Ann', email: 'ann@t.example' }], receivedAt: '2026-11-01T10:00:00Z', preview: 'question' };
|
||||
const draft = { ...base, id: 'd1', keywords: { $seen: true, $draft: true }, from: [{ name: 'Me', email: 'me@example.org' }], receivedAt: '2026-11-02T10:00:00Z', preview: 'my answer' };
|
||||
|
||||
function renderView(handlers: Record<string, unknown>) {
|
||||
render(
|
||||
<ThreadConversationView
|
||||
thread={{ threadId: 't1', emails: [draft, received], latestEmail: draft } as never}
|
||||
emails={[draft, received] as never}
|
||||
onBack={() => {}}
|
||||
onReply={() => {}}
|
||||
onReplyAll={() => {}}
|
||||
onForward={() => {}}
|
||||
{...handlers}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('a draft in a phone conversation', () => {
|
||||
it('is marked Draft, and the newest received message is the one open', () => {
|
||||
renderView({});
|
||||
expect(screen.getAllByTestId('conversation-draft-label')).toHaveLength(1);
|
||||
// Only the received message is open, with its Reply.
|
||||
expect(screen.getAllByRole('button', { name: /email_viewer.reply$/ })).toHaveLength(1);
|
||||
expect(screen.queryByRole('button', { name: /email_viewer.edit_draft/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('offers Edit and Discard instead of Reply once opened', () => {
|
||||
const onEditDraft = vi.fn();
|
||||
const onDiscardDraft = vi.fn();
|
||||
renderView({ onEditDraft, onDiscardDraft });
|
||||
fireEvent.click(screen.getByText('my answer'));
|
||||
expect(screen.getAllByRole('button', { name: /email_viewer.reply$/ })).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /email_viewer.edit_draft/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /email_composer.discard/ }));
|
||||
expect(onEditDraft).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
|
||||
expect(onDiscardDraft).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
|
||||
});
|
||||
});
|
||||
@@ -63,19 +63,6 @@ describe('quick reply', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// mail-p2-12: toasts covered the right half of the quick reply box.
|
||||
describe('quick reply and toasts', () => {
|
||||
it('counts as a bottom bar, so toasts sit above it', async () => {
|
||||
document.documentElement.style.removeProperty('--bottom-controls-height');
|
||||
const { unmount } = render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
|
||||
expect(document.querySelector('[data-quick-reply-box]')).not.toBeNull();
|
||||
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).not.toBe('');
|
||||
unmount();
|
||||
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// mail-p2-8: Undo send on a quick reply brings its text back into this box.
|
||||
describe('quick reply after Undo send', () => {
|
||||
it('takes the undone text back into the box of the message it answered', async () => {
|
||||
|
||||
@@ -85,3 +85,18 @@ describe('quick reply wording', () => {
|
||||
expect(onReply).toHaveBeenCalledWith('Thanks, will do');
|
||||
});
|
||||
});
|
||||
|
||||
// compose-p2-17: the toasts lift over the box by its height (toast.tsx).
|
||||
describe('quick reply box and the toasts', () => {
|
||||
it('marks the box and gives the page its height, and takes both back when it goes', async () => {
|
||||
const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(96);
|
||||
const { unmount } = render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} onReply={vi.fn()} />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
expect(document.querySelector('[data-quick-reply-box]')).not.toBeNull();
|
||||
expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('96px');
|
||||
unmount();
|
||||
expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('');
|
||||
height.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { render, act, fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// compose-p2-6: a reply saved as a draft was nowhere to be seen on the
|
||||
// message it answers. The reader shows it under the message, marked Draft,
|
||||
// with Edit and Discard, where the quick reply box would be.
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
||||
values ? `${key}:${JSON.stringify(values)}` : key,
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({
|
||||
dateTime: (d: Date | string) => String(d),
|
||||
relativeTime: (d: Date | string) => String(d),
|
||||
number: (n: number) => String(n),
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const message = {
|
||||
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Ben Carter', email: 'ben.carter@example.com' }], to: [{ name: 'Me', email: 'me@example.org' }],
|
||||
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
|
||||
bodyValues: { '1': { value: 'Plain body' } },
|
||||
} as unknown as Email;
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
useAuthStore.setState({ identities: [{ id: 'me', email: 'me@example.org', name: 'Me' }] } as never);
|
||||
});
|
||||
|
||||
async function renderViewer(replyDraft: { preview?: string; onEdit: () => void; onDiscard: () => void } | null) {
|
||||
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} onReply={vi.fn()} replyDraft={replyDraft} />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
}
|
||||
|
||||
describe('a reply draft under its message', () => {
|
||||
it('shows Draft with its text, Edit and Discard, instead of the quick reply box', async () => {
|
||||
const onEdit = vi.fn();
|
||||
const onDiscard = vi.fn();
|
||||
await renderViewer({ preview: 'my answer', onEdit, onDiscard });
|
||||
const card = screen.getByTestId('reply-draft');
|
||||
expect(card.textContent).toContain('draft');
|
||||
expect(card.textContent).toContain('my answer');
|
||||
expect(screen.queryByPlaceholderText(/^quick_reply_to/)).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('reply-draft-edit'));
|
||||
fireEvent.click(screen.getByTestId('reply-draft-discard'));
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(onDiscard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// compose-p2-17: toasts lift over it as over the quick reply box.
|
||||
it('counts as the quick reply box for the toasts', async () => {
|
||||
await renderViewer({ preview: 'my answer', onEdit: vi.fn(), onDiscard: vi.fn() });
|
||||
expect(screen.getByTestId('reply-draft')).toHaveAttribute('data-quick-reply-box');
|
||||
});
|
||||
|
||||
it('keeps the quick reply box when there is none', async () => {
|
||||
await renderViewer(null);
|
||||
expect(screen.queryByTestId('reply-draft')).toBeNull();
|
||||
expect(screen.getByPlaceholderText(/^quick_reply_to/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Minimize2,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
LogIn,
|
||||
ReplyAll,
|
||||
Reply,
|
||||
Forward,
|
||||
@@ -69,10 +70,10 @@ import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { markReplyQuoteHeader } from "@/components/email/reply-quote-collapse";
|
||||
import { markReplyQuoteHeader, userTextEnd } from "@/components/email/reply-quote-collapse";
|
||||
import { attachmentIcon } from "@/components/email/attachment-chips";
|
||||
import { attachmentsForDisplay } from "@/lib/compose/attachment-order";
|
||||
import { showDraftSavedToast } from "@/lib/compose/draft-close-toast";
|
||||
import { discardWithUndo, showDraftSavedToast } from "@/lib/compose/draft-close-toast";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { containsEmbeddedSignature } from "@/components/email/signature-block";
|
||||
import {
|
||||
@@ -414,6 +415,11 @@ interface EmailComposerProps {
|
||||
*/
|
||||
onDiscardDraft?: (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => void;
|
||||
onSaveState?: (data: ComposerDraftData) => void;
|
||||
/**
|
||||
* Undo after the bin: opens a composer again with what this one held. The
|
||||
* draft on the server is still there then.
|
||||
*/
|
||||
onRestoreDiscarded?: (data: ComposerDraftData) => void;
|
||||
/**
|
||||
* Offers "Open in new window". Called in the click, since a browser only
|
||||
* opens a window for a user action: the host opens it and answers with what
|
||||
@@ -453,6 +459,8 @@ interface EmailComposerProps {
|
||||
* this one is gone before a toast here could be read.
|
||||
*/
|
||||
onDraftKept?: (draftId: string, where: { accountId?: string; localAccountId?: string }) => void;
|
||||
/** The bin closed the window: another window says "Draft discarded" with Undo, and deletes it after. */
|
||||
onDraftDiscarded?: (draftId: string, where: { accountId?: string; localAccountId?: string }) => void;
|
||||
};
|
||||
/** The subject as typed, for a host that shows it (a Pro compose tab's title). */
|
||||
onSubjectChange?: (subject: string) => void;
|
||||
@@ -615,6 +623,7 @@ export function EmailComposer({
|
||||
requestCloseRef,
|
||||
onDiscardDraft,
|
||||
onSaveState,
|
||||
onRestoreDiscarded,
|
||||
popOut,
|
||||
popOutToMainLabel,
|
||||
docked = false,
|
||||
@@ -1271,7 +1280,9 @@ export function EmailComposer({
|
||||
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | 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);
|
||||
// `schedule`: asked before the schedule dialog, which opens on "Schedule anyway".
|
||||
const [sendWarnings, setSendWarnings] = useState<{ items: SendWarning[]; delayedUntil?: string; schedule?: boolean } | null>(null);
|
||||
const checkThenScheduleRef = useRef<() => void>(() => {});
|
||||
// Open proofreading issues, once a checker asked to warn about them.
|
||||
const proofreadingConfirmedRef = useRef(false);
|
||||
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
|
||||
@@ -1467,6 +1478,19 @@ export function EmailComposer({
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
// the whole body (which would lose user edits to the surrounding draft).
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
// The white space under the text is the message too (compose-p2-9): a
|
||||
// click there puts the caret at the end of the user's text, above a folded
|
||||
// quote and the signature, instead of dropping the focus.
|
||||
const focusBodyFromEmptySpace = (event: React.MouseEvent<HTMLElement>) => {
|
||||
const editor = editorRef.current;
|
||||
const target = event.target as HTMLElement;
|
||||
if (event.button !== 0 || !editor || editor.isDestroyed || !editor.isEditable) return;
|
||||
if (target.closest('.ProseMirror, button, input, textarea, select, a, label, [role="toolbar"], [role="dialog"], [data-plugin-slot]')) return;
|
||||
event.preventDefault();
|
||||
const end = userTextEnd(editor.state);
|
||||
if (end === null) editor.commands.focus('end');
|
||||
else editor.chain().setTextSelection(end).focus().run();
|
||||
};
|
||||
// Set while the composer itself swaps the signature in the editor.
|
||||
const signatureSwapRef = useRef(false);
|
||||
// Set once a reply's body got its initial focus, so switching formats later
|
||||
@@ -1557,9 +1581,11 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision, autoSignatureEnabled]);
|
||||
|
||||
// The send checks run first, so they don't open on top of the picked time
|
||||
// saying "Send anyway" when nothing is sent now (compose-p2-14).
|
||||
const openScheduleDialog = useCallback(() => {
|
||||
setShowScheduleDialog(true);
|
||||
closeSendMenu();
|
||||
checkThenScheduleRef.current();
|
||||
}, [closeSendMenu]);
|
||||
|
||||
// `autoSelectReplyIdentity` fused two behaviours: matching one of the user's
|
||||
@@ -1854,9 +1880,13 @@ export function EmailComposer({
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
// "Save as template" keeps it with the account the message is written from.
|
||||
// It says so: the dialog closing was the only sign before (compose-p2-15).
|
||||
const addTemplate = useCallback(
|
||||
(data: Parameters<ReturnType<typeof personalTemplatesFor>['add']>[0]) => personalTemplatesFor(composingAccountId).add(data),
|
||||
[composingAccountId],
|
||||
(data: Parameters<ReturnType<typeof personalTemplatesFor>['add']>[0]) => {
|
||||
personalTemplatesFor(composingAccountId).add(data);
|
||||
toast.success(tSend('template_saved'));
|
||||
},
|
||||
[composingAccountId, tSend],
|
||||
);
|
||||
// The admin may leave users only organisation and team templates.
|
||||
const composingTemplates = useTemplateSources(composingAccountId);
|
||||
@@ -3848,10 +3878,12 @@ export function EmailComposer({
|
||||
else editor.commands.focus();
|
||||
};
|
||||
|
||||
const handleSend = async ({ warningsConfirmed = false, delayedUntil }: {
|
||||
const handleSend = async ({ warningsConfirmed = false, delayedUntil, checkOnly = false }: {
|
||||
/** "Send anyway" in the warnings dialog: don't check those again. */
|
||||
warningsConfirmed?: boolean;
|
||||
delayedUntil?: string;
|
||||
/** Only the checks, then the schedule dialog instead of a send. */
|
||||
checkOnly?: boolean;
|
||||
} = {}) => {
|
||||
if (isSendingRef.current) return;
|
||||
|
||||
@@ -3998,10 +4030,14 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
setSendWarnings({ items: warnings, delayedUntil });
|
||||
setSendWarnings({ items: warnings, delayedUntil, ...(checkOnly ? { schedule: true } : {}) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (checkOnly) {
|
||||
setShowScheduleDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Checks other features register (lib/outgoing/send-pipeline.ts), such
|
||||
// as the external-recipient confirm. They may ask the user, so a second
|
||||
@@ -4486,6 +4522,8 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
|
||||
checkThenScheduleRef.current = () => void handleSend({ checkOnly: true });
|
||||
|
||||
// A host can hand a follow-up to requestCloseRef - "close this session, then
|
||||
// do that". It runs only once the composer really closes, never when the
|
||||
// guard dialog is cancelled, and never before onClose has had its say.
|
||||
@@ -4694,11 +4732,14 @@ export function EmailComposer({
|
||||
title={popOutToMainLabel ?? tWindows('open_in_window')}
|
||||
data-testid={popOutToMainLabel ? "composer-back-to-main" : "composer-open-in-window"}
|
||||
>
|
||||
{popOutToMainLabel ? <Minimize2 className={iconClassName} /> : <ExternalLink className={iconClassName} />}
|
||||
{/* Into the main window: an arrow going in, not the inward arrows of leaving full screen. */}
|
||||
{popOutToMainLabel ? <LogIn className={iconClassName} /> : <ExternalLink className={iconClassName} />}
|
||||
</button>
|
||||
);
|
||||
|
||||
const handleDiscardAndClose = () => {
|
||||
// `keepDraft`: the bin's discard, which deletes the draft once its Undo is
|
||||
// over, not here.
|
||||
const handleDiscardAndClose = ({ keepDraft = false }: { keepDraft?: boolean } = {}) => {
|
||||
cancelSaveAndClose();
|
||||
sendCancelledRef.current = true;
|
||||
explicitCloseRef.current = true;
|
||||
@@ -4716,7 +4757,7 @@ export function EmailComposer({
|
||||
// From belongs to: switching From doesn't move the draft until it is
|
||||
// saved again, and the same id names another message on another server.
|
||||
const discardedDraftId = draftIdRef.current;
|
||||
if (discardedDraftId && onDiscardDraft) {
|
||||
if (discardedDraftId && onDiscardDraft && !keepDraft) {
|
||||
onDiscardDraft(discardedDraftId, {
|
||||
accountId: draftAccountIdRef.current,
|
||||
localAccountId: draftLocalAccountIdRef.current,
|
||||
@@ -4752,6 +4793,11 @@ export function EmailComposer({
|
||||
// Discard deletes the draft, also one that has no unsaved changes, such as
|
||||
// a scheduled message reopened with "Cancel and edit". It used to share the
|
||||
// X button's path, which closed such a composer and kept the draft.
|
||||
// It asks nothing (compose-p2-10): the composer goes at once with "Draft
|
||||
// discarded · Undo", as Discard on the "Draft saved" toast does, and the
|
||||
// draft is deleted once Undo is no longer offered. Undo brings the composer
|
||||
// back with its text where the host can; elsewhere the last saved draft
|
||||
// stays in Drafts. A window of its own has the main window say it.
|
||||
const handleDiscardClick = () => {
|
||||
if (saveCloseAttemptRef.current?.saving) return;
|
||||
cancelSaveAndClose();
|
||||
@@ -4760,7 +4806,22 @@ export function EmailComposer({
|
||||
return;
|
||||
}
|
||||
afterCloseRef.current = null;
|
||||
openCloseDialog('discard');
|
||||
const draftId = draftIdRef.current;
|
||||
const where = { accountId: draftAccountIdRef.current, localAccountId: draftLocalAccountIdRef.current };
|
||||
const snapshot = snapshotStateRef.current();
|
||||
const discard = onDiscardDraft;
|
||||
const restore = onRestoreDiscarded;
|
||||
const windowDiscarded = ownWindowRef.current?.onDraftDiscarded;
|
||||
handleDiscardAndClose({ keepDraft: true });
|
||||
if (windowDiscarded) {
|
||||
if (draftId) windowDiscarded(draftId, where);
|
||||
return;
|
||||
}
|
||||
discardWithUndo(
|
||||
() => { if (draftId) discard?.(draftId, where); },
|
||||
{ discarded: tUx('draft_discarded'), undo: tUx('undo') },
|
||||
restore ? () => restore(snapshot) : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
// X, Escape, another message, a host's tab: one rule, as drafts save
|
||||
@@ -4962,7 +5023,8 @@ export function EmailComposer({
|
||||
"flex h-full bg-background relative",
|
||||
className,
|
||||
// Over everything but dialogs (z-60) and toasts; below the desktop app's window controls.
|
||||
maximized && "fixed inset-0 z-40 h-auto pt-[var(--titlebar-row-height)]",
|
||||
// At the folder sidebar's z-50 and after it in the page, so over it (compose-p2-5).
|
||||
maximized && "fixed inset-0 z-50 h-auto pt-[var(--titlebar-row-height)]",
|
||||
)}
|
||||
data-maximized={maximized || undefined}
|
||||
onKeyDownCapture={handleComposerKeyDown}
|
||||
@@ -5515,7 +5577,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex-1", pinFields && "min-h-0 overflow-auto")}>
|
||||
<div className={cn("flex-1", pinFields && "min-h-0 overflow-auto")} onMouseDown={focusBodyFromEmptySpace}>
|
||||
{wantsPluginDraft && (
|
||||
<PluginSlot name="composer-above-body" className="w-full" extraProps={pluginDraftProps} />
|
||||
)}
|
||||
@@ -5647,12 +5709,13 @@ export function EmailComposer({
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
// Every file shows, wrapping. Past about two rows the list
|
||||
// scrolls by itself: it sits outside the body's scroller and
|
||||
// can't shrink, and a few dozen files pushed Send off the
|
||||
// bottom. Files still uploading or failed come first, so their
|
||||
// progress and errors are never out of sight.
|
||||
className="flex flex-wrap gap-2 max-h-[5.25rem] overflow-y-auto overscroll-contain"
|
||||
// Every file shows, wrapping. Past four rows (two in a short
|
||||
// window) the list scrolls by itself: it sits outside the
|
||||
// body's scroller and can't shrink, and a few dozen files pushed
|
||||
// Send off the bottom. The height is whole rows of 2rem chips
|
||||
// with 0.5rem gaps, so no chip is cut in half (compose-p2-11);
|
||||
// the line above says how many there are. Failed files first.
|
||||
className="flex flex-wrap gap-2 max-h-[4.5rem] [@media(min-height:700px)]:max-h-[9.5rem] overflow-y-auto overscroll-contain [scrollbar-gutter:stable]"
|
||||
data-testid="composer-attachments"
|
||||
>
|
||||
{attachmentsForDisplay(attachments).map(({ att, index }) => {
|
||||
@@ -6156,7 +6219,8 @@ export function EmailComposer({
|
||||
<ScheduleSendDialog
|
||||
isOpen={showScheduleDialog}
|
||||
onClose={() => setShowScheduleDialog(false)}
|
||||
onSchedule={(delayedUntil) => handleSend({ delayedUntil })}
|
||||
// Checked before the dialog opened (openScheduleDialog).
|
||||
onSchedule={(delayedUntil) => handleSend({ delayedUntil, warningsConfirmed: true })}
|
||||
supported={!!composerClient?.hasDelayedSend()}
|
||||
maxDelayedSend={composerClient?.getMaxDelayedSend() ?? 0}
|
||||
disabled={!canSend || isSending || isSavingAndClosing}
|
||||
@@ -6167,6 +6231,7 @@ export function EmailComposer({
|
||||
|
||||
<SendWarningsDialog
|
||||
warnings={sendWarnings?.items ?? null}
|
||||
scheduling={sendWarnings?.schedule === true}
|
||||
onBack={() => {
|
||||
const first = sendWarnings?.items[0];
|
||||
setSendWarnings(null);
|
||||
@@ -6184,7 +6249,7 @@ export function EmailComposer({
|
||||
}}
|
||||
onSendAnyway={(dontAskAgain) => {
|
||||
if (!sendWarnings) return;
|
||||
const { items, delayedUntil } = sendWarnings;
|
||||
const { items, delayedUntil, schedule } = sendWarnings;
|
||||
for (const warning of items) {
|
||||
if (warning.kind === 'subject') {
|
||||
emptySubjectConfirmedRef.current = true;
|
||||
@@ -6199,7 +6264,8 @@ export function EmailComposer({
|
||||
}
|
||||
}
|
||||
setSendWarnings(null);
|
||||
handleSend({ warningsConfirmed: true, delayedUntil });
|
||||
if (schedule) setShowScheduleDialog(true);
|
||||
else handleSend({ warningsConfirmed: true, delayedUntil });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6215,7 +6281,7 @@ export function EmailComposer({
|
||||
<Button variant="outline" onClick={() => dismissCloseDialog()}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDiscardAndClose}>
|
||||
<Button variant="destructive" onClick={() => handleDiscardAndClose()}>
|
||||
{t('discard')}
|
||||
</Button>
|
||||
{closeDialogKind === 'close' && (
|
||||
@@ -6828,6 +6894,14 @@ export function RecipientChipInput({
|
||||
!isEditing && "focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
)}
|
||||
onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)}
|
||||
// A click opens the chip's menu too (address, Edit, Move to,
|
||||
// Remove), as Gmail and Outlook do; it did nothing visible.
|
||||
onClick={isEditing ? undefined : (e) => {
|
||||
if (e.target instanceof Element && e.target.closest('button')) return;
|
||||
// Not on to the field, whose click puts the caret in the input.
|
||||
e.stopPropagation();
|
||||
openChipMenu(i);
|
||||
}}
|
||||
data-invalid={isInvalid || undefined}
|
||||
title={isInvalid ? t('invalid_recipient') : undefined}
|
||||
role="listitem"
|
||||
@@ -6944,7 +7018,10 @@ export function RecipientChipInput({
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onBlur={handleBlur}
|
||||
className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
|
||||
// focus-inset: the keyboard focus ring goes inside the box. Drawn
|
||||
// outside, the field's own scroller cut it to a line along the top
|
||||
// and the end (compose-p2-18, a pop-out focuses To from a key).
|
||||
className="focus-inset flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
|
||||
role="combobox"
|
||||
aria-expanded={activeAutoField === field && autocompleteResults.length > 0}
|
||||
aria-autocomplete="list"
|
||||
|
||||
@@ -310,6 +310,11 @@ interface EmailViewerProps {
|
||||
mvInlineComposer?: React.ReactNode;
|
||||
/** A reply or forward to this message is open under it: no quick reply box as well. */
|
||||
replyComposerOpen?: boolean;
|
||||
/**
|
||||
* A reply to this message saved as a draft (compose-p2-6): shown under it,
|
||||
* marked Draft, with Edit and Discard, in place of the quick reply box.
|
||||
*/
|
||||
replyDraft?: { preview?: string; onEdit: () => void; onDiscard: () => void } | null;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
/** Stop a scheduled message from going out; it goes back to Drafts. */
|
||||
@@ -1468,6 +1473,7 @@ export function EmailViewer({
|
||||
onForwardConversationEmail,
|
||||
mvInlineComposer,
|
||||
replyComposerOpen,
|
||||
replyDraft,
|
||||
onEditDraft,
|
||||
onCancelScheduledForEdit,
|
||||
onCancelScheduledSend,
|
||||
@@ -1492,6 +1498,7 @@ export function EmailViewer({
|
||||
const tWindows = useTranslations('pwaWindows');
|
||||
const tComposer = useTranslations('email_composer');
|
||||
const tWords = useTranslations('compose_words');
|
||||
const tList = useTranslations('email_list');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tCommon = useTranslations('common');
|
||||
const tFiles = useTranslations('files');
|
||||
@@ -1827,16 +1834,7 @@ export function EmailViewer({
|
||||
const [quickReplyText, setQuickReplyText] = useState("");
|
||||
const quickReplyRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
|
||||
const quickReplyBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
// Toasts ("Archived · Undo", "Sending in 5 seconds") sit above the quick
|
||||
// reply box while it is on screen, not over it (mail-p2-12).
|
||||
const [quickReplyBoxShown, setQuickReplyBoxShown] = useState(false);
|
||||
const quickReplyBarRef = useBottomControls(quickReplyBoxShown);
|
||||
const setQuickReplyBox = useCallback((el: HTMLDivElement | null) => {
|
||||
quickReplyBoxRef.current = el;
|
||||
setQuickReplyBoxShown(!!el);
|
||||
quickReplyBarRef(el);
|
||||
}, [quickReplyBarRef]);
|
||||
const quickReplyBoxRef = useRef<HTMLDivElement>(null);
|
||||
// Focused, the box grows by its buttons: bring them up, they'd sit below the fold.
|
||||
useEffect(() => {
|
||||
if (isQuickReplyFocused) quickReplyBoxRef.current?.scrollIntoView?.({ block: 'nearest' });
|
||||
@@ -3705,8 +3703,25 @@ export function EmailViewer({
|
||||
return () => { cancelAnimationFrame(frame); clearTimeout(timer); };
|
||||
}, [replyComposerOpen, email?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- once per reply opened
|
||||
|
||||
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
|
||||
const showReplyDraft = !!email && !!replyDraft && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled;
|
||||
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !showReplyDraft && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
|
||||
&& (!effectiveEmailContent.isHtml || iframeReady);
|
||||
// Toasts sit above the quick reply box, as above a composer's footer
|
||||
// (compose-p2-17): its height for toast.tsx, as it grows when focused.
|
||||
useEffect(() => {
|
||||
// The reply draft's card takes the box's place, and its ref.
|
||||
const box = quickReplyBoxRef.current;
|
||||
if (!(showQuickReply || showReplyDraft) || !box || typeof ResizeObserver === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
const update = () => root.style.setProperty('--quick-reply-height', `${box.offsetHeight}px`);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(box);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
root.style.removeProperty('--quick-reply-height');
|
||||
};
|
||||
}, [showQuickReply, showReplyDraft]);
|
||||
// The plain-text body, where reader-above-quote mounts between text and quote.
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
// Read aloud (browser speech): the body as shown, HTML frame or plain text.
|
||||
@@ -6401,8 +6416,31 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The reply already started on this message, as Gmail shows it. */}
|
||||
{showReplyDraft && replyDraft && (
|
||||
<div ref={quickReplyBoxRef} data-quick-reply-box="" data-testid="reply-draft" className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-border bg-muted/30 px-3 py-2">
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-destructive shrink-0">
|
||||
<File className="w-4 h-4" />
|
||||
{tList('draft')}
|
||||
</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{replyDraft.preview}</span>
|
||||
<span className="flex shrink-0 gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={replyDraft.onEdit} data-testid="reply-draft-edit">
|
||||
<Pencil className="w-4 h-4" />
|
||||
{t('edit_draft')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="gap-1.5" onClick={replyDraft.onDiscard} data-testid="reply-draft-discard">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{tComposer('discard')}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||
{showQuickReply && (<div ref={setQuickReplyBox} data-quick-reply-box="" className="bg-background border-t border-border px-6 mt-auto max-sm:scroll-mb-16" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
{showQuickReply && (<div ref={quickReplyBoxRef} data-quick-reply-box="" className="bg-background border-t border-border px-6 mt-auto max-sm:scroll-mb-16" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, 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";
|
||||
|
||||
export interface LinkDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** `text` is what the link shows; empty means the address itself. */
|
||||
onSubmit: (link: { text: string; href: string }) => void;
|
||||
/** The selected text, or the link's own text when editing one. */
|
||||
defaultText: string;
|
||||
defaultHref: string;
|
||||
/** Offers "Remove link" when editing a link. */
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert link (compose-p2-7): "Text" and "Link", as Gmail and Outlook ask. The
|
||||
* selection fills Text; with nothing selected the text (or the address) goes
|
||||
* in as a new link.
|
||||
*/
|
||||
export function LinkDialog({ isOpen, onClose, onSubmit, defaultText, defaultHref, onRemove }: LinkDialogProps) {
|
||||
const t = useTranslations("email_composer.toolbar");
|
||||
const tCommon = useTranslations("common");
|
||||
const id = useId();
|
||||
const [text, setText] = useState(defaultText);
|
||||
const [href, setHref] = useState(defaultHref);
|
||||
const textRef = useRef<HTMLInputElement>(null);
|
||||
const hrefRef = useRef<HTMLInputElement>(null);
|
||||
// With text already there, the address is what is left to give.
|
||||
const firstField = defaultText ? hrefRef : textRef;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setText(defaultText);
|
||||
setHref(defaultHref);
|
||||
}, [isOpen, defaultText, defaultHref]);
|
||||
|
||||
const address = href.trim();
|
||||
const error = /\s/.test(address) ? t("link_invalid") : null;
|
||||
const canSubmit = address.length > 0 && !error;
|
||||
const errorId = `${id}-error`;
|
||||
|
||||
const submit = (event?: React.FormEvent) => {
|
||||
event?.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
onSubmit({ text, href: address });
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={defaultHref ? t("edit_link") : t("insert_link")}
|
||||
showClose={false}
|
||||
initialFocus={firstField}
|
||||
onSubmit={submit}
|
||||
footer={
|
||||
<>
|
||||
{onRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="me-auto"
|
||||
onClick={() => {
|
||||
try {
|
||||
onRemove();
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("remove_link")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="default" disabled={!canSubmit}>
|
||||
{tCommon("save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={`${id}-text`} className="block text-sm font-medium text-foreground">{t("link_text")}</label>
|
||||
<Input id={`${id}-text`} ref={textRef} type="text" value={text} onChange={(e) => setText(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={`${id}-href`} className="block text-sm font-medium text-foreground">{t("link")}</label>
|
||||
<Input
|
||||
id={`${id}-href`}
|
||||
ref={hrefRef}
|
||||
type="text"
|
||||
value={href}
|
||||
onChange={(e) => setHref(e.target.value)}
|
||||
placeholder={t("link_address")}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
className={error ? "border-destructive focus-visible:border-destructive" : undefined}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Extension } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { Plugin, PluginKey, Selection } from '@tiptap/pm/state';
|
||||
import { Plugin, PluginKey, Selection, TextSelection, type EditorState } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
|
||||
export const REPLY_QUOTE_START_ATTRIBUTE = 'data-reply-quote-start';
|
||||
@@ -59,6 +59,55 @@ function quoteRanges(doc: PMNode): QuoteRange[] {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
const isSignature = (node: PMNode) => node.type.name === 'signatureBlock' || Boolean(node.attrs['data-signature-block']);
|
||||
|
||||
function collapsedRanges(state: EditorState): QuoteRange[] {
|
||||
const expanded = quoteKey.getState(state)?.expanded ?? new Set<number>();
|
||||
return quoteRanges(state.doc).filter((range) => !expanded.has(range.from));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the user's own text ends: the end of the last paragraph above the
|
||||
* folded quote or the signature, whichever comes first. Null when the body
|
||||
* has neither, so the end of the body is the end of the text.
|
||||
*/
|
||||
export function userTextEnd(state: EditorState): number | null {
|
||||
const ranges = quoteRanges(state.doc);
|
||||
let boundary: number | null = collapsedRanges(state)[0]?.from ?? null;
|
||||
state.doc.forEach((node, pos) => {
|
||||
if (isSignature(node) && (boundary === null || pos < boundary)) boundary = pos;
|
||||
});
|
||||
if (boundary === null) return null;
|
||||
let end: number | null = null;
|
||||
state.doc.forEach((node, pos) => {
|
||||
if (pos + node.nodeSize > boundary! || !node.isTextblock || isSignature(node)) return;
|
||||
if (ranges.some((range) => pos >= range.from && pos < range.to)) return;
|
||||
end = pos + node.nodeSize - 1;
|
||||
});
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* A click that lands under a folded quote (on the empty line the editor keeps
|
||||
* at the end) would write below the hidden original, where the user can't see
|
||||
* it. It goes to the end of their own text above the quote instead
|
||||
* (compose-p2-3). Lines the user can see below the quote keep their click.
|
||||
*/
|
||||
function clickBelowCollapsedQuote(state: EditorState, pos: number): boolean {
|
||||
const ranges = collapsedRanges(state);
|
||||
const first = ranges[0];
|
||||
if (!first || pos < first.from) return false;
|
||||
const $pos = state.doc.resolve(pos);
|
||||
const topPos = $pos.depth > 0 ? $pos.before(1) : pos;
|
||||
let hiddenOrEmpty = true;
|
||||
state.doc.forEach((node, nodePos) => {
|
||||
if (nodePos < first.from || nodePos > topPos) return;
|
||||
if (ranges.some((range) => nodePos >= range.from && nodePos < range.to)) return;
|
||||
if (!node.isTextblock || isSignature(node) || node.content.size > 0) hiddenOrEmpty = false;
|
||||
});
|
||||
return hiddenOrEmpty;
|
||||
}
|
||||
|
||||
/** Visibility is a view decoration, never a document edit or an undo step. */
|
||||
export const ReplyQuoteCollapse = Extension.create<{ show: string; hide: string }>({
|
||||
name: 'replyQuoteCollapse',
|
||||
@@ -107,6 +156,13 @@ export const ReplyQuoteCollapse = Extension.create<{ show: string; hide: string
|
||||
},
|
||||
},
|
||||
props: {
|
||||
handleClick(view, pos) {
|
||||
if (!clickBelowCollapsedQuote(view.state, pos)) return false;
|
||||
const end = userTextEnd(view.state);
|
||||
if (end === null) return false;
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, end)));
|
||||
return true;
|
||||
},
|
||||
decorations(state) {
|
||||
const expanded = quoteKey.getState(state)?.expanded ?? new Set<number>();
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
@@ -32,7 +32,8 @@ import { RecipientMention, RecipientMentionList, type RecipientMentionListHandle
|
||||
import { MentionChipCard, setMentionRecipients } from "@/components/email/mention-chip";
|
||||
import { mentionSuggestions, type MentionCandidate, type MentionContact } from "@/lib/recipient-mentions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
import { getMarkRange } from "@tiptap/core";
|
||||
import { LinkDialog } from "@/components/email/link-dialog";
|
||||
import { ImageAltDialog } from "@/components/email/image-alt-dialog";
|
||||
|
||||
/** A typed address without a scheme ("example.com") becomes a web link. */
|
||||
@@ -741,26 +742,43 @@ export function RichTextEditor({
|
||||
if (editor) setMentionRecipients(editor, mentionCandidates ? mentionCandidates.map((c) => c.email) : null);
|
||||
}, [editor, mentionCandidates]);
|
||||
|
||||
// Insert link asks for the address in the app's own dialog; on a link it
|
||||
// starts from the current address and offers "Remove link".
|
||||
// The selection is kept from when the dialog opened: the dialog takes the
|
||||
// focus, and with it the editor's selection.
|
||||
const [linkDialog, setLinkDialog] = useState<{ href: string; from: number; to: number } | null>(null);
|
||||
// Insert link asks for the text and the address in the app's own dialog
|
||||
// (compose-p2-7); on a link it starts from the link's text and address and
|
||||
// offers "Remove link". The range is kept from when the dialog opened: the
|
||||
// dialog takes the focus, and with it the editor's selection.
|
||||
const [linkDialog, setLinkDialog] = useState<{ href: string; text: string; from: number; to: number } | null>(null);
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const previousUrl = editor.getAttributes("link").href as string | undefined;
|
||||
const { from, to } = editor.state.selection;
|
||||
setLinkDialog({ href: previousUrl ?? "", from, to });
|
||||
let { from, to } = editor.state.selection;
|
||||
// The caret in a link edits the whole link.
|
||||
const linkType = editor.schema.marks.link;
|
||||
if (previousUrl && from === to && linkType) {
|
||||
const range = getMarkRange(editor.state.doc.resolve(from), linkType);
|
||||
if (range) ({ from, to } = range);
|
||||
}
|
||||
const text = editor.state.doc.textBetween(from, to, " ");
|
||||
setLinkDialog({ href: previousUrl ?? "", text, from, to });
|
||||
}, [editor]);
|
||||
addLinkRef.current = addLink;
|
||||
const applyLink = useCallback((url: string) => {
|
||||
const applyLink = useCallback(({ text, href }: { text: string; href: string }) => {
|
||||
if (!editor || !linkDialog) return;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection({ from: linkDialog.from, to: linkDialog.to })
|
||||
const label = text.trim() ? text : href;
|
||||
const { from, to } = linkDialog;
|
||||
const chain = editor.chain().focus();
|
||||
if (from === to || label !== linkDialog.text) {
|
||||
// Nothing selected, or the text changed: the text goes in as the link.
|
||||
chain
|
||||
.insertContentAt({ from, to }, { type: "text", text: label, marks: [{ type: "link", attrs: { href } }] })
|
||||
.setTextSelection(from + label.length)
|
||||
.unsetMark("link")
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
chain
|
||||
.setTextSelection({ from, to })
|
||||
.extendMarkRange("link")
|
||||
.setLink({ href: url })
|
||||
.setLink({ href })
|
||||
.run();
|
||||
}, [editor, linkDialog]);
|
||||
const removeLink = useCallback(() => {
|
||||
@@ -776,7 +794,6 @@ export function RichTextEditor({
|
||||
|
||||
const tToolbar = useTranslations("email_composer.toolbar");
|
||||
const tWindows = useTranslations("compose_windows");
|
||||
const tCommon = useTranslations("common");
|
||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||||
@@ -1465,16 +1482,13 @@ export function RichTextEditor({
|
||||
document.body
|
||||
)}
|
||||
|
||||
<PromptDialog
|
||||
<LinkDialog
|
||||
isOpen={!!linkDialog}
|
||||
onClose={() => setLinkDialog(null)}
|
||||
title={linkDialog?.href ? tToolbar("edit_link") : tToolbar("insert_link")}
|
||||
placeholder={tToolbar("link_address")}
|
||||
defaultValue={linkDialog?.href ?? ""}
|
||||
confirmText={tCommon("save")}
|
||||
validate={(value) => (/\s/.test(value) ? tToolbar("link_invalid") : null)}
|
||||
onSubmit={(value) => applyLink(withLinkScheme(value))}
|
||||
extraAction={linkDialog?.href ? { label: tToolbar("remove_link"), onClick: removeLink } : undefined}
|
||||
defaultText={linkDialog?.text ?? ""}
|
||||
defaultHref={linkDialog?.href ?? ""}
|
||||
onSubmit={({ text, href }) => applyLink({ text, href: withLinkScheme(href) })}
|
||||
onRemove={linkDialog?.href ? removeLink : undefined}
|
||||
/>
|
||||
|
||||
<ImageAltDialog
|
||||
|
||||
@@ -20,6 +20,8 @@ interface SendWarningsDialogProps {
|
||||
onBack: () => void;
|
||||
/** "Send anyway". `dontAskAgain`: the box under a single warning that has a setting. */
|
||||
onSendAnyway: (dontAskAgain: boolean) => void;
|
||||
/** Asked before Schedule send: the button says "Schedule anyway". */
|
||||
scheduling?: boolean;
|
||||
}
|
||||
|
||||
/** The warnings that can be switched off in Settings (subject and placeholders). */
|
||||
@@ -30,7 +32,7 @@ const HAS_SETTING = new Set<SendWarning["kind"]>(["subject", "placeholders"]);
|
||||
* 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) {
|
||||
export function SendWarningsDialog({ warnings, onBack, onSendAnyway, scheduling = false }: SendWarningsDialogProps) {
|
||||
const t = useTranslations("email_composer");
|
||||
const tSend = useTranslations("compose_send");
|
||||
const tProofreading = useTranslations("proofreading");
|
||||
@@ -89,7 +91,7 @@ export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarni
|
||||
backLabel = tProofreading("send_warning.review");
|
||||
sendLabel = tProofreading("send_warning.send");
|
||||
} else {
|
||||
title = tSend("warnings_title");
|
||||
title = tSend(scheduling ? "schedule_warnings_title" : "warnings_title");
|
||||
body = (
|
||||
<ul className="mt-3 space-y-2 text-sm text-foreground" data-testid="send-warnings-list">
|
||||
{items.map((w) => (
|
||||
@@ -113,6 +115,9 @@ export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarni
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing goes out now: the message is scheduled (compose-p2-14).
|
||||
if (scheduling) sendLabel = tSend("schedule_anyway");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={items.length > 0}
|
||||
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
CalendarClock,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "@/components/icons";
|
||||
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -91,6 +93,9 @@ interface ThreadConversationViewProps {
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
onForward?: (email: Email) => void;
|
||||
/** A draft in the conversation (compose-p2-6): Edit and Discard take Reply's place. */
|
||||
onEditDraft?: (email: Email) => void;
|
||||
onDiscardDraft?: (email: Email) => void;
|
||||
onDownloadAttachment?: ConversationAttachmentHandler;
|
||||
/** Routing captured when this conversation was opened. */
|
||||
blobSource?: EmailBlobContext;
|
||||
@@ -196,6 +201,8 @@ export function ThreadConversationView({
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onEditDraft,
|
||||
onDiscardDraft,
|
||||
onDownloadAttachment,
|
||||
blobSource,
|
||||
onMarkAsRead,
|
||||
@@ -230,7 +237,7 @@ export function ThreadConversationView({
|
||||
|
||||
// Also expand all unread emails
|
||||
ordered.forEach(email => {
|
||||
if (!email.keywords?.$seen) {
|
||||
if (!email.keywords?.$seen && !email.keywords?.$draft) {
|
||||
idsToExpand.add(email.id);
|
||||
}
|
||||
});
|
||||
@@ -297,7 +304,9 @@ export function ThreadConversationView({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
|
||||
<div ref={cardsRef} className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
|
||||
{ordered.map((email) => (
|
||||
{ordered.map((email) => {
|
||||
const draft = !!email.keywords?.$draft;
|
||||
return (
|
||||
<div key={email.id} data-thread-email-id={email.id} className="scroll-mt-16">
|
||||
<EmailCard
|
||||
email={email}
|
||||
@@ -307,16 +316,20 @@ export function ThreadConversationView({
|
||||
onToggleExpanded={() => toggleExpanded(email.id)}
|
||||
onAllowExternal={() => externalContent.allow(email.id)}
|
||||
onTrustSender={externalContent.trustSender(email)}
|
||||
onReply={onReply ? () => onReply(email) : undefined}
|
||||
onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
|
||||
onForward={onForward ? () => onForward(email) : undefined}
|
||||
isDraft={draft}
|
||||
onEditDraft={draft && onEditDraft ? () => onEditDraft(email) : undefined}
|
||||
onDiscardDraft={draft && onDiscardDraft ? () => onDiscardDraft(email) : undefined}
|
||||
onReply={!draft && onReply ? () => onReply(email) : undefined}
|
||||
onReplyAll={!draft && onReplyAll ? () => onReplyAll(email) : undefined}
|
||||
onForward={!draft && onForward ? () => onForward(email) : undefined}
|
||||
onDownloadAttachment={onDownloadAttachment}
|
||||
blobSource={blobSource}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
mailboxRole={mailboxRole}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -349,6 +362,9 @@ interface EmailCardProps {
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
isDraft?: boolean;
|
||||
onEditDraft?: () => void;
|
||||
onDiscardDraft?: () => void;
|
||||
onDownloadAttachment?: ConversationAttachmentHandler;
|
||||
/** Routing captured when this conversation was opened. */
|
||||
blobSource?: EmailBlobContext;
|
||||
@@ -387,6 +403,9 @@ function EmailCard({
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
isDraft = false,
|
||||
onEditDraft,
|
||||
onDiscardDraft,
|
||||
onDownloadAttachment,
|
||||
blobSource,
|
||||
onMarkAsRead,
|
||||
@@ -442,6 +461,11 @@ function EmailCard({
|
||||
</span>
|
||||
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} interactive={false} />
|
||||
<ReceivedLabelChip email={email} />
|
||||
{isDraft && (
|
||||
<span data-testid="conversation-draft-label" className="text-xs font-medium text-destructive shrink-0">
|
||||
{t('email_list.draft')}
|
||||
</span>
|
||||
)}
|
||||
{email.isScheduled && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary shrink-0">
|
||||
<CalendarClock className="w-3.5 h-3.5" />
|
||||
@@ -507,6 +531,34 @@ function EmailCard({
|
||||
in the single reader's More panel on phones. Long names (Arabic
|
||||
"Reply all") wrap to a second line instead of being cut. */}
|
||||
<div className="px-4 pb-4 flex flex-wrap gap-2">
|
||||
{onEditDraft && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditDraft();
|
||||
}}
|
||||
className="flex-auto px-2 whitespace-nowrap"
|
||||
>
|
||||
<Pencil className="w-4 h-4 me-1.5 shrink-0" />
|
||||
{t("email_viewer.edit_draft")}
|
||||
</Button>
|
||||
)}
|
||||
{onDiscardDraft && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDiscardDraft();
|
||||
}}
|
||||
className="flex-auto px-2 whitespace-nowrap"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 me-1.5 shrink-0" />
|
||||
{t("email_composer.discard")}
|
||||
</Button>
|
||||
)}
|
||||
{onReply && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -51,6 +51,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { useDesktopWindowName } from "@/lib/desktop/window-name";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
import { FilesSkeleton, FilesSkeletonBody } from "@/components/boot-skeleton/files-skeleton";
|
||||
@@ -69,6 +70,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const tShell = useTranslations("shell");
|
||||
useDocumentTitle(tSidebar("files"));
|
||||
useDesktopWindowName(tSidebar("files"), { withAccount: true });
|
||||
const tDeepLink = useTranslations("deep_link");
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||
|
||||
@@ -372,7 +372,7 @@ export function WindowMenuBar() {
|
||||
aria-haspopup={isSub ? "menu" : undefined}
|
||||
aria-expanded={isSub ? opened : undefined}
|
||||
aria-disabled={item.enabled ? undefined : true}
|
||||
aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform) : undefined}
|
||||
aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform, 'en') : undefined}
|
||||
data-menu-line={index}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={(event) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { useAccountStore } from '@/stores/account-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
@@ -211,6 +212,40 @@ describe('a mail link from another program in the desktop app', () => {
|
||||
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('files from Send to or Open with go to a compose window too, attached, and the main window shows no composer', async () => {
|
||||
const { handOffDraft } = await import('@/lib/launch-handoff');
|
||||
render(<MailApp />);
|
||||
const file = new File(['pdf'], 'report.pdf', { type: 'application/pdf' });
|
||||
act(() => { handOffDraft({ files: [file], fromOs: true }); });
|
||||
await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1));
|
||||
expect(desktopWindows.handOver).toHaveBeenCalledWith(expect.objectContaining({ mode: 'compose', initialFiles: [file] }));
|
||||
await act(() => new Promise((r) => setTimeout(r, 50)));
|
||||
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('with two accounts it opens from the default one, in its own window, with no question', async () => {
|
||||
const client = useAuthStore.getState().client;
|
||||
useAuthStore.setState({ getAllConnectedClients: () => new Map([['login', client], ['second', client]]) as never });
|
||||
useAccountStore.setState({
|
||||
accounts: [
|
||||
{ id: 'login', email: 'me@example.com', isConnected: true, isDefault: false },
|
||||
{ id: 'second', email: 'work@example.com', isConnected: true, isDefault: true },
|
||||
],
|
||||
defaultAccountId: 'second',
|
||||
} as never);
|
||||
try {
|
||||
render(<MailApp />);
|
||||
await fromOs();
|
||||
await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1));
|
||||
expect(desktopWindows.open).toHaveBeenCalledWith('second');
|
||||
expect(screen.queryByText('select_account_title')).not.toBeInTheDocument();
|
||||
// The main window keeps the account it shows.
|
||||
expect(useAuthStore.getState().activeAccountId).toBe('login');
|
||||
} finally {
|
||||
useAccountStore.setState({ accounts: [], defaultAccountId: null } as never);
|
||||
}
|
||||
});
|
||||
|
||||
it('a link inside the app still opens here', async () => {
|
||||
render(<MailApp />);
|
||||
act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); });
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// One composer at a time (compose-p2-1, compose-p2-2). Another one opening
|
||||
// while a composer is on screen closes that one the way its X does (save,
|
||||
// "Draft saved"), then opens. It never crashes and never drops the text.
|
||||
|
||||
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
|
||||
// What the stub composer's close does: 'saves' closes and lets the next one
|
||||
// open, 'fails' keeps the composer (a save the server refused).
|
||||
const stub = vi.hoisted(() => ({
|
||||
close: 'none' as 'none' | 'saves' | 'fails',
|
||||
closes: [] as string[],
|
||||
mounts: [] as string[],
|
||||
restore: null as null | ((data: Record<string, unknown>) => void),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({
|
||||
Sidebar: ({ onCompose }: { onCompose: () => void }) => <button onClick={onCompose}>stub compose</button>,
|
||||
}));
|
||||
// As the real composer: it hands its state (its own mode, untouched) to the
|
||||
// host as it unmounts without an explicit close, and offers its close.
|
||||
vi.mock('@/components/email/email-composer', async () => {
|
||||
const { useEffect, useRef } = await import('react');
|
||||
return {
|
||||
EmailComposer: ({ mode, onSaveState, onClose, requestCloseRef, onRestoreDiscarded }: {
|
||||
mode: string;
|
||||
onRestoreDiscarded?: (data: Record<string, unknown>) => void;
|
||||
onSaveState: (data: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
requestCloseRef?: { current: unknown };
|
||||
}) => {
|
||||
const closed = useRef(false);
|
||||
useEffect(() => {
|
||||
stub.mounts.push(mode);
|
||||
stub.restore = onRestoreDiscarded ?? null;
|
||||
// The old host moved the composer between dock and pane for ever
|
||||
// (React error 185 in the browser); stop it here instead of hanging.
|
||||
if (stub.mounts.length > 40) throw new Error('composer remount loop');
|
||||
if (requestCloseRef && stub.close !== 'none') {
|
||||
requestCloseRef.current = (afterClose?: () => void) => {
|
||||
stub.closes.push(mode);
|
||||
if (stub.close === 'fails') return;
|
||||
closed.current = true;
|
||||
onClose();
|
||||
afterClose?.();
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
if (requestCloseRef) requestCloseRef.current = null;
|
||||
if (closed.current) return;
|
||||
onSaveState({ to: '', cc: '', bcc: '', subject: '', body: '', mode, draftId: null, unedited: true });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return <div data-testid="stub-composer" data-mode={mode}>stub composer</div>;
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@/components/search/mv-search-form', () => ({
|
||||
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
|
||||
}));
|
||||
vi.mock('@/components/email/email-viewer', () => ({
|
||||
EmailViewer: ({ onReply, onReplyAll, onForward }: { onReply: () => void; onReplyAll: () => void; onForward: () => void }) => (
|
||||
<div data-testid="viewer">
|
||||
message viewer
|
||||
<button onClick={() => onReply()}>stub reply</button>
|
||||
<button onClick={() => onReplyAll()}>stub reply all</button>
|
||||
<button onClick={() => onForward()}>stub forward</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => device.current,
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({
|
||||
EmailList: () => null,
|
||||
}));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
|
||||
const MESSAGE = {
|
||||
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }],
|
||||
subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10,
|
||||
hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {},
|
||||
} as never;
|
||||
|
||||
beforeEach(() => {
|
||||
device.current = { isMobile: false, isTablet: false, isDesktop: true };
|
||||
stub.close = 'none';
|
||||
stub.closes = [];
|
||||
stub.mounts = [];
|
||||
stub.restore = null;
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
getEmail: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
|
||||
useEmailStore.setState({
|
||||
emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
|
||||
selectedEmailKeys: new Set(), unreadSection: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
|
||||
const composerMode = () => screen.getByTestId('stub-composer').getAttribute('data-mode');
|
||||
|
||||
describe('Compose while a reply is docked (compose-p2-1)', () => {
|
||||
for (const action of ['stub reply', 'stub forward']) {
|
||||
it(`opens a new message in the pane after ${action}`, async () => {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText(action));
|
||||
const reply = await screen.findByTestId('stub-composer');
|
||||
expect(reply.closest('[data-reply-dock]')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await waitFor(() => expect(composerMode()).toBe('compose'));
|
||||
// The new message takes the pane, not the dock, and stays put.
|
||||
expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).toBeNull();
|
||||
const mounted = stub.mounts.length;
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
|
||||
expect(stub.mounts.length).toBe(mounted);
|
||||
expect(composerMode()).toBe('compose');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('another composer while one is open (compose-p2-2)', () => {
|
||||
it('closes the reply through its own close (save, "Draft saved") before Compose opens', async () => {
|
||||
stub.close = 'saves';
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText('stub reply'));
|
||||
await screen.findByTestId('stub-composer');
|
||||
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await waitFor(() => expect(composerMode()).toBe('compose'));
|
||||
expect(stub.closes).toEqual(['reply']);
|
||||
});
|
||||
|
||||
for (const [action, mode] of [['stub reply all', 'replyAll'], ['stub forward', 'forward']] as const) {
|
||||
it(`closes the reply through its own close before ${action} opens`, async () => {
|
||||
stub.close = 'saves';
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText('stub reply'));
|
||||
await screen.findByTestId('stub-composer');
|
||||
|
||||
fireEvent.click(screen.getByText(action));
|
||||
await waitFor(() => expect(composerMode()).toBe(mode));
|
||||
expect(stub.closes).toEqual(['reply']);
|
||||
});
|
||||
}
|
||||
|
||||
it('closes a new message before another new message opens', async () => {
|
||||
stub.close = 'saves';
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await screen.findByTestId('stub-composer');
|
||||
const mounted = stub.mounts.length;
|
||||
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await waitFor(() => expect(stub.mounts.length).toBe(mounted + 1));
|
||||
expect(stub.closes).toEqual(['compose']);
|
||||
});
|
||||
|
||||
it('keeps the open composer when its save fails', async () => {
|
||||
stub.close = 'fails';
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText('stub reply'));
|
||||
await screen.findByTestId('stub-composer');
|
||||
const mounted = stub.mounts.length;
|
||||
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
|
||||
expect(stub.closes).toEqual(['reply']);
|
||||
expect(stub.mounts.length).toBe(mounted);
|
||||
expect(composerMode()).toBe('reply');
|
||||
});
|
||||
});
|
||||
|
||||
// compose-p2-10: Undo after the bin brings the composer back.
|
||||
describe('Undo after the bin', () => {
|
||||
it('opens the discarded text again, closing whatever is open first', async () => {
|
||||
stub.close = 'saves';
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await screen.findByTestId('stub-composer');
|
||||
const restore = stub.restore!;
|
||||
|
||||
act(() => restore({ to: 'bob@example.org', cc: '', bcc: '', subject: 'Back', body: 'kept', mode: 'forward', draftId: 'd1' }));
|
||||
await waitFor(() => expect(composerMode()).toBe('forward'));
|
||||
expect(stub.closes).toEqual(['compose']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// Drafts reopen where people look for them (compose-p2-6, compose-p2-8):
|
||||
// a click on a draft opens it in the composer, and Reply on a message with a
|
||||
// reply draft opens that draft instead of starting a second one.
|
||||
|
||||
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
|
||||
const DRAFT_ROW = vi.hoisted(() => ({
|
||||
id: 'd1', threadId: 't1', mailboxIds: { drafts: true }, keywords: { $seen: true, $draft: true },
|
||||
from: [{ email: 'a@example.org' }], to: [{ email: 'ann@example.org' }],
|
||||
subject: 'Re: Plans', receivedAt: '2026-11-02T10:00:00Z', preview: 'my answer', size: 10, hasAttachment: false,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/email/email-composer', () => ({
|
||||
EmailComposer: ({ mode, initialData }: { mode: string; initialData?: { draftId?: string } | null }) => (
|
||||
<div data-testid="stub-composer" data-mode={mode} data-draft={initialData?.draftId ?? ''}>stub composer</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/search/mv-search-form', () => ({
|
||||
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
|
||||
}));
|
||||
vi.mock('@/components/email/email-viewer', () => ({
|
||||
EmailViewer: ({ onReply, replyDraft }: { onReply: () => void; replyDraft?: { preview?: string; onEdit: () => void } | null }) => (
|
||||
<div data-testid="viewer">
|
||||
message viewer
|
||||
<button onClick={() => onReply()}>stub reply</button>
|
||||
{replyDraft && <button onClick={replyDraft.onEdit}>stub reply draft: {replyDraft.preview}</button>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => device.current,
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({
|
||||
EmailList: ({ onEmailSelect }: { onEmailSelect: (email: unknown) => void }) => (
|
||||
<button onClick={() => onEmailSelect(DRAFT_ROW)}>stub draft row</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
|
||||
const drafts = { id: 'drafts', name: 'Drafts', role: 'drafts', totalEmails: 1, unreadEmails: 0 } as Mailbox;
|
||||
const MESSAGE = {
|
||||
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }],
|
||||
subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10,
|
||||
hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {}, messageId: ['plans@example.org'],
|
||||
} as never;
|
||||
const FULL_DRAFT = {
|
||||
...DRAFT_ROW, textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], bodyValues: { 1: { value: 'my answer' } },
|
||||
inReplyTo: ['plans@example.org'],
|
||||
};
|
||||
|
||||
type Client = Record<string, unknown> & { getEmail: ReturnType<typeof vi.fn> };
|
||||
let client: Client;
|
||||
|
||||
beforeEach(() => {
|
||||
device.current = { isMobile: false, isTablet: false, isDesktop: true };
|
||||
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) });
|
||||
client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
getEmail: vi.fn(async (id: string) => (id === 'd1' ? { ...FULL_DRAFT } : null)),
|
||||
getThread: vi.fn(async () => ({ id: 't1', emailIds: ['m1', 'd1'] })),
|
||||
getSomeEmails: vi.fn(async () => [{ ...(MESSAGE as object) }, { ...DRAFT_ROW }]),
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
|
||||
useEmailStore.setState({
|
||||
emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox, drafts],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
|
||||
selectedEmailKeys: new Set(), unreadSection: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
|
||||
describe('a draft in the list (compose-p2-8)', () => {
|
||||
it('opens in the composer on a click', async () => {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText('stub draft row'));
|
||||
const composer = await screen.findByTestId('stub-composer');
|
||||
expect(composer).toHaveAttribute('data-draft', 'd1');
|
||||
expect(composer).toHaveAttribute('data-mode', 'compose');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a message with a reply draft (compose-p2-6)', () => {
|
||||
it('shows the draft under the message', async () => {
|
||||
render(<MailApp />);
|
||||
expect(await screen.findByText('stub reply draft: my answer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens that draft on Reply instead of a new reply', async () => {
|
||||
render(<MailApp />);
|
||||
await screen.findByText('stub reply draft: my answer');
|
||||
fireEvent.click(screen.getByText('stub reply'));
|
||||
await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', 'd1'));
|
||||
// Under the message it answers, as a new reply is, not over it.
|
||||
expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('starts a new reply when the conversation has no reply to this message', async () => {
|
||||
client.getEmail = vi.fn(async (id: string) => (id === 'd1' ? { ...FULL_DRAFT, inReplyTo: ['other@example.org'] } : null));
|
||||
render(<MailApp />);
|
||||
await waitFor(() => expect(client.getEmail).toHaveBeenCalled());
|
||||
expect(screen.queryByText(/stub reply draft/)).toBeNull();
|
||||
fireEvent.click(screen.getByText('stub reply'));
|
||||
await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-mode', 'reply'));
|
||||
expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', '');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from 'react';
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, 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';
|
||||
import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
|
||||
import { useMoveDialogStore } from '@/components/email/move-to-dialog';
|
||||
|
||||
// The desktop app's Message menu with several messages selected: each line acts on all
|
||||
// of them, or isn't offered. Reply to one of seven, or Move for the open one only, acts on
|
||||
// mail nobody picked (usability pass 2, desktop-p2-3).
|
||||
|
||||
const seen = vi.hoisted(() => ({ sourceEmailId: undefined as string | undefined }));
|
||||
|
||||
|
||||
vi.mock('@/components/email/email-composer', async () => {
|
||||
const { useEffect, useRef, useState } = await import('react');
|
||||
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
|
||||
return {
|
||||
EmailComposer: ({ initialData, onSaveState, onSend, sourceEmailId }: {
|
||||
initialData?: { subject?: string; mode?: string } | null;
|
||||
onSaveState: (data: { subject: string; mode?: string }) => void;
|
||||
onSend: (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string }) => Promise<unknown>;
|
||||
sourceEmailId?: string;
|
||||
}) => {
|
||||
seen.sourceEmailId = sourceEmailId;
|
||||
useEffect(() => registerComposeFlush(async () => {}), []);
|
||||
const [subject, setSubject] = useState(initialData?.subject ?? '');
|
||||
const initial = useRef(subject);
|
||||
const latest = useRef(subject);
|
||||
latest.current = subject;
|
||||
useEffect(() => () => {
|
||||
if (latest.current !== initial.current) onSaveState({ subject: latest.current, mode: initialData?.mode });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<input aria-label="stub subject" value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
<button onClick={() => void onSend({ to: ['a@example.org'], cc: [], bcc: [], subject, body: 'x' })}>stub send</button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null }));
|
||||
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
// A compact desktop: rail and folders beside the list, the composer in the pane.
|
||||
const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: true, isDesktop: true } }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => device.current,
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
atLeast: (px: number) => `(width >= ${px}px)`,
|
||||
PHONE_BELOW: 600,
|
||||
}));
|
||||
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', totalEmails: 3, unreadEmails: 0, myRights: { mayRemoveItems: true, mayAddItems: true } } as unknown as Mailbox;
|
||||
const later = { id: 'later', name: 'Read later', role: null, totalEmails: 0, unreadEmails: 0, myRights: { mayRemoveItems: true, mayAddItems: true } } as unknown as Mailbox;
|
||||
const message = (id: string) => ({ id, threadId: `t-${id}`, subject: `Subject ${id}`, receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } }) as unknown as Email;
|
||||
const [a, b, c] = ['a', 'b', 'c'].map(message);
|
||||
|
||||
let reported: Record<string, unknown> = {};
|
||||
let named: string[] = [];
|
||||
let batchMoveToMailbox: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} })));
|
||||
reported = {};
|
||||
named = [];
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
menu: { setState: (state: Record<string, unknown>) => { reported = state; }, onCommand: () => () => {} },
|
||||
windows: { setMenuName: (name: string) => { named.push(name); } },
|
||||
};
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', proInterface: false } as never);
|
||||
batchMoveToMailbox = vi.fn().mockResolvedValue(true);
|
||||
useEmailStore.setState({
|
||||
emails: [a, b, c], selectedEmail: a, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox, later],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
|
||||
selectedEmailKeys: new Set(['a', 'b', 'c']), unreadSection: null, threadEmailsCache: new Map(), error: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
fetchEmailContent: vi.fn().mockResolvedValue(undefined),
|
||||
refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined),
|
||||
batchMoveToMailbox,
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetDesktopCommands();
|
||||
useMoveDialogStore.setState({ request: null });
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('the Message menu with three messages selected', () => {
|
||||
it('offers no Reply, Reply all, Forward or Star, which would act on one of them', async () => {
|
||||
render(<MailApp />);
|
||||
await waitFor(() => expect(reported['message.archive']).toBe(true));
|
||||
expect(reported['message.reply']).toBe(false);
|
||||
expect(reported['message.replyAll']).toBe(false);
|
||||
expect(reported['message.forward']).toBe(false);
|
||||
expect(reported['message.star']).toBe(false);
|
||||
expect(reported['message.openWindow']).toBe(false);
|
||||
expect(reported['message.move']).toBe(true);
|
||||
});
|
||||
|
||||
it('Move to… moves all three, and the toast says how many', async () => {
|
||||
render(<MailApp />);
|
||||
await waitFor(() => expect(reported['message.move']).toBe(true));
|
||||
act(() => { runDesktopCommand('message.move'); });
|
||||
const request = useMoveDialogStore.getState().request;
|
||||
expect(request?.emails.map((email) => email.id)).toEqual(['a', 'b', 'c']);
|
||||
await act(async () => { request!.onMove('later'); });
|
||||
await waitFor(() => expect(batchMoveToMailbox).toHaveBeenCalledWith(expect.anything(), 'later', undefined, expect.any(Array)));
|
||||
await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('moved_count_to'));
|
||||
});
|
||||
|
||||
it('with one message open and none selected, Reply and Move are for that one', async () => {
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set() } as never);
|
||||
render(<MailApp />);
|
||||
await waitFor(() => expect(reported['message.reply']).toBe(true));
|
||||
expect(reported['message.move']).toBe(true);
|
||||
act(() => { runDesktopCommand('message.move'); });
|
||||
expect(useMoveDialogStore.getState().request).toBeNull();
|
||||
expect(batchMoveToMailbox).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Window menu name of the main window', () => {
|
||||
it('is the folder, not the message open in it, so it never reads like the window of that message', async () => {
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set() } as never);
|
||||
render(<MailApp />);
|
||||
await waitFor(() => expect(named.at(-1)).toBe('sidebar.mailboxes.inbox'));
|
||||
expect(named).not.toContain('Subject a');
|
||||
});
|
||||
});
|
||||
@@ -114,11 +114,26 @@ describe('undo send toast', () => {
|
||||
await waitFor(() => expect(undoToast()).toBeDefined());
|
||||
const toast = undoToast()!;
|
||||
expect(toast.title).toBe('email_viewer.sending_in');
|
||||
// 30 s is the setting, but only 10 s are left of the window.
|
||||
expect(toast.duration).toBeLessThan(10_000);
|
||||
// 30 s is the setting, but only 10 s are left of the window. The toast
|
||||
// itself stays a moment longer, to say "Sent" once Undo is over.
|
||||
expect(toast.duration).toBeLessThanOrEqual(10_000);
|
||||
expect(toast.pauseOnHover).toBe(false);
|
||||
});
|
||||
|
||||
// compose-p2-12: once Undo is over the toast says the message went.
|
||||
it('turns into "Sent" with View before the server releases the message', async () => {
|
||||
render(<MailApp />);
|
||||
sendDelayedBy(2_500);
|
||||
|
||||
await waitFor(() => expect(undoToast()).toBeDefined());
|
||||
const id = undoToast()!.id;
|
||||
await waitFor(() => {
|
||||
const toast = useToastStore.getState().toasts.find((t) => t.id === id);
|
||||
expect(toast?.title).toBe('compose_send.sent');
|
||||
expect(toast?.action?.label).toBe('compose_send.view');
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
|
||||
+272
-61
@@ -56,9 +56,10 @@ import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
|
||||
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { discardWithUndo } from "@/lib/compose/draft-close-toast";
|
||||
import { findReplyDraft, threadDrafts } from "@/lib/reply-drafts";
|
||||
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 { runUndoSendCountdown, startSendProgress, UNDO_TOAST_SLACK_MS } 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";
|
||||
@@ -163,7 +164,8 @@ import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-ident
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog";
|
||||
import { MoveToDialog, MoveToDialogHost, useMoveDialogStore } from "@/components/email/move-to-dialog";
|
||||
import { useDesktopWindowName } from "@/lib/desktop/window-name";
|
||||
import { SearchResultCount } from "@/components/email/search-result-count";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -174,7 +176,7 @@ import { useOtherAccountsUnread } from "@/hooks/use-all-inboxes-unread";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { canDownload } from "@/lib/device-policy";
|
||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { consumePendingMailto, mailLinkAccountId, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
|
||||
import { setMailLinkOpener } from "@/lib/desktop/mail-links";
|
||||
import { requestTabReply } from "@/lib/pwa-notify/tab-reply";
|
||||
@@ -328,7 +330,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
composerSessionRef.current = composerSessionId;
|
||||
const composerHostRef = useRef('pane');
|
||||
// The compose session docked under its message (dockedReply).
|
||||
const dockedSessionRef = useRef<number | null>(null);
|
||||
const [dockedSession, setDockedSession] = useState<number | null>(null);
|
||||
// A reply draft opened again from its message (compose-p2-6): it docks too.
|
||||
const [resumedReplySession, setResumedReplySession] = useState<number | null>(null);
|
||||
const showComposerRef = useRef(showComposer);
|
||||
showComposerRef.current = showComposer;
|
||||
// Fields a plugin filled in through onBeforeCompose for the composer about
|
||||
@@ -343,6 +347,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// already uses the same seam), so entry points that replace the session can
|
||||
// ask before discarding unsaved text.
|
||||
const composerRequestCloseRef = useRef<((afterClose?: () => void) => void) | null>(null);
|
||||
// One composer at a time (compose-p2-2). Whatever opens a composer closes
|
||||
// the one on screen first, the way its X does: it saves and says "Draft
|
||||
// saved" with Discard, then the new one opens. When that save fails the old
|
||||
// composer stays with its question, and this never settles: nothing opens.
|
||||
const closeOpenComposer = useCallback(() => new Promise<void>((resolve) => {
|
||||
const requestClose = composerRequestCloseRef.current;
|
||||
if (showComposerRef.current && requestClose) requestClose(resolve);
|
||||
else resolve();
|
||||
}), []);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
@@ -384,6 +397,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
threadId: string; emailId: string; startedAt: number; promise: Promise<Email[] | null>;
|
||||
} | null>(null);
|
||||
const mvConversationOpenSeqRef = useRef(0);
|
||||
// compose-p2-6: a reply to the open message saved as a draft. The reader
|
||||
// shows it under the message, and Reply opens it again. The conversation
|
||||
// fetch notes the thread's drafts; a scan reads them fresh after a composer
|
||||
// closed or a draft went.
|
||||
const [replyDraft, setReplyDraft] = useState<{ emailId: string; draft: Email; source: DraftSource } | null>(null);
|
||||
const [replyDraftScan, setReplyDraftScan] = useState<{ emailId: string; n: number } | null>(null);
|
||||
const conversationDraftsRef = useRef<{ threadId: string; emailId: string; drafts: Email[] } | null>(null);
|
||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||
@@ -1205,12 +1225,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
},
|
||||
onCompose: () => {
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
void handleCompose();
|
||||
},
|
||||
onFocusSearch: () => {
|
||||
if (isScheduledView) return;
|
||||
@@ -1408,7 +1423,26 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// The desktop app's menu bar: the same handlers as the keys, and what's enabled
|
||||
// from what's on screen (lib/desktop/commands.ts).
|
||||
const desktopTarget = !showComposer && (!!selectedEmail || selectedEmailKeys.size > 0);
|
||||
const desktopSingle = !showComposer && !!selectedEmail;
|
||||
// With several selected, a menu line acts on all of them or isn't offered: Reply to one
|
||||
// of seven, or Move for the open one only, would act on mail nobody picked.
|
||||
const desktopMulti = selectedEmailKeys.size > 1;
|
||||
const desktopSingle = !showComposer && !!selectedEmail && !desktopMulti;
|
||||
const tListExtra = useTranslations('mail_list_extra');
|
||||
/** Message > Move to… on the selection, as the selection bar's Move does: one toast with the count. */
|
||||
const moveSelectionTo = async (mailboxId: string) => {
|
||||
if (!client) return;
|
||||
const count = selectedEmailKeys.size;
|
||||
const restore = restoreBatchesForSelection(client);
|
||||
const receipts: CrossAccountMoveReceipt[] = [];
|
||||
const destination = mailboxes.find((m) => m.id === mailboxId);
|
||||
await runBatchEmailAction(() => useEmailStore.getState().batchMoveToMailbox(client, mailboxId, undefined, receipts), {
|
||||
success: destination
|
||||
? tListExtra('moved_count_to', { count, mailbox: getMailboxPath(destination, mailboxes) })
|
||||
: tNotifications('emails_moved', { count }),
|
||||
error: tNotifications('move_failed'),
|
||||
undo: undoMoveAction(client, restore, undo, receipts),
|
||||
});
|
||||
};
|
||||
const mailLayoutSetting = useSettingsStore((s) => s.mailLayout);
|
||||
const layoutFixed = interfaceLayout === 'mountain-view';
|
||||
// A shared folder you may only read from: nothing can leave it.
|
||||
@@ -1426,7 +1460,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
'message.markRead': () => void keyboardHandlers.onMarkAsRead?.(),
|
||||
'message.markUnread': () => void keyboardHandlers.onMarkAsUnread?.(),
|
||||
'message.star': () => void keyboardHandlers.onToggleStar?.(),
|
||||
'message.move': () => { if (selectedEmail) setMoveDialogEmail(selectedEmail); },
|
||||
'message.move': () => {
|
||||
if (shortcutsTargetSelection) {
|
||||
const state = useEmailStore.getState();
|
||||
const picked = resolveSelectedEmails({ emails: state.emails, threadEmailsCache: state.threadEmailsCache, selectedEmailKeys });
|
||||
if (picked.length > 0) useMoveDialogStore.getState().open({ emails: picked, onMove: (mailboxId) => void moveSelectionTo(mailboxId) });
|
||||
} else if (selectedEmail) {
|
||||
setMoveDialogEmail(selectedEmail);
|
||||
}
|
||||
},
|
||||
'message.archive': keyboardHandlers.onArchive ? () => void keyboardHandlers.onArchive?.() : undefined,
|
||||
'message.spam': () => void keyboardHandlers.onToggleSpam?.(),
|
||||
'message.delete': () => void keyboardHandlers.onDelete?.(),
|
||||
@@ -1450,7 +1492,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen),
|
||||
'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen),
|
||||
'message.star': desktopSingle,
|
||||
'message.move': desktopSingle && desktopMayRemove,
|
||||
'message.move': desktopTarget && desktopMayRemove,
|
||||
'message.archive': desktopTarget && archiveActionEnabled && desktopMayRemove,
|
||||
'message.spam': desktopTarget && desktopMayRemove,
|
||||
'message.delete': desktopTarget && desktopMayRemove,
|
||||
@@ -1545,6 +1587,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle });
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle, otherAccountsUnread, isUnifiedView]);
|
||||
useDocumentTitle(titleContext);
|
||||
// The Window menu names this window by its folder, never by the open message (desktop-p2-5).
|
||||
const windowView = useMemo(() => {
|
||||
const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined;
|
||||
return mailbox ? localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)) : t('sidebar.mail');
|
||||
}, [selectedMailbox, mailboxes, t]);
|
||||
useDesktopWindowName(windowView, { withAccount: true });
|
||||
|
||||
// When this page is rendered inside the Pro shell as the Mail tab body,
|
||||
// we hoist every "show composer" intent into its own Pro tab and reset
|
||||
@@ -1730,8 +1778,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// A mail link from another program, in the desktop app: a compose window of its own,
|
||||
// as Outlook and Thunderbird do, and the main window stays where it was. Plugins get
|
||||
// their say first. False when it has to open here instead.
|
||||
const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise<boolean> => {
|
||||
if (!pending.fromOs || pending.initialFiles?.length || !hasDesktopWindows() || !popoutsAvailable()) return false;
|
||||
const openMailtoInWindow = useCallback(async (pending: DraftRequest, accountId?: string): Promise<boolean> => {
|
||||
if (!pending.fromOs || !hasDesktopWindows() || !popoutsAvailable()) return false;
|
||||
let prefill: ComposePrefill | null = null;
|
||||
if (emailHooks.onBeforeCompose.size > 0) {
|
||||
prefill = await runBeforeCompose(composeOptionsFor('compose', {
|
||||
@@ -1743,12 +1791,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// A plugin said no: nothing opens, here or there.
|
||||
if (!prefill) return true;
|
||||
}
|
||||
const opened = openComposerWindow(useAuthStore.getState().activeAccountId);
|
||||
// The account the link goes out from, in its own window: the main window keeps the one it shows.
|
||||
const opened = openComposerWindow(accountId ?? useAuthStore.getState().activeAccountId);
|
||||
if (!opened) return false;
|
||||
void opened.handOver({
|
||||
mode: 'compose',
|
||||
initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode),
|
||||
prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null,
|
||||
// Send to and Open with: the files go along (desktop-p2-6).
|
||||
...(pending.initialFiles?.length ? { initialFiles: pending.initialFiles } : {}),
|
||||
});
|
||||
return true;
|
||||
}, [identities, t]);
|
||||
@@ -1780,6 +1831,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}, [openMailtoInWindow, openMailtoHere]);
|
||||
|
||||
const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => {
|
||||
if (await openMailtoInWindow(pending, accountId)) {
|
||||
setPendingMailtoAccountChoice(null);
|
||||
return;
|
||||
}
|
||||
setIsProtocolAccountSwitching(true);
|
||||
try {
|
||||
if (useAuthStore.getState().activeAccountId !== accountId) {
|
||||
@@ -1790,7 +1845,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
} finally {
|
||||
setIsProtocolAccountSwitching(false);
|
||||
}
|
||||
}, [openMailtoDraft, switchAccount]);
|
||||
}, [openMailtoDraft, openMailtoInWindow, switchAccount]);
|
||||
|
||||
// onMailtoIntercept: a plugin may stop a mailto: from opening the composer.
|
||||
const mailtoAllowed = useCallback(async (pending: ParsedMailto & { href?: string }): Promise<boolean> => {
|
||||
@@ -1803,17 +1858,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// A draft that comes from outside: which account it goes out from.
|
||||
// A draft that comes from outside goes out from the default account, as in Outlook and
|
||||
// Thunderbird, with no question: the composer's From changes it (desktop-p2-8).
|
||||
const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise<void> => {
|
||||
const protocolAccounts = getMailtoProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
// The question is asked in this window: bring it up.
|
||||
if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
|
||||
setPendingMailtoAccountChoice(pending);
|
||||
return;
|
||||
}
|
||||
|
||||
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
|
||||
const accountId = mailLinkAccountId(getMailtoProtocolAccounts(), useAccountStore.getState().defaultAccountId, activeAccountId);
|
||||
if (accountId) {
|
||||
await openMailtoForAccount(pending, accountId);
|
||||
return;
|
||||
@@ -1871,6 +1919,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
body: handoff.body ?? '',
|
||||
initialFiles: handoff.files,
|
||||
unedited: true,
|
||||
...(handoff.fromOs ? { fromOs: true } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2888,11 +2937,51 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
setPendingDraft(null);
|
||||
}, []);
|
||||
|
||||
// A new message, from every Compose and New message there is.
|
||||
const handleCompose = async (onOpen?: () => void) => {
|
||||
await closeOpenComposer();
|
||||
await withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
onOpen?.();
|
||||
});
|
||||
};
|
||||
|
||||
// A minimised composer's edits live only in pendingDraft. Before an
|
||||
// automatic sign-out it is opened again and saves them itself.
|
||||
useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true));
|
||||
|
||||
// Reply on a message with a reply draft opens that draft (compose-p2-6).
|
||||
// After a composer closed (it may have saved that very draft under a new
|
||||
// id) the drafts are read fresh.
|
||||
const resumeReplyDraft = async (open: Email | null, composerWasOpen: boolean): Promise<boolean> => {
|
||||
if (!client || !open || open.keywords?.$draft || mvShell || isScheduledView) return false;
|
||||
let found = replyDraft?.emailId === open.id ? replyDraft : null;
|
||||
// A phone's conversation holds its drafts in full.
|
||||
const phoneConversation = isMobile && conversationEmails.some((e) => e.id === open.id);
|
||||
if (composerWasOpen || phoneConversation) {
|
||||
const { client: threadClient, accountId, clientAccountId } = resolveEmailBlobContext(open, client);
|
||||
if (!threadClient) return false;
|
||||
const drafts = phoneConversation && !composerWasOpen
|
||||
? conversationEmails
|
||||
: await threadDrafts(threadClient, open.threadId, accountId).catch(() => []);
|
||||
const draft = await findReplyDraft(drafts, open, (id) => threadClient.getEmail(id, accountId));
|
||||
found = draft ? {
|
||||
emailId: open.id, draft,
|
||||
source: { accountId, localAccountId: clientAccountId ?? activeAccountId ?? undefined },
|
||||
} : null;
|
||||
}
|
||||
if (!found) return false;
|
||||
await handleEditDraft(found.draft, found.source, { underMessage: true });
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleReply = async (draftText?: string) => {
|
||||
const composerWasOpen = showComposer;
|
||||
await closeOpenComposer();
|
||||
if (!draftText && await resumeReplyDraft(selectedEmail, composerWasOpen)) return;
|
||||
if (selectedEmail) {
|
||||
const formerSelected = { ...selectedEmail };
|
||||
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReply.transform(selectedEmail);
|
||||
@@ -2924,13 +3013,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
* undone or cancelled - the account it was sent from (a group or shared one
|
||||
* for mail sent from its identity) and the login that sent it.
|
||||
*/
|
||||
const handleEditDraft = async (email?: Email, source?: DraftSource) => {
|
||||
// `underMessage`: a reply draft resumed from the message it answers docks
|
||||
// under that message, as a new reply does (compose-p2-6).
|
||||
const handleEditDraft = async (email?: Email, source?: DraftSource, { underMessage = false }: { underMessage?: boolean } = {}) => {
|
||||
if (!client) return;
|
||||
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
|
||||
? email
|
||||
: selectedEmail;
|
||||
let draft = draftCandidate;
|
||||
if (!draft) return;
|
||||
await closeOpenComposer();
|
||||
|
||||
// The login and account the draft lives in (resolveDraftSource). The
|
||||
// composer removes the old version there when it saves, sends or
|
||||
@@ -2972,7 +3064,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
// Increment session ID to force the composer to remount with fresh state,
|
||||
// even if it was already open (e.g. right-clicking a draft while composing).
|
||||
setComposerSessionId(id => id + 1);
|
||||
const nextSession = composerSessionRef.current + 1;
|
||||
setComposerSessionId(nextSession);
|
||||
setResumedReplySession(underMessage ? nextSession : null);
|
||||
setPendingDraft(composerDataFromDraft(draft, {
|
||||
accountId,
|
||||
localAccountId,
|
||||
@@ -3155,10 +3249,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
// A message scheduled for later says when it goes, and offers Cancel
|
||||
// where a send-delay window offers Undo send.
|
||||
toast.success(isUndoWindow
|
||||
const undoToastId = toast.success(isUndoWindow
|
||||
? t('email_viewer.sending_in', { seconds: Math.round(remainingMs / 1000) })
|
||||
: t('compose_send.scheduled_for', { when: formatScheduledSendAt(pending.sendAt, useSettingsStore.getState().timeFormat) }), {
|
||||
duration: undoDurationMs,
|
||||
// An undo window turns into "Sent" as Undo ends (runUndoSendCountdown).
|
||||
duration: isUndoWindow ? undoDurationMs + UNDO_TOAST_SLACK_MS : undoDurationMs,
|
||||
pauseOnHover: false,
|
||||
secondaryAction: (pending.emailId && pending.identityId)
|
||||
? {
|
||||
@@ -3236,6 +3331,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
},
|
||||
});
|
||||
|
||||
// Counts down, then "Sent" with View (compose-p2-12).
|
||||
if (isUndoWindow) {
|
||||
const sentId = pending.emailId;
|
||||
const viewable = sentId && undoClient === client;
|
||||
const ownerAccountId = pending.submissionAccountId && pending.submissionAccountId !== client.getAccountId() ? pending.submissionAccountId : undefined;
|
||||
runUndoSendCountdown(undoToastId, {
|
||||
sendAt: Date.parse(pending.sendAt),
|
||||
undoEndsAt: Date.now() + undoDurationMs,
|
||||
maxSeconds: sendDelaySeconds,
|
||||
sendingIn: (seconds) => t('email_viewer.sending_in', { seconds }),
|
||||
sent: t('compose_send.sent'),
|
||||
view: viewable
|
||||
? { label: t('compose_send.view'), onClick: () => void applyMailDeepLinkRef.current({ kind: 'message', id: sentId, ownerAccountId }) }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const current = useEmailStore.getState().pendingUndoSend;
|
||||
if (current?.submissionId === pending.submissionId) {
|
||||
@@ -3254,6 +3366,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t, tComposer, tViewer]);
|
||||
|
||||
const handleReplyAll = async (draftText?: string) => {
|
||||
const composerWasOpen = showComposer;
|
||||
await closeOpenComposer();
|
||||
if (!draftText && await resumeReplyDraft(selectedEmail, composerWasOpen)) return;
|
||||
if (selectedEmail) {
|
||||
const formerSelected = { ...selectedEmail };
|
||||
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReplyAll.transform(selectedEmail);
|
||||
@@ -3343,6 +3458,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}, [isEmbedded]);
|
||||
|
||||
const handleForward = async () => {
|
||||
await closeOpenComposer();
|
||||
if (selectedEmail) {
|
||||
const formerSelected = { ...selectedEmail };
|
||||
const enrichedEmail = await emailHooks.onBeforeComposeOpenToForward.transform(selectedEmail);
|
||||
@@ -3389,6 +3505,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// unselected row. See the list context-menu wiring below.
|
||||
const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => {
|
||||
if (!email) return;
|
||||
await closeOpenComposer();
|
||||
email = await emailHooks.onBeforeComposeOpenToForwardAsAttachment.transform(email);
|
||||
// Same filename options "Export as .eml" uses (see emailFilenameOptions
|
||||
// in email-viewer.tsx), so the two actions produce consistent filenames
|
||||
@@ -3457,6 +3574,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const handleBatchForwardAsAttachment = async () => {
|
||||
const selected = resolveSelectedEmails(useEmailStore.getState());
|
||||
if (selected.length < 2) return; // menu only offers this for >1; guards future wiring
|
||||
await closeOpenComposer();
|
||||
|
||||
const {
|
||||
emailDownloadTemplate,
|
||||
@@ -4858,6 +4976,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId;
|
||||
const annotated = annotateScheduledEmails(emails, scheduledSubmissionByEmailId);
|
||||
conversationDraftsRef.current = {
|
||||
threadId: ref.threadId, emailId: ref.id,
|
||||
drafts: annotated.filter((e) => e.id !== ref.id && e.keywords?.$draft),
|
||||
};
|
||||
return annotated
|
||||
.filter((e) => e.id === ref.id || !e.keywords?.$draft)
|
||||
.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
|
||||
@@ -4927,6 +5049,62 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
// The open message's reply draft (compose-p2-6). Not in Mountain View, which
|
||||
// has its own conversation, nor on a phone, whose conversation lists drafts.
|
||||
const replyDraftMessageIds = String(selectedEmail?.messageId ?? '');
|
||||
useEffect(() => {
|
||||
const open = selectedEmail;
|
||||
if (!client || !open || open.keywords?.$draft || mvShell || isMobile || isScheduledView || !replyDraftMessageIds) {
|
||||
setReplyDraft(null);
|
||||
return;
|
||||
}
|
||||
const fresh = replyDraftScan?.emailId === open.id;
|
||||
const noted = conversationDraftsRef.current;
|
||||
const known = noted && noted.threadId === open.threadId && noted.emailId === open.id ? noted.drafts : null;
|
||||
if (!fresh && !known) {
|
||||
// The conversation isn't here yet: keep what this message had.
|
||||
setReplyDraft((prev) => (prev?.emailId === open.id ? prev : null));
|
||||
return;
|
||||
}
|
||||
if (!fresh && known?.length === 0) {
|
||||
setReplyDraft(null);
|
||||
return;
|
||||
}
|
||||
const { client: threadClient, accountId, clientAccountId } = resolveEmailBlobContext(open, client);
|
||||
if (!threadClient) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const drafts = fresh ? await threadDrafts(threadClient, open.threadId, accountId).catch(() => []) : known ?? [];
|
||||
const found = await findReplyDraft(drafts, open, (id) => threadClient.getEmail(id, accountId));
|
||||
if (cancelled) return;
|
||||
setReplyDraft(found ? {
|
||||
emailId: open.id,
|
||||
draft: { ...found, sourceClientAccountId: open.sourceClientAccountId, sourceAccountId: open.sourceAccountId },
|
||||
source: { accountId, localAccountId: clientAccountId ?? activeAccountId ?? undefined },
|
||||
} : null);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message and what can change its drafts
|
||||
}, [selectedEmail?.id, replyDraftMessageIds, mvConversationResult, replyDraftScan, client, mvShell, isMobile, isScheduledView]);
|
||||
// A composer closing may have saved, sent or discarded a reply draft.
|
||||
const scanReplyDrafts = () => {
|
||||
const open = useEmailStore.getState().selectedEmail;
|
||||
if (open) setReplyDraftScan((prev) => ({ emailId: open.id, n: (prev?.n ?? 0) + 1 }));
|
||||
};
|
||||
const composerWasOpenRef = useRef(showComposer);
|
||||
useEffect(() => {
|
||||
if (composerWasOpenRef.current && !showComposer) scanReplyDrafts();
|
||||
composerWasOpenRef.current = showComposer;
|
||||
}, [showComposer]);
|
||||
const discardReplyDraft = () => {
|
||||
const kept = replyDraft;
|
||||
if (!kept) return;
|
||||
setReplyDraft(null);
|
||||
discardWithUndo(() => {
|
||||
void handleDiscardDraft(kept.draft.id, kept.source).then(scanReplyDrafts);
|
||||
}, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setReplyDraft(kept));
|
||||
};
|
||||
|
||||
const patchMvConversation = (id: string, patch: (e: Email) => Email) =>
|
||||
setMvConversationResult(prev => prev ? {
|
||||
...prev, emails: prev.emails.map(e => e.id === id ? patch(e) : e),
|
||||
@@ -5000,14 +5178,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onImportEmail: handleImportEmailFromContextMenu,
|
||||
onRefreshMailboxes: handleRefreshMailboxes,
|
||||
onCompose: () => {
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView('viewer');
|
||||
}
|
||||
void handleCompose(() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
});
|
||||
},
|
||||
onSidebarClose: () => setSidebarOpen(false),
|
||||
@@ -5107,9 +5279,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Decided as it opens and kept for the session: moving it would mount it
|
||||
// anew when the message goes away (another folder opened meanwhile).
|
||||
const dockCandidate = !mvShell && !isMobile && showComposer && !isEmbedded && !floatingComposer
|
||||
&& (pendingDraft?.mode ?? composerMode) !== 'compose';
|
||||
if (dockCandidate && selectedEmail && !selectedEmail.keywords?.$draft) dockedSessionRef.current = composerSessionId;
|
||||
const dockedReply = dockCandidate && dockedSessionRef.current === composerSessionId;
|
||||
&& ((pendingDraft?.mode ?? composerMode) !== 'compose' || resumedReplySession === composerSessionId);
|
||||
const dockOnOpen = dockCandidate && Boolean(selectedEmail) && !selectedEmail?.keywords?.$draft;
|
||||
if (dockOnOpen && dockedSession !== composerSessionId) setDockedSession(composerSessionId);
|
||||
const dockedReply = dockCandidate && (dockOnOpen || dockedSession === composerSessionId);
|
||||
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane';
|
||||
composerHostRef.current = composerHost;
|
||||
// Rows ticked beyond the open message take the reading pane (mail-p2-3):
|
||||
@@ -5368,6 +5541,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
// Handle reply from conversation view
|
||||
const handleConversationReply = async (email: Email) => {
|
||||
const composerWasOpen = showComposer;
|
||||
await closeOpenComposer();
|
||||
if (await resumeReplyDraft(email, composerWasOpen)) return;
|
||||
email = await emailHooks.onBeforeComposeOpenToReply.transform(email);
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'reply');
|
||||
@@ -5380,6 +5556,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
};
|
||||
|
||||
const handleConversationReplyAll = async (email: Email) => {
|
||||
const composerWasOpen = showComposer;
|
||||
await closeOpenComposer();
|
||||
if (await resumeReplyDraft(email, composerWasOpen)) return;
|
||||
email = await emailHooks.onBeforeComposeOpenToReplyAll.transform(email);
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'replyAll');
|
||||
@@ -5392,6 +5571,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
};
|
||||
|
||||
const handleConversationForward = async (email: Email) => {
|
||||
await closeOpenComposer();
|
||||
email = await emailHooks.onBeforeComposeOpenToForward.transform(email);
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'forward');
|
||||
@@ -5490,6 +5670,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
docked={composerHost === 'dock'}
|
||||
sourceEmailId={answeredEmailId}
|
||||
onSaveState={(data) => {
|
||||
// A composer whose session another one has replaced: its state
|
||||
// belongs to no composer on screen. Taken as the new session's,
|
||||
// it turned a new message into the old reply and moved it between
|
||||
// the dock and the pane for ever (React error 185, compose-p2-1).
|
||||
// Whatever opens a composer closes the old one first, which saves it.
|
||||
if (composerSessionRef.current !== composerSessionId) return;
|
||||
setComposerPrefill(null);
|
||||
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
|
||||
suppressComposerStateSaveSessionRef.current = null;
|
||||
@@ -5521,6 +5707,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const outcome = await handleEmailSend(data, state);
|
||||
setPendingDraft(null);
|
||||
setComposerPrefill(null);
|
||||
scanReplyDrafts();
|
||||
return outcome;
|
||||
}}
|
||||
onSendFailed={(state) => {
|
||||
@@ -5551,7 +5738,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
requestCloseRef={composerRequestCloseRef}
|
||||
onDiscardDraft={(draftId, draft) => {
|
||||
handleDiscardDraft(draftId, draft);
|
||||
setPendingDraft(null);
|
||||
// The bin and the toasts' Discard delete once their Undo is over,
|
||||
// when another composer may be open: its draft stays.
|
||||
if (composerSessionRef.current === composerSessionId) setPendingDraft(null);
|
||||
}}
|
||||
onRestoreDiscarded={(data) => {
|
||||
void closeOpenComposer().then(() => {
|
||||
startFreshComposerSession();
|
||||
setPendingDraft(data);
|
||||
setComposerMode(data.mode);
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
}}
|
||||
popOut={popoutsOn && !isEmbedded ? composerPopOut : undefined}
|
||||
/>
|
||||
@@ -6025,7 +6223,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}}
|
||||
onRescheduleScheduled={(email) => setRescheduleTarget(email)}
|
||||
onCancelScheduledSend={handleCancelScheduledSend}
|
||||
onEmailSelect={handleEmailSelect}
|
||||
// A click (or Enter) on a draft opens it in the composer, as
|
||||
// Gmail and Outlook do (compose-p2-8). j/k still read it.
|
||||
// Mountain View and Scheduled keep their own ways.
|
||||
onEmailSelect={!mvShell && !isScheduledView && !isEmbedded ? (email) => {
|
||||
if (email.keywords?.$draft) void handleEditDraft(email).catch(reportDraftOpenFailure);
|
||||
else void handleEmailSelect(email);
|
||||
} : handleEmailSelect}
|
||||
onOpenSearchResult={handleEmailSelect}
|
||||
onEmailPrefetch={handleEmailPrefetch}
|
||||
onEmailDoubleClick={!isEmbedded ? (desktopDoubleClick ? (email) => {
|
||||
@@ -6088,12 +6292,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}}
|
||||
onClearSearch={() => void handleClearSearch()}
|
||||
onCompose={() => {
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
void handleCompose();
|
||||
}}
|
||||
onMarkAsRead={async (email, read) => {
|
||||
await markReadWithUndo(email, read).catch(toastUpdateFailure);
|
||||
@@ -6136,12 +6335,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
button no longer sits on the list's rows. */}
|
||||
{!mvShell && (isMobile || isEmbedded) && <Button
|
||||
onClick={() => {
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
void handleCompose();
|
||||
}}
|
||||
className={cn(
|
||||
// Under the drawer's backdrop (z-40), which comes earlier in the page.
|
||||
@@ -6269,6 +6463,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onReply={handleConversationReply}
|
||||
onReplyAll={handleConversationReplyAll}
|
||||
onForward={handleConversationForward}
|
||||
onEditDraft={(draft) => {
|
||||
void handleEditDraft(draft, {
|
||||
accountId: conversationBlobSource?.accountId,
|
||||
localAccountId: conversationBlobSource?.clientAccountId ?? activeAccountId ?? undefined,
|
||||
}).catch(reportDraftOpenFailure);
|
||||
}}
|
||||
onDiscardDraft={(draft) => {
|
||||
const before = conversationEmails;
|
||||
setConversationEmails(before.filter((e) => e.id !== draft.id));
|
||||
discardWithUndo(() => {
|
||||
void handleDiscardDraft(draft.id, {
|
||||
accountId: conversationBlobSource?.accountId,
|
||||
localAccountId: conversationBlobSource?.clientAccountId ?? activeAccountId ?? undefined,
|
||||
});
|
||||
}, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setConversationEmails(before));
|
||||
}}
|
||||
blobSource={conversationBlobSource}
|
||||
onDownloadAttachment={handleDownloadConversationAttachment}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
@@ -6395,6 +6605,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
} : undefined}
|
||||
mvInlineComposer={mvInlineReply ? <MvComposeInline onPopOut={handleMvPopOut}>{composerNode}</MvComposeInline> : undefined}
|
||||
replyComposerOpen={dockedReply}
|
||||
replyDraft={replyDraft && selectedEmail && replyDraft.emailId === selectedEmail.id ? {
|
||||
preview: replyDraft.draft.preview,
|
||||
onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source, { underMessage: true }).catch(reportDraftOpenFailure),
|
||||
onDiscard: discardReplyDraft,
|
||||
} : null}
|
||||
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
|
||||
onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined}
|
||||
isFullscreen={fullscreenReading}
|
||||
@@ -6420,11 +6635,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
}}
|
||||
onCompose={() => {
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
});
|
||||
void handleCompose();
|
||||
}}
|
||||
emptyPaneContent={emptyPaneBesideList && selectedEmailKeys.size > 0 && !isScheduledView && !mvShell
|
||||
? <div ref={setSelectionPaneHost} className="m-auto p-8" />
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronUp } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBottomControls } from "@/hooks/use-bottom-controls";
|
||||
|
||||
/**
|
||||
* Below this reading-pane height a docked reply can't share the pane with
|
||||
@@ -35,8 +34,6 @@ export function ReplyDock({ children }: { children: ReactNode }) {
|
||||
// The reader's toolbar and subject above its scrolling part: they stay.
|
||||
const [readerChrome, setReaderChrome] = useState(0);
|
||||
const [showMessage, setShowMessage] = useState(false);
|
||||
// Toasts sit above the reply and its Send button (mail-p2-12).
|
||||
const barRef = useBottomControls();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pane = ref.current?.parentElement;
|
||||
@@ -81,7 +78,7 @@ export function ReplyDock({ children }: { children: ReactNode }) {
|
||||
</button>
|
||||
)}
|
||||
{/* Folded, the composer stays mounted, with its text and its undo history. */}
|
||||
<div ref={barRef} className={cn("flex min-h-0 flex-1 flex-col", folded && "hidden")}>
|
||||
<div className={cn("flex min-h-0 flex-1 flex-col", folded && "hidden")}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { emailHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
// The composer's window: when it tells the opening window to let go, and what
|
||||
@@ -610,11 +611,51 @@ describe('a send held for undo', () => {
|
||||
expect(closeSelf).not.toHaveBeenCalled();
|
||||
const sent = heard.find((message) => message.type === 'sent') as Extract<PopoutMessage, { type: 'sent' }> | undefined;
|
||||
expect(sent?.notice.pendingUndoSend ?? null).toBeNull();
|
||||
// compose-p2-13: the main window asks for the Scheduled count again then.
|
||||
expect(sent?.notice.releasesAt).toBe(sendAt);
|
||||
});
|
||||
|
||||
// The server's time runs past the delay: "Sending in 6 seconds" for 5 s.
|
||||
it('never counts from more than the delay the user picked', async () => {
|
||||
const before = useSettingsStore.getState().sendDelaySeconds;
|
||||
useSettingsStore.setState({ sendDelaySeconds: 5 } as never);
|
||||
await sendFromWindow(5.8);
|
||||
expect(screen.getByTestId('popout-sending')).toHaveAttribute('data-seconds', '5');
|
||||
useSettingsStore.setState({ sendDelaySeconds: before } as never);
|
||||
});
|
||||
|
||||
// compose-24: the window sat with a greyed Send until the server answered.
|
||||
it('says "Sending…" as soon as Send goes through, before the server answers', async () => {
|
||||
let answer!: () => void;
|
||||
useEmailStore.setState({ sendEmail: vi.fn(() => new Promise((resolve) => { answer = () => resolve({}); })) } as never);
|
||||
sendAt = new Date(Date.now() + 5000).toISOString();
|
||||
window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice');
|
||||
render(<PopoutComposeApp />);
|
||||
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
|
||||
let done: Promise<unknown> = Promise.resolve();
|
||||
act(() => { done = (composer as unknown as Sending).onSend(DATA); });
|
||||
expect(screen.getByTestId('popout-sending').textContent).toMatch(/sending$/);
|
||||
await act(async () => { answer(); await done; });
|
||||
});
|
||||
|
||||
it('shows the composer again with its text when the send fails', async () => {
|
||||
useEmailStore.setState({ sendEmail: vi.fn(async () => { throw new Error('refused'); }) } as never);
|
||||
sendAt = new Date(Date.now() + 5000).toISOString();
|
||||
window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice');
|
||||
render(<PopoutComposeApp />);
|
||||
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
|
||||
await act(async () => {
|
||||
await expect((composer as unknown as Sending).onSend(DATA)).rejects.toThrow('refused');
|
||||
});
|
||||
expect(screen.queryByTestId('popout-sending')).toBeNull();
|
||||
expect(screen.getByTestId('composer').closest('.hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes once the undo time is over', async () => {
|
||||
await sendFromWindow(1.2);
|
||||
expect(closeSelf).not.toHaveBeenCalled();
|
||||
// As the main window's toast: "Sent" once Undo is over (compose-24).
|
||||
await waitFor(() => expect(screen.getByTestId('popout-sending').textContent).toMatch(/(^|\.)sent$/), { timeout: 3000 });
|
||||
await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 });
|
||||
});
|
||||
|
||||
@@ -629,7 +670,8 @@ describe('a send held for undo', () => {
|
||||
|
||||
it('a message scheduled for later still goes to the main window, and the window closes', async () => {
|
||||
await sendFromWindow(3600);
|
||||
expect(screen.queryByTestId('popout-sending')).toBeNull();
|
||||
// "Sending…" may still show as the window goes, but no Undo here.
|
||||
expect(screen.queryByRole('button', { name: 'undo_send' })).toBeNull();
|
||||
expect(closeSelf).toHaveBeenCalled();
|
||||
const sent = heard.find((message) => message.type === 'sent') as Extract<PopoutMessage, { type: 'sent' }> | undefined;
|
||||
expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1');
|
||||
|
||||
@@ -309,6 +309,23 @@ describe('PopoutHost', () => {
|
||||
expect(useToastStore.getState().toasts).toHaveLength(1);
|
||||
});
|
||||
|
||||
// compose-p2-13: the Scheduled count kept a pop-out's undo-held send
|
||||
// until the page was reloaded, since nothing reports the release.
|
||||
it('asks for the Scheduled count again once a send held in a pop-out has gone', async () => {
|
||||
const refreshScheduledMetadata = vi.fn(async () => {});
|
||||
useEmailStore.setState({ refreshScheduledMetadata: refreshScheduledMetadata as never });
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
popout.post({ type: 'sent', requestId: 'r1', notice: { accountId: 'alice', scheduled: true, releasesAt: new Date(Date.now() + 100).toISOString() } });
|
||||
popout.post({ type: 'sent', requestId: 'r2', notice: { accountId: 'alice', scheduled: true, releasesAt: '2030-01-01T00:00:00Z' } });
|
||||
await settle();
|
||||
expect(refreshScheduledMetadata).not.toHaveBeenCalled();
|
||||
await settle(700);
|
||||
// Once, for the held one; the one scheduled for 2030 stays in the count.
|
||||
expect(refreshScheduledMetadata).toHaveBeenCalledTimes(1);
|
||||
expect(refreshScheduledMetadata).toHaveBeenCalledWith(client);
|
||||
});
|
||||
|
||||
it('says once that a closed pop-out kept its draft, with Discard', async () => {
|
||||
render(<><PopoutHost /><PopoutHost /></>);
|
||||
await settle();
|
||||
@@ -320,6 +337,18 @@ describe('PopoutHost', () => {
|
||||
expect(toasts[0].action?.label).toBe('email_composer.discard');
|
||||
});
|
||||
|
||||
// compose-p2-10: the window's bin.
|
||||
it('says a pop-out discarded its draft, with Undo', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
popout.post({ type: 'draft-kept', requestId: 'k2', notice: { draftId: 'd1', localAccountId: 'alice', discarded: true } });
|
||||
await settle();
|
||||
const toasts = useToastStore.getState().toasts;
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0].title).toBe('compose_ux.draft_discarded');
|
||||
expect(toasts[0].action?.label).toBe('compose_ux.undo');
|
||||
});
|
||||
|
||||
it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
|
||||
@@ -5,11 +5,14 @@ import { useTranslations } from "next-intl";
|
||||
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { useDesktopWindowName } from "@/lib/desktop/window-name";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { secondsUntilSend } from "@/lib/outgoing/send-progress-toast";
|
||||
import { formatRejectedRecipients } from "@/lib/jmap/client";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
|
||||
@@ -67,6 +70,17 @@ interface ReopenTarget {
|
||||
ownerAccountId?: string;
|
||||
}
|
||||
|
||||
/** "Sending…" or "Sent", where the composer was, with the send icon. */
|
||||
function SendStatus({ text, seconds, children }: { text: string; seconds?: number; children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending" data-seconds={seconds}>
|
||||
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
<p role="status" className="text-sm text-foreground">{text}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A send held for undo, shown in the window it was sent from: that's where the
|
||||
* user is looking, and quick compose is used with the main window out of sight.
|
||||
@@ -79,6 +93,7 @@ function SendingWithUndo({ held, onUndone, onGone }: {
|
||||
onGone: () => void;
|
||||
}) {
|
||||
const tViewer = useTranslations("email_viewer");
|
||||
const tSend = useTranslations("compose_send");
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const sendAt = Date.parse(held.sendAt);
|
||||
@@ -122,17 +137,18 @@ function SendingWithUndo({ held, onUndone, onGone }: {
|
||||
onUndone(held.isSmime ? null : restored);
|
||||
};
|
||||
|
||||
const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000));
|
||||
// As the main window's toast counts: never past the delay the user picked.
|
||||
const seconds = secondsUntilSend(sendAt, now, useSettingsStore.getState().sendDelaySeconds);
|
||||
// Once Undo is over, "Sent" for the moment before the window goes, as the
|
||||
// main window's toast says it.
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending">
|
||||
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
<p role="status" className="text-sm text-foreground">{tViewer("sending_in", { seconds })}</p>
|
||||
<SendStatus text={over && !busy ? tSend("sent") : tViewer("sending_in", { seconds })} seconds={over ? undefined : seconds}>
|
||||
{!over && (
|
||||
<Button onClick={() => void undo()} disabled={busy} data-testid="popout-undo-send">
|
||||
{tViewer("undo_send")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SendStatus>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -313,6 +329,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
|
||||
const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`);
|
||||
|
||||
const mode = payload.initialData?.mode ?? payload.mode;
|
||||
// The Window menu names a composer by its subject, or "New message" before there is one.
|
||||
const [subject, setSubject] = useState(payload.initialData?.subject ?? "");
|
||||
useDesktopWindowName(subject.trim() || t("email_composer.new_message"));
|
||||
useDocumentTitle(
|
||||
mode === "reply" ? t("email_composer.reply")
|
||||
: mode === "replyAll" ? t("email_composer.reply_all")
|
||||
@@ -320,7 +339,11 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
|
||||
: t("email_composer.new_message"),
|
||||
);
|
||||
|
||||
const handleSend = useCallback(async (data: SendData) => {
|
||||
// "Sending…" from the moment Send goes through, as the main window says it
|
||||
// (compose-24); the composer waited greyed out for the server before. It
|
||||
// stays mounted behind, and comes back with the text if the send fails.
|
||||
const [sending, setSending] = useState(false);
|
||||
const sendFromWindow = useCallback(async (data: SendData) => {
|
||||
if (!client) return false;
|
||||
const sendClient = data.localAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(data.localAccountId) ?? client)
|
||||
@@ -377,6 +400,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
|
||||
filingError: !!result.filingError,
|
||||
rejectedRecipients,
|
||||
pendingUndoSend: keepHere ? null : withLogin,
|
||||
releasesAt: keepHere?.sendAt,
|
||||
});
|
||||
// No main window to say so, and this is the only notice these get (#1123).
|
||||
if (!taken && (result.filingError || rejectedRecipients)) {
|
||||
@@ -385,6 +409,16 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
|
||||
await new Promise((resolve) => setTimeout(resolve, WARNING_READ_MS));
|
||||
}
|
||||
}, [client, mode, payload.sourceEmailId, payload.sourceEmailSource, t]);
|
||||
const handleSend = useCallback(async (data: SendData) => {
|
||||
if (!client) return false;
|
||||
if (!data.delayedUntil) setSending(true);
|
||||
try {
|
||||
return await sendFromWindow(data);
|
||||
} catch (error) {
|
||||
setSending(false);
|
||||
throw error;
|
||||
}
|
||||
}, [client, sendFromWindow]);
|
||||
|
||||
const handleDiscardDraft = useCallback(async (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => {
|
||||
if (!client) return;
|
||||
@@ -487,6 +521,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
|
||||
onDraftKept: (draftId: string, where: { accountId?: string; localAccountId?: string }) => {
|
||||
announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined });
|
||||
},
|
||||
onDraftDiscarded: (draftId: string, where: { accountId?: string; localAccountId?: string }) => {
|
||||
announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined, discarded: true });
|
||||
},
|
||||
onDraftSaved: (draftId: string, where: { accountId?: string; localAccountId?: string } = {}) => {
|
||||
const active = useAuthStore.getState().activeAccountId;
|
||||
announceMailChanged(active);
|
||||
@@ -506,12 +543,16 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
|
||||
if (held) return <SendingWithUndo held={held} onUndone={heldUndone} onGone={heldGone} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{sending && <SendStatus text={t("compose_send.sending")} />}
|
||||
<div className={sending ? "hidden" : "contents"}>
|
||||
<ErrorBoundary fallback={ComposerErrorFallback}>
|
||||
<EmailComposer
|
||||
mode={mode}
|
||||
replyTo={payload.replyTo}
|
||||
initialDraftText={payload.initialDraftText}
|
||||
initialData={payload.initialData}
|
||||
initialFiles={payload.initialFiles ?? null}
|
||||
prefill={payload.prefill}
|
||||
sessionKey={sessionKey}
|
||||
sourceEmailId={payload.sourceEmailId ?? undefined}
|
||||
@@ -523,9 +564,12 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
|
||||
ownWindow={ownWindow}
|
||||
popOut={mainHoldsLogin ? backToMain : undefined}
|
||||
popOutToMainLabel={mainHoldsLogin ? tQuick("back_to_main") : undefined}
|
||||
onSubjectChange={setSubject}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { showDraftSavedToast } from "@/lib/compose/draft-close-toast";
|
||||
import { discardWithUndo, showDraftSavedToast } from "@/lib/compose/draft-close-toast";
|
||||
import { newPopoutId, openPopoutChannel, type PopoutChannel, type PopoutMessage, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel";
|
||||
import { takeOverMoved } from "@/lib/popout/moved-undo";
|
||||
import { keepRescuedDraft } from "@/lib/popout/rescued-drafts";
|
||||
@@ -24,6 +24,10 @@ const LIST_CHANGE_DEBOUNCE_MS = 400;
|
||||
const CLAIM_HOLD_MS = 15_000;
|
||||
/** How long a window remembers which draft id a rescue became, for a reload of the pop-out. */
|
||||
const RESCUE_MEMORY_MS = 60_000;
|
||||
/** The Scheduled count is asked again this long after the server let a held send go. */
|
||||
const RELEASE_REFRESH_MS = 3000;
|
||||
/** Longer holds are messages scheduled for later, which stay in the count. */
|
||||
const UNDO_HOLD_MS = 120_000;
|
||||
|
||||
/** The sessions this window can lend. Empty until it is signed in, and in the demo. */
|
||||
function shareableSessions(): SharedAccountSession[] {
|
||||
@@ -112,8 +116,26 @@ export function PopoutHost() {
|
||||
}, REFRESH_THROTTLE_MS);
|
||||
};
|
||||
|
||||
// The Scheduled count holds a send while the server holds it. Nothing
|
||||
// reports the release (compose-p2-13): ask again once it is out.
|
||||
const releaseTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
const refreshScheduled = () => {
|
||||
const client = useAuthStore.getState().client;
|
||||
if (client) void useEmailStore.getState().refreshScheduledMetadata(client);
|
||||
};
|
||||
const refreshScheduledAfter = (releasesAt: string) => {
|
||||
const wait = Date.parse(releasesAt) - Date.now();
|
||||
if (!Number.isFinite(wait) || wait > UNDO_HOLD_MS) return;
|
||||
const timer = setTimeout(() => {
|
||||
releaseTimers.delete(timer);
|
||||
refreshScheduled();
|
||||
}, Math.max(0, wait) + RELEASE_REFRESH_MS);
|
||||
releaseTimers.add(timer);
|
||||
};
|
||||
|
||||
const takeOverSent = (notice: PopoutSentNotice) => {
|
||||
const translate = tRef.current;
|
||||
if (notice.releasesAt) refreshScheduledAfter(notice.releasesAt);
|
||||
if (notice.filingError) toast.warning(translate("email_composer.send_filing_warning"));
|
||||
if (notice.rejectedRecipients) {
|
||||
toast.warning(translate("email_composer.send_some_recipients_rejected"), { message: notice.rejectedRecipients, duration: 15000 });
|
||||
@@ -180,10 +202,10 @@ export function PopoutHost() {
|
||||
break;
|
||||
case "draft-kept": {
|
||||
// Said here, as the pop-out closes with its Escape or X.
|
||||
const { draftId, accountId, localAccountId } = message.notice;
|
||||
const { draftId, accountId, localAccountId, discarded } = message.notice;
|
||||
claimOnce(`bulwark-popout-kept:${message.requestId}`, () => {
|
||||
const tr = tRef.current;
|
||||
showDraftSavedToast(() => {
|
||||
const discard = () => {
|
||||
const auth = useAuthStore.getState();
|
||||
const login = localAccountId ? auth.getClientForAccount(localAccountId) : auth.client;
|
||||
if (!login) {
|
||||
@@ -191,12 +213,16 @@ export function PopoutHost() {
|
||||
return;
|
||||
}
|
||||
void login.deleteEmail(draftId, accountId).catch((error: unknown) => console.error("Failed to discard draft:", error));
|
||||
}, {
|
||||
};
|
||||
const labels = {
|
||||
saved: tr("email_composer.draft_saved"),
|
||||
discard: tr("email_composer.discard"),
|
||||
discarded: tr("compose_ux.draft_discarded"),
|
||||
undo: tr("compose_ux.undo"),
|
||||
});
|
||||
};
|
||||
// The window's bin: Undo leaves the draft in Drafts.
|
||||
if (discarded) discardWithUndo(discard, labels);
|
||||
else showDraftSavedToast(discard, labels);
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -272,6 +298,7 @@ export function PopoutHost() {
|
||||
stopAccounts();
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
if (listTimer) clearTimeout(listTimer);
|
||||
for (const timer of releaseTimers) clearTimeout(timer);
|
||||
channel?.close();
|
||||
};
|
||||
}, [available]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { useDesktopWindowName } from "@/lib/desktop/window-name";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -105,6 +106,8 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email
|
||||
const [subject, setSubject] = useState<string | null>(null);
|
||||
const subjectInTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled("tabTitleSubjectEnabled"));
|
||||
useDocumentTitle(subjectInTitle ? subject : null);
|
||||
// Kept out where the organisation keeps subjects out of titles: the shell then says "Message".
|
||||
useDesktopWindowName(subjectInTitle ? subject : null);
|
||||
|
||||
// Folders first: without them Delete has no Trash to move to, Archive has no
|
||||
// button yet, and the toolbar moves under the pointer once they arrive.
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ProtocolAccountPicker({
|
||||
{t("switching_account")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t("select_account_note")}</span>
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -256,3 +256,26 @@ describe('NotificationSettings - push errors', () => {
|
||||
expect(text).toBe('Relay register failed: 502');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationSettings - the unread count in the desktop app', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailNotificationsEnabled: true });
|
||||
useAuthStore.setState({ client: fakeClient, username: 'me@example.com' });
|
||||
usePolicyStore.setState({ ...usePolicyStore.getState(), policy: {}, isSettingLocked: () => false, isSettingHidden: () => false } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
});
|
||||
|
||||
it('speaks of the taskbar icon there, not of a tab', async () => {
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
|
||||
const { findByText, queryByText } = render(<NotificationSettings />);
|
||||
await findByText('unread_badge_label');
|
||||
expect(queryByText('favicon_unread_badge.label')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the tab in a browser', async () => {
|
||||
const { findByText } = render(<NotificationSettings />);
|
||||
await findByText('favicon_unread_badge.label');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,16 +5,10 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopOsStatus } from "@/lib/desktop/types";
|
||||
import { describeAccelerator, showShortcutDefault } from "@/lib/desktop/shortcut-keys";
|
||||
import { DesktopQuickComposeSetting } from "./desktop-quick-compose-setting";
|
||||
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
|
||||
|
||||
/** On Windows and Linux this is what a press of the keys shows; macOS maps it to Cmd. */
|
||||
const SHOW_SHORTCUT = "CommandOrControl+Shift+B";
|
||||
|
||||
function shortcutLabel(platform: string | undefined): string {
|
||||
return platform === "darwin" ? "⇧⌘B" : "Ctrl+Shift+B";
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop app's own settings: only where there's an app to set. A browser never sees
|
||||
* them, and they render nothing until the bridge has answered. The General page has how
|
||||
@@ -46,6 +40,8 @@ export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notificati
|
||||
|
||||
if (!os || !status) return null;
|
||||
const { settings } = status;
|
||||
const platform = bridge?.platform;
|
||||
const showKeys = describeAccelerator(settings.showShortcut ?? showShortcutDefault(platform), platform);
|
||||
const change = (patch: Partial<Settings>) => {
|
||||
void os.setSettings(patch).then(setStatus);
|
||||
};
|
||||
@@ -103,12 +99,12 @@ export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notificati
|
||||
<SettingItem
|
||||
label={t("show_shortcut_label")}
|
||||
description={settings.showShortcut && !status.shortcutActive
|
||||
? t("show_shortcut_taken", { keys: shortcutLabel(bridge?.platform) })
|
||||
: t("show_shortcut_description", { keys: shortcutLabel(bridge?.platform) })}
|
||||
? t("show_shortcut_taken", { keys: showKeys })
|
||||
: t("show_shortcut_description", { keys: showKeys })}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={!!settings.showShortcut}
|
||||
onChange={(on) => change({ showShortcut: on ? SHOW_SHORTCUT : null })}
|
||||
onChange={(on) => change({ showShortcut: on ? showShortcutDefault(platform) : null })}
|
||||
/>
|
||||
</SettingItem>
|
||||
{status.quickCompose && (
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { webPushErrorKey } from '@/lib/web-push-errors';
|
||||
import type { PushDevice } from '@/lib/web-push';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { isDesktopApp } from '@/lib/desktop/bridge';
|
||||
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
|
||||
import {
|
||||
resolveActiveRelayUrl,
|
||||
@@ -55,6 +56,7 @@ type DevicesState =
|
||||
export function NotificationSettings() {
|
||||
const t = useTranslations('settings.notifications');
|
||||
const tAppearance = useTranslations('settings.appearance');
|
||||
const tDesktopP2 = useTranslations('uxp2_desktop');
|
||||
const tUnread = useTranslations('account_unread');
|
||||
const severalAccounts = useAccountStore((s) => s.accounts.length > 1);
|
||||
const tThirdParty = useTranslations('third_party');
|
||||
@@ -354,8 +356,11 @@ export function NotificationSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* The tab/app icon badge is a new-mail signal too; people look for it here. */}
|
||||
<SettingItem label={tAppearance('favicon_unread_badge.label')} description={tAppearance('favicon_unread_badge.description')}>
|
||||
{/* The tab/app icon badge is a new-mail signal too; people look for it here. The desktop app has no tab: the taskbar icon. */}
|
||||
<SettingItem
|
||||
label={isDesktopApp() ? tDesktopP2('unread_badge_label') : tAppearance('favicon_unread_badge.label')}
|
||||
description={isDesktopApp() ? tDesktopP2('unread_badge_description') : tAppearance('favicon_unread_badge.description')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={faviconUnreadBadge}
|
||||
onChange={(checked) => updateSetting('faviconUnreadBadge', checked)}
|
||||
|
||||
@@ -95,6 +95,7 @@ import { useAccountStore } from '@/stores/account-store';
|
||||
import { useMediaQuery, atLeast, PHONE_BELOW } from '@/hooks/use-media-query';
|
||||
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
|
||||
import { useDocumentTitle } from '@/hooks/use-document-title';
|
||||
import { useDesktopWindowName } from '@/lib/desktop/window-name';
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
import { scrollBehavior } from '@/lib/reduced-motion';
|
||||
@@ -816,6 +817,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
? `${activeTabLabel} - ${tSidebar('settings')}`
|
||||
: tSidebar('settings'),
|
||||
);
|
||||
useDesktopWindowName(tSidebar('settings'), { withAccount: true });
|
||||
|
||||
const handleTabSelect = (tabId: SettingsTabId) => {
|
||||
setActiveTab(tabId);
|
||||
|
||||
@@ -40,7 +40,15 @@ describe('toast layout', () => {
|
||||
render(<Toasts />);
|
||||
const container = toastContainer();
|
||||
expect(container.className).toContain('max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]');
|
||||
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,0px)+0.75rem))]');
|
||||
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20');
|
||||
});
|
||||
|
||||
// compose-p2-17: "Draft saved" covered the quick reply box.
|
||||
it('sits over the quick reply box, by its height', () => {
|
||||
render(<Toasts />);
|
||||
const container = toastContainer();
|
||||
expect(container).toHaveClass('[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]');
|
||||
expect(container).toHaveClass('max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]');
|
||||
});
|
||||
|
||||
it('sits above a registered bottom bar on the desktop too, such as a form footer', () => {
|
||||
|
||||
@@ -27,6 +27,19 @@ describe('WelcomeBanner tips', () => {
|
||||
expect(screen.getByText('tip_shortcuts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('in the desktop app, names the keys it answers: Ctrl+N and Ctrl+K, not c', () => {
|
||||
device.isMobile = false;
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
|
||||
try {
|
||||
render(<WelcomeBanner />);
|
||||
expect(screen.getByText('tip_compose_keys')).toBeInTheDocument();
|
||||
expect(screen.getByText('tip_palette_keys')).toBeInTheDocument();
|
||||
expect(screen.queryByText('tip_compose')).toBeNull();
|
||||
} finally {
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
}
|
||||
});
|
||||
|
||||
it('gives touch tips on a phone', () => {
|
||||
device.isMobile = true;
|
||||
render(<WelcomeBanner />);
|
||||
|
||||
@@ -474,11 +474,14 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
|
||||
// Newest at the bottom, at the end corner (bottom left in RTL);
|
||||
// lifted over a registered bottom bar (a form's Save/Cancel footer)
|
||||
// and over an open composer's footer.
|
||||
"fixed bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))] end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,0px)+0.75rem))]",
|
||||
"fixed bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))] end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20",
|
||||
// And over the reader's quick reply box (compose-p2-17).
|
||||
"[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]",
|
||||
// Phone: full width with 12px margins, above the bottom controls
|
||||
// (the tab bar, or whichever bar registered with useBottomControls).
|
||||
"max-sm:inset-x-3 max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]",
|
||||
"max-sm:[html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,3.75rem)+0.75rem))]",
|
||||
"max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]",
|
||||
// Out of the way of the focused element: the top end corner.
|
||||
dodge && "top-5 bottom-auto!",
|
||||
held && "invisible"
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useRouter } from "@/i18n/navigation";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { isDesktopApp } from "@/lib/desktop/bridge";
|
||||
import { formatShortcutCombo } from "@/lib/shortcut-keys";
|
||||
|
||||
const ONBOARDING_KEY = "onboarding_completed";
|
||||
|
||||
@@ -48,6 +50,7 @@ export function welcomeTipsDue(onboardingCompleted: boolean, showOnNewDevices: b
|
||||
*/
|
||||
export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane" }) {
|
||||
const t = useTranslations("welcome");
|
||||
const tDesktop = useTranslations("uxp2_desktop");
|
||||
const router = useRouter();
|
||||
const { startTour } = useTour();
|
||||
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
|
||||
@@ -111,7 +114,9 @@ export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane"
|
||||
{t("title")}
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li>{isMobile ? t("tip_compose_touch") : t("tip_compose")}</li>
|
||||
{/* The desktop app answers the keys Windows mail apps use, not single letters (desktop-p2-12). */}
|
||||
<li>{isMobile ? t("tip_compose_touch") : isDesktopApp() ? tDesktop("tip_compose_keys", { keys: formatShortcutCombo("Mod + N") }) : t("tip_compose")}</li>
|
||||
{!isMobile && isDesktopApp() && <li>{tDesktop("tip_palette_keys", { keys: formatShortcutCombo("Mod + K") })}</li>}
|
||||
{!isMobile && <li>{t("tip_shortcuts")}</li>}
|
||||
<li>{isMobile ? t("tip_sidebar_touch") : t("tip_sidebar")}</li>
|
||||
<li>{t("tip_settings")}</li>
|
||||
|
||||
@@ -48,13 +48,9 @@ export function useBottomControls(active = true): (el: HTMLElement | null) => vo
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
|
||||
observer?.observe(el);
|
||||
window.addEventListener('resize', schedule);
|
||||
// A bar inside a scrolling pane (the quick reply under a message) moves
|
||||
// when the pane scrolls.
|
||||
window.addEventListener('scroll', schedule, { capture: true, passive: true });
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener('resize', schedule);
|
||||
window.removeEventListener('scroll', schedule, { capture: true });
|
||||
bars.delete(el);
|
||||
update();
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('the shortcut help in the desktop app', () => {
|
||||
expect(shortcutKeyFor(row('shortcuts.actions.reply'), 'windows')).toBe('Mod + R / r');
|
||||
expect(shortcutKeyFor(row('shortcuts.actions.reply_all'), 'linux')).toBe('Mod + Shift + R / Shift + R');
|
||||
expect(shortcutKeyFor(row('shortcuts.actions.forward'), 'mac')).toBe('Mod + Shift + F / f');
|
||||
expect(shortcutKeyFor(row('shortcuts.actions.forward'), 'windows')).toBe('Ctrl + F / Ctrl + Shift + F / f');
|
||||
expect(shortcutKeyFor(row('shortcuts.global.search'), 'windows')).toBe('Ctrl + E / /');
|
||||
expect(shortcutKeyFor(row('shortcuts.global.refresh'), 'windows')).toBe('F9 / F5 / Shift + G');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { answersMessage, findReplyDraft, threadDrafts } from '@/lib/reply-drafts';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
const message = { id: 'm1', messageId: ['abc@example.org'] as unknown as string };
|
||||
const draft = (id: string, extra: Partial<Email> = {}) => ({
|
||||
id, threadId: 't1', keywords: { $draft: true }, receivedAt: '2026-11-02T10:00:00Z', ...extra,
|
||||
}) as Email;
|
||||
|
||||
describe('answersMessage', () => {
|
||||
it('matches a draft whose In-Reply-To names the message, brackets or not', () => {
|
||||
expect(answersMessage(draft('d1', { inReplyTo: ['<abc@example.org>'] }), message)).toBe(true);
|
||||
expect(answersMessage(draft('d1', { inReplyTo: ['abc@example.org'] }), { messageId: 'abc@example.org' })).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves out a reply to another message, and a sent message', () => {
|
||||
expect(answersMessage(draft('d1', { inReplyTo: ['other@example.org'] }), message)).toBe(false);
|
||||
expect(answersMessage({ inReplyTo: ['abc@example.org'], keywords: {} }, message)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findReplyDraft', () => {
|
||||
it('reads list rows without In-Reply-To in full and picks the newest that answers', async () => {
|
||||
const full: Record<string, Email> = {
|
||||
d1: draft('d1', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-01T10:00:00Z' }),
|
||||
d2: draft('d2', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-03T10:00:00Z' }),
|
||||
d3: draft('d3', { inReplyTo: ['other@example.org'], receivedAt: '2026-11-04T10:00:00Z' }),
|
||||
};
|
||||
const getEmail = vi.fn(async (id: string) => full[id] ?? null);
|
||||
const rows = Object.values(full).map(({ inReplyTo: _drop, ...row }) => row as Email);
|
||||
const found = await findReplyDraft(rows, message, getEmail);
|
||||
expect(found?.id).toBe('d2');
|
||||
expect(getEmail).toHaveBeenCalledWith('d3');
|
||||
expect(getEmail).not.toHaveBeenCalledWith('d1');
|
||||
});
|
||||
|
||||
it('finds nothing for a message without a Message-ID', async () => {
|
||||
const getEmail = vi.fn();
|
||||
expect(await findReplyDraft([draft('d1')], { id: 'm1' }, getEmail)).toBeNull();
|
||||
expect(getEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('threadDrafts', () => {
|
||||
it('returns the drafts of a conversation, and asks nothing more of a single message', async () => {
|
||||
const client = {
|
||||
getThread: vi.fn(async (id: string) => (id === 't1' ? { emailIds: ['m1', 'd1'] } : { emailIds: ['x'] })),
|
||||
getSomeEmails: vi.fn(async () => [{ id: 'm1', keywords: {} }, draft('d1')] as Email[]),
|
||||
};
|
||||
expect((await threadDrafts(client, 't1', 'acc')).map((e) => e.id)).toEqual(['d1']);
|
||||
expect(await threadDrafts(client, 't2', 'acc')).toEqual([]);
|
||||
expect(client.getSomeEmails).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// "Not sent - the server rejected every recipient." read as a hyphen where a colon belongs
|
||||
// (usability pass 2, desktop-p2-18).
|
||||
const LOCALES = join(process.cwd(), 'locales');
|
||||
|
||||
describe('the send error for refused recipients', () => {
|
||||
it.each(readdirSync(LOCALES))('%s: no hyphen standing in for a dash or colon', (locale) => {
|
||||
const messages = JSON.parse(readFileSync(join(LOCALES, locale, 'common.json'), 'utf8'));
|
||||
expect(messages.email_composer.send_recipients_rejected).not.toContain(' - ');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,25 @@ describe('shortcut notation', () => {
|
||||
expect(formatShortcutCombo('Ctrl+Shift+Y', false)).toBe('Ctrl+Shift+Y');
|
||||
});
|
||||
|
||||
it('names keys the way Windows does in the app\'s language, and a Mac keeps its symbols', () => {
|
||||
expect(formatShortcutCombo('Mod + Shift + R', false, 'de')).toBe('Strg + Umschalt + R');
|
||||
expect(formatShortcutCombo('Shift + Mod + Enter', false, 'de-AT')).toBe('Umschalt + Strg + Eingabe');
|
||||
expect(shortcutAlternatives('# / Del', false, 'de')).toEqual(['#', 'Entf']);
|
||||
expect(formatShortcutCombo('Alt + Shift + N', false, 'fr')).toBe('Alt + Maj + N');
|
||||
expect(formatShortcutCombo('Mod + Shift + R', true, 'de')).toBe('⌘ + Shift + R');
|
||||
expect(formatShortcutCombo('Mod + Shift + R', false, 'nl')).toBe('Ctrl + Shift + R');
|
||||
expect(formatShortcutCombo('Mod + Shift + R', false, 'en')).toBe('Ctrl + Shift + R');
|
||||
});
|
||||
|
||||
it('follows the app\'s language when none is given', () => {
|
||||
document.documentElement.lang = 'de';
|
||||
try {
|
||||
expect(formatShortcutCombo('Mod + K', false)).toBe('Strg + K');
|
||||
} finally {
|
||||
document.documentElement.lang = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('splits alternatives', () => {
|
||||
expect(shortcutAlternatives('j / ↓', false)).toEqual(['j', '↓']);
|
||||
expect(shortcutAlternatives('Mod + A', true)).toEqual(['⌘ + A']);
|
||||
|
||||
@@ -278,6 +278,14 @@ describe('the menu bar in the desktop app', () => {
|
||||
expect(menuKeyText('?', 'win32')).toBe('?');
|
||||
expect(menuKeyText('CommandOrControl+Shift+Z', 'win32')).toBe('Ctrl+Shift+Z');
|
||||
});
|
||||
|
||||
it('and in the app\'s language, as German Windows writes them', () => {
|
||||
expect(menuKeyText('CmdOrCtrl+Shift+R', 'win32', 'de')).toBe('Strg+Umschalt+R');
|
||||
expect(menuKeyText('Delete', 'win32', 'de')).toBe('Entf');
|
||||
expect(menuKeyText('CmdOrCtrl+Enter', 'linux', 'de')).toBe('Strg+Eingabe');
|
||||
expect(menuKeyText('Alt+Left', 'win32', 'de')).toBe('Alt+←');
|
||||
expect(menuKeyText('Delete', 'win32', 'fr')).toBe('Suppr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the window controls over the app', () => {
|
||||
@@ -370,6 +378,32 @@ describe('the window controls over the app', () => {
|
||||
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
|
||||
expect(rightInset()).toBe('');
|
||||
});
|
||||
|
||||
describe('when the window changes size in one step', () => {
|
||||
const startWidth = window.innerWidth;
|
||||
const setWidth = (width: number) => Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
|
||||
afterEach(() => setWidth(startWidth));
|
||||
|
||||
// As Chromium does it: geometrychange with the new rect while the page still has
|
||||
// its old width, then the page takes the new width and fires resize.
|
||||
const resizeTo = (fake: EventTarget, width: number) => act(() => {
|
||||
(fake as unknown as { getTitlebarAreaRect: () => object }).getTitlebarAreaRect = () => ({ x: 0, y: 0, width: width - 137, height: 56 });
|
||||
fake.dispatchEvent(new Event('geometrychange'));
|
||||
setWidth(width);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
|
||||
it('keeps the room for the controls after a maximise, a restore, a snap and a shrink', () => {
|
||||
setWidth(1366);
|
||||
const fake = overlayAt(0, 1366 - 137);
|
||||
render(<WindowControlsHost />);
|
||||
expect(rightInset()).toBe('137px');
|
||||
for (const width of [2048, 1366, 1024, 640, 1920]) {
|
||||
resizeTo(fake, width);
|
||||
expect(rightInset(), `at ${width} px`).toBe('137px');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('which rows hold the controls', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { attachmentsForDisplay } from '../attachment-order';
|
||||
|
||||
describe('the order of the composer attachment chips', () => {
|
||||
it('puts failed, then uploading files first, and keeps the rest in the order added', () => {
|
||||
it('puts failed files first, and keeps the rest, uploading or not, in the order added', () => {
|
||||
const files = [
|
||||
{ name: 'a.pdf' },
|
||||
{ name: 'b.pdf', uploading: true },
|
||||
@@ -11,8 +11,8 @@ describe('the order of the composer attachment chips', () => {
|
||||
{ name: 'broken.png', error: 'failed' },
|
||||
];
|
||||
const shown = attachmentsForDisplay(files);
|
||||
expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'b.pdf', 'big.zip', 'a.pdf', 'c.pdf']);
|
||||
expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'a.pdf', 'b.pdf', 'c.pdf', 'big.zip']);
|
||||
// Each keeps its place in the composer's list, for Remove.
|
||||
expect(shown.map(({ index }) => index)).toEqual([4, 1, 3, 0, 2]);
|
||||
expect(shown.map(({ index }) => index)).toEqual([4, 0, 1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* The composer's attachment chips in the order they're shown: failed first,
|
||||
* then still uploading, then the rest, each group in the order added. The
|
||||
* list scrolls past about two rows, so the files that need an eye on them
|
||||
* are the ones always in view. `index` is the place in the composer's list.
|
||||
* then the rest in the order added. A file still uploading keeps its place:
|
||||
* moved to the front, it jumped to the end once done and went out of sight
|
||||
* past the rows on screen (compose-p2-11). `index` is the place in the
|
||||
* composer's list.
|
||||
*/
|
||||
export function attachmentsForDisplay<T extends { error?: unknown; uploading?: boolean }>(
|
||||
attachments: readonly T[],
|
||||
): Array<{ att: T; index: number }> {
|
||||
const rank = (att: T) => (att.error ? 0 : att.uploading ? 1 : 2);
|
||||
const rank = (att: T) => (att.error ? 0 : 1);
|
||||
return attachments
|
||||
.map((att, index) => ({ att, index }))
|
||||
.sort((a, b) => rank(a.att) - rank(b.att) || a.index - b.index);
|
||||
|
||||
@@ -132,6 +132,12 @@ describe('launches', () => {
|
||||
expect(takeHandoff('compose')?.files.map((file) => file.name)).toEqual(['a.pdf', 'b.png']);
|
||||
});
|
||||
|
||||
it('files sent from another program are marked so, for a compose window of their own, as a mail link', async () => {
|
||||
const { takeHandoff } = await import('@/lib/launch-handoff');
|
||||
await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'a.pdf', data: text('a') }] });
|
||||
expect(takeHandoff('compose')).toMatchObject({ fromOs: true });
|
||||
});
|
||||
|
||||
it('a file opened with the app asks: new message or Files, a new message first', async () => {
|
||||
const { takeHandoff } = await import('@/lib/launch-handoff');
|
||||
const { listenForFileChoices } = await import('@/lib/file-drop/choice');
|
||||
|
||||
@@ -290,6 +290,15 @@ describe('the summary', () => {
|
||||
expect(nextEventOf([event('past', '2026-11-03T09:00:00Z')], now)).toBeNull();
|
||||
});
|
||||
|
||||
it('the event\'s time follows the app\'s time format, not the language\'s', async () => {
|
||||
const { formatEventTime } = await import('../os-summary');
|
||||
const at = new Date(2026, 10, 3, 11, 0).getTime();
|
||||
const morning = new Date(2026, 10, 3, 8, 0).getTime();
|
||||
expect(formatEventTime(at, morning, 'en-US', '24h')).toBe('11:00');
|
||||
expect(formatEventTime(at, morning, 'en-US', '12h')).toMatch(/^11:00\sAM$/);
|
||||
expect(formatEventTime(new Date(2026, 10, 3, 9, 5).getTime(), morning, 'en-US', '24h')).toBe('09:05');
|
||||
});
|
||||
|
||||
describe('with a login', () => {
|
||||
const row = { id: 'e1', threadId: 't1', subject: 'Lunch?', from: [{ name: 'Alice', email: 'a@example.org' }] };
|
||||
const getEmails = vi.fn(async () => ({ emails: [row], hasMore: false, total: 1 }));
|
||||
@@ -422,6 +431,17 @@ describe('the settings section', () => {
|
||||
await waitFor(() => expect(os.openDefaultAppSettings).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('switching "Show the app" on takes a key the composer doesn\'t use', async () => {
|
||||
const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
|
||||
const os = bridgeFor(status());
|
||||
setBridge(os as never);
|
||||
render(<DesktopOsSettings />);
|
||||
expect(await screen.findByText('show_shortcut_label')).toBeInTheDocument();
|
||||
// Close to tray, start with the computer, then this one.
|
||||
fireEvent.click(screen.getAllByRole('switch')[2]);
|
||||
await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ showShortcut: 'CommandOrControl+Alt+F11' }));
|
||||
});
|
||||
|
||||
it('says when another app holds the shortcut', async () => {
|
||||
const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
|
||||
setBridge(bridgeFor(status({ showShortcut: 'CommandOrControl+Shift+B' })) as never);
|
||||
|
||||
@@ -37,6 +37,11 @@ describe('the labels the desktop shell gets', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('names File > Unlock, which the menus offer while the app is locked', () => {
|
||||
expect(labelsFor('de').labels.menuUnlock).toBe('Entsperren');
|
||||
expect(labelsFor('en').labels.menuUnlock).toBe('Unlock');
|
||||
});
|
||||
|
||||
it.each(LOCALES)('%s: leaves the shell its placeholders to fill in', (locale) => {
|
||||
const { labels } = labelsFor(locale);
|
||||
expect(labels.contextAddToDictionary).toContain('{word}');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describeAccelerator, readShortcutPress, shortcutProblem, type ShortcutKeyEvent } from '../shortcut-keys';
|
||||
import { describeAccelerator, readShortcutPress, shortcutProblem, showShortcutDefault, type ShortcutKeyEvent } from '../shortcut-keys';
|
||||
|
||||
const press = (key: string, code: string, modifiers: Partial<ShortcutKeyEvent> = {}): ShortcutKeyEvent => ({
|
||||
key,
|
||||
@@ -27,6 +27,14 @@ describe('describeAccelerator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeAccelerator in the app\'s language', () => {
|
||||
it('says Strg and Umschalt in German, and keeps a Mac\'s symbols', () => {
|
||||
expect(describeAccelerator('CommandOrControl+Alt+F11', 'win32', 'de')).toBe('Strg+Alt+F11');
|
||||
expect(describeAccelerator('CommandOrControl+Shift+Enter', 'linux', 'de')).toBe('Strg+Umschalt+Eingabe');
|
||||
expect(describeAccelerator('CommandOrControl+Shift+B', 'darwin', 'de')).toBe('⇧⌘B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readShortcutPress', () => {
|
||||
it('turns a press into what the shell takes', () => {
|
||||
expect(readShortcutPress(press('n', 'KeyN', CTRL_ALT), 'win32')).toEqual({ accelerator: 'CommandOrControl+Alt+N' });
|
||||
@@ -108,4 +116,17 @@ describe('shortcutProblem', () => {
|
||||
expect(shortcutProblem('CommandOrControl+Control+N', 'win32')).toBe('incomplete');
|
||||
expect(shortcutProblem('CommandOrControl+Control+N', 'darwin')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses the app\'s own keys: a global one would take them from the composer and the menus', () => {
|
||||
for (const keys of ['CommandOrControl+Shift+B', 'CommandOrControl+Shift+C', 'CommandOrControl+Shift+Enter', 'CommandOrControl+Shift+M', 'CommandOrControl+Shift+N', 'CommandOrControl+Shift+S', 'CommandOrControl+Shift+R', 'CommandOrControl+Shift+F']) {
|
||||
for (const platform of ['win32', 'darwin', 'linux']) expect(shortcutProblem(keys, platform), `${keys} on ${platform}`).toBe('reserved');
|
||||
}
|
||||
expect(readShortcutPress(press('B', 'KeyB', { ctrlKey: true, shiftKey: true }), 'win32')).toEqual({ refused: 'reserved' });
|
||||
});
|
||||
|
||||
it('"Show the app" by default is none of them, and on Windows types nothing with AltGr', () => {
|
||||
for (const platform of ['win32', 'darwin', 'linux']) expect(shortcutProblem(showShortcutDefault(platform), platform)).toBeNull();
|
||||
expect(showShortcutDefault('win32')).toBe('CommandOrControl+Alt+F11');
|
||||
expect(showShortcutDefault('linux')).toBe('CommandOrControl+Alt+B');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { useDesktopWindowName, windowMenuName } from '../window-name';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
// The desktop app's Window menu: each window by what it shows (usability pass 2, desktop-p2-5).
|
||||
|
||||
function Named({ view, withAccount }: { view: string | null; withAccount?: boolean }) {
|
||||
useDesktopWindowName(view, { withAccount });
|
||||
return null;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
});
|
||||
|
||||
describe('the Window menu name', () => {
|
||||
it('is the view and the account for the main window, the view alone otherwise', () => {
|
||||
expect(windowMenuName('Inbox', 'a@example.org')).toBe('Inbox - a@example.org');
|
||||
expect(windowMenuName('Quarterly budget', null)).toBe('Quarterly budget');
|
||||
expect(windowMenuName(' ', null)).toBeNull();
|
||||
});
|
||||
|
||||
it('goes to the shell, with the account in view, and again when it changes', () => {
|
||||
const named: string[] = [];
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', windows: { setMenuName: (name: string) => named.push(name) } };
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc-1', email: 'a@example.org' }], activeAccountId: 'acc-1' } as never);
|
||||
const { rerender } = render(<Named view="Inbox" withAccount />);
|
||||
expect(named).toEqual(['Inbox - a@example.org']);
|
||||
rerender(<Named view="Sent" withAccount />);
|
||||
expect(named).toEqual(['Inbox - a@example.org', 'Sent - a@example.org']);
|
||||
rerender(<Named view="A subject" />);
|
||||
expect(named.at(-1)).toBe('A subject');
|
||||
});
|
||||
|
||||
it('is nothing to an older shell, or in a browser', () => {
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', windows: {} };
|
||||
expect(() => render(<Named view="Inbox" />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,7 @@ export function shellLabels(t: (key: string, values?: Record<string, string>) =>
|
||||
trayNewMessage: t('email_composer.new_message'),
|
||||
trayLockNow: t('appLock.lock_now'),
|
||||
menuLockNow: t('appLock.lock_now'),
|
||||
menuUnlock: t('appLock.unlock'),
|
||||
trayUnread: t('email_list.sections.unread'),
|
||||
trayPause: t('desktopMenuBar.tray_pause'),
|
||||
trayPauseHour: t('desktopMenuBar.tray_pause_hour'),
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getDesktopBridge } from './bridge';
|
||||
import { describeAccelerator } from './shortcut-keys';
|
||||
import { localKeyName } from '@/lib/shortcut-keys';
|
||||
|
||||
import { MENU_BAR_STORAGE_KEY } from './boot';
|
||||
|
||||
@@ -66,14 +67,17 @@ const KEY_NAMES: Record<string, string> = {
|
||||
space: 'Space',
|
||||
};
|
||||
|
||||
/** An Electron accelerator the way a Windows or Linux menu shows it: `Ctrl+Shift+N`, `Alt+←`, `?`. */
|
||||
export function menuKeyText(accelerator: string, platform: string | undefined): string {
|
||||
/**
|
||||
* An Electron accelerator the way a Windows or Linux menu shows it: `Ctrl+Shift+N`, `Alt+←`, `?`,
|
||||
* in the app's language (`Strg+Umschalt+N`, `Entf` in German). `language` defaults to the app's.
|
||||
*/
|
||||
export function menuKeyText(accelerator: string, platform: string | undefined, language?: string): string {
|
||||
// A key that is itself "+", as in Ctrl++, ends in an empty part.
|
||||
const parts = accelerator.split('+');
|
||||
const last = parts[parts.length - 1] === '' ? '+' : parts[parts.length - 1];
|
||||
const key = KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last);
|
||||
const key = localKeyName(KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last), language);
|
||||
const modifiers = parts.slice(0, parts[parts.length - 1] === '' ? -2 : -1);
|
||||
if (modifiers.length === 0) return key;
|
||||
const named = describeAccelerator(`${modifiers.join('+')}+X`, platform);
|
||||
const named = describeAccelerator(`${modifiers.join('+')}+X`, platform, language);
|
||||
return `${named.slice(0, -1)}${key}`;
|
||||
}
|
||||
|
||||
@@ -133,10 +133,13 @@ function handleFiles(command: Extract<DesktopOsCommand, { type: 'files' }>, env:
|
||||
const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name));
|
||||
const failed = [...command.failed];
|
||||
if (command.reason === 'attach' && command.ask) {
|
||||
// Opened with the app ("Open with"): the same choice as dropping it on the window.
|
||||
// An older shell's "Open with": the same choice as dropping it on the window.
|
||||
requestFileChoice({ files: made, preferred: 'new-message' });
|
||||
} else if (command.reason === 'attach') {
|
||||
if (made.length > 0) handOffDraft({ files: made });
|
||||
// Send to and Open with: a compose window with the files, as a mail link gets, and the
|
||||
// main window stays where it is. Signed out, the sign-in has to show.
|
||||
if (!useAuthStore.getState().isAuthenticated) showWindow();
|
||||
if (made.length > 0) handOffDraft({ files: made, fromOs: true });
|
||||
} else {
|
||||
for (const file of made) {
|
||||
const kind = kindForFile(file);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useSettingsStore, type TimeFormat } from '@/stores/settings-store';
|
||||
|
||||
const ROWS = 5;
|
||||
const REFRESH_DELAY_MS = 2000;
|
||||
@@ -36,12 +37,14 @@ export function nextEventOf(events: readonly CalendarEvent[], now: number): Cale
|
||||
return ahead[0] ?? null;
|
||||
}
|
||||
|
||||
export function formatEventTime(start: number, now: number, locale: string | undefined): string {
|
||||
/** In the app's own time format (Settings > General), not the language's: 11:00, never 11:00 AM where the app shows 24 hours. */
|
||||
export function formatEventTime(start: number, now: number, locale: string | undefined, timeFormat: TimeFormat = '24h'): string {
|
||||
const date = new Date(start);
|
||||
const sameDay = date.toDateString() === new Date(now).toDateString();
|
||||
const hour12 = timeFormat === '12h';
|
||||
const options: Intl.DateTimeFormatOptions = sameDay
|
||||
? { hour: 'numeric', minute: '2-digit' }
|
||||
: { weekday: 'short', hour: 'numeric', minute: '2-digit' };
|
||||
? { hour: hour12 ? 'numeric' : '2-digit', minute: '2-digit', hour12 }
|
||||
: { weekday: 'short', hour: hour12 ? 'numeric' : '2-digit', minute: '2-digit', hour12 };
|
||||
return new Intl.DateTimeFormat(locale, options).format(date);
|
||||
}
|
||||
|
||||
@@ -79,7 +82,7 @@ export async function readSummary(locale: string | undefined, now = Date.now()):
|
||||
before: new Date(now + EVENT_WINDOW_MS).toISOString(),
|
||||
}).catch(() => [] as CalendarEvent[]);
|
||||
const next = nextEventOf(events, now);
|
||||
if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale) };
|
||||
if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale, useSettingsStore.getState().timeFormat) };
|
||||
}
|
||||
// The tray lists one account's inbox: with more than one, it says whose.
|
||||
const several = useAccountStore.getState().accounts.length > 1;
|
||||
|
||||
@@ -4,12 +4,23 @@
|
||||
* so the settings page can say why before the shell silently refuses.
|
||||
*/
|
||||
|
||||
import { localKeyName } from '@/lib/shortcut-keys';
|
||||
|
||||
/**
|
||||
* What turning quick compose on gives when the shell doesn't say. On Windows the shell
|
||||
* answers Ctrl+Alt+Enter: Ctrl+Alt+N is AltGr+N, which types a character on many layouts.
|
||||
*/
|
||||
export const QUICK_COMPOSE_DEFAULT = 'CommandOrControl+Alt+N';
|
||||
|
||||
/**
|
||||
* What "Show the app with a keyboard shortcut" takes when switched on: Ctrl+Alt+B (⌥⌘B), and
|
||||
* on Windows Ctrl+Alt+F11, since Ctrl+Alt is AltGr there and B types on many layouts. The
|
||||
* shell's showShortcutBuiltIn, which also moves anyone still on the old Ctrl+Shift+B to it.
|
||||
*/
|
||||
export function showShortcutDefault(platform: string | undefined): string {
|
||||
return platform === 'win32' ? 'CommandOrControl+Alt+F11' : 'CommandOrControl+Alt+B';
|
||||
}
|
||||
|
||||
const MAC_SYMBOLS: Record<string, string> = {
|
||||
control: '⌃',
|
||||
ctrl: '⌃',
|
||||
@@ -41,7 +52,8 @@ const OTHER_NAMES: Record<string, string> = {
|
||||
/** The order a Mac shows modifiers in. */
|
||||
const MAC_ORDER = ['⌃', '⌥', '⇧', '⌘'];
|
||||
|
||||
export function describeAccelerator(accelerator: string, platform: string | undefined): string {
|
||||
/** An Electron accelerator as people read it: ⇧⌘B on a Mac, Ctrl+Alt+F11 elsewhere, in the app's language (Strg in German). */
|
||||
export function describeAccelerator(accelerator: string, platform: string | undefined, language?: string): string {
|
||||
const parts = accelerator.split('+');
|
||||
const key = parts.pop() ?? '';
|
||||
const named = parts.map((part) => part.toLowerCase());
|
||||
@@ -50,7 +62,7 @@ export function describeAccelerator(accelerator: string, platform: string | unde
|
||||
return [...symbols].sort((a, b) => MAC_ORDER.indexOf(a) - MAC_ORDER.indexOf(b)).join('') + key;
|
||||
}
|
||||
const superKey = platform === 'win32' ? 'Win' : 'Super';
|
||||
return [...named.map((part) => OTHER_NAMES[part] ?? superKey), key].join('+');
|
||||
return [...named.map((part) => OTHER_NAMES[part] ?? superKey), key].map((part) => localKeyName(part, language)).join('+');
|
||||
}
|
||||
|
||||
export interface ShortcutKeyEvent {
|
||||
@@ -67,6 +79,12 @@ export interface ShortcutKeyEvent {
|
||||
* shell's. `cmd` is Cmd on a Mac and Ctrl elsewhere.
|
||||
*/
|
||||
const RESERVED = new Set(['cmd+shift+A', 'cmd+shift+C', 'cmd+shift+V', 'cmd+shift+X', 'cmd+shift+Z']);
|
||||
/**
|
||||
* The app's own keys with two modifiers, which a global key would take from Bulwark itself:
|
||||
* the composer's Cc, Bcc and schedule send, and the menus' new message, new window, Save as
|
||||
* PDF, reply all and forward. Same list as APP_KEYS in the shell's os-rules.ts.
|
||||
*/
|
||||
const APP_KEYS = new Set(['cmd+shift+B', 'cmd+shift+C', 'cmd+shift+ENTER', 'cmd+shift+M', 'cmd+shift+N', 'cmd+shift+S', 'cmd+shift+R', 'cmd+shift+F']);
|
||||
const RESERVED_ON_MAC = new Set([
|
||||
'cmd+shift+3', 'cmd+shift+4', 'cmd+shift+5', 'cmd+shift+Q',
|
||||
'cmd+ctrl+Q', 'cmd+ctrl+F',
|
||||
@@ -119,7 +137,7 @@ export function shortcutProblem(accelerator: string, platform: string | undefine
|
||||
if (modifiers.has('super')) return 'reserved';
|
||||
if (modifiers.size < 2 || !(modifiers.has('cmd') || modifiers.has('ctrl'))) return 'incomplete';
|
||||
const named = `${[...modifiers].sort().join('+')}+${key}`;
|
||||
return RESERVED.has(named) || (mac && RESERVED_ON_MAC.has(named)) ? 'reserved' : null;
|
||||
return RESERVED.has(named) || APP_KEYS.has(`${[...modifiers].sort().join('+')}+${key.toUpperCase()}`) || (mac && RESERVED_ON_MAC.has(named)) ? 'reserved' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface DesktopWindowInfo {
|
||||
id: number;
|
||||
type: DesktopWindowType;
|
||||
title: string;
|
||||
/** What the page calls the window in the Window menu (`setMenuName`), if it said. */
|
||||
menuName?: string;
|
||||
focused: boolean;
|
||||
/** False for a main window hidden while pop-outs are open. */
|
||||
visible?: boolean;
|
||||
@@ -46,6 +48,8 @@ export interface DesktopWindowsBridge {
|
||||
/** What this window's open drafts come back as at the next start: pop-out composer addresses. */
|
||||
setRestore?: (urls: string[]) => void;
|
||||
setRestoreEnabled?: (enabled: boolean) => void;
|
||||
/** This window's name in the Window menu: lib/desktop/window-name.ts. */
|
||||
setMenuName?: (name: string) => void;
|
||||
/** Signed in, with a session to lend: windows from last time can open. */
|
||||
sessionReady?: () => void;
|
||||
/** Nobody is signed in any more: a pop-out would find no session. */
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { getDesktopBridge } from './bridge';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
/**
|
||||
* What the desktop app's Window menu calls this window (the shell numbers two alike and
|
||||
* cuts long ones). The main window goes by its view and account ("Inbox - a@example.org"),
|
||||
* not by the message open in it, so it never reads like a message window; a message window
|
||||
* by its subject; a composer by its subject or "New message". Nothing in a browser.
|
||||
*/
|
||||
export function windowMenuName(view: string | null | undefined, account: string | null | undefined): string | null {
|
||||
const parts = [view?.trim(), account?.trim()].filter(Boolean);
|
||||
return parts.length ? parts.join(' - ') : null;
|
||||
}
|
||||
|
||||
/** Tells the shell this window's Window menu name. `withAccount` adds the account in view, for the main window. */
|
||||
export function useDesktopWindowName(view: string | null | undefined, { withAccount = false }: { withAccount?: boolean } = {}): void {
|
||||
const account = useAccountStore((s) => (withAccount ? s.accounts.find((a) => a.id === s.activeAccountId)?.email ?? null : null));
|
||||
const name = windowMenuName(view, account);
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
getDesktopBridge()?.windows?.setMenuName?.(name);
|
||||
}, [name]);
|
||||
}
|
||||
@@ -16,7 +16,8 @@ export const MAIL_SHORTCUTS = {
|
||||
actions: [
|
||||
{ key: "r", description: "shortcuts.actions.reply", desktopKey: "Mod + R" },
|
||||
{ key: "Shift + R", description: "shortcuts.actions.reply_all", desktopKey: "Mod + Shift + R" },
|
||||
{ key: "f", description: "shortcuts.actions.forward", desktopKey: "Mod + Shift + F" },
|
||||
// Ctrl+F forwards with a message selected (Outlook) and searches elsewhere; Ctrl+Shift+F still works.
|
||||
{ key: "f", description: "shortcuts.actions.forward", desktopKey: "Mod + Shift + F", desktopPcKey: "Ctrl + F / Ctrl + Shift + F" },
|
||||
{ key: "s", description: "shortcuts.actions.star" },
|
||||
{ key: "e", description: "shortcuts.actions.archive", feature: "archiveActionEnabled" },
|
||||
{ key: "Del", description: "shortcuts.actions.delete" },
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface Handoff {
|
||||
/** New draft only. */
|
||||
subject?: string;
|
||||
body?: string;
|
||||
/** From another program through the desktop app (Send to, Open with): a compose window of its own. */
|
||||
fromOs?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -307,13 +309,14 @@ export function handOffFiles(files: File[], kind: HandoffKind): void {
|
||||
}
|
||||
|
||||
/** Opens a new draft with this text and these files. Trusted: what came from outside was asked about first. */
|
||||
export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void {
|
||||
export function handOffDraft(draft: { subject?: string; body?: string; files?: File[]; fromOs?: boolean }): void {
|
||||
const subject = cleanMailtoSubject(draft.subject ?? '');
|
||||
const body = cleanMailtoBody(draft.body ?? '');
|
||||
enqueue({
|
||||
id: newId(),
|
||||
kind: 'compose',
|
||||
files: draft.files ?? [],
|
||||
...(draft.fromOs ? { fromOs: true } : {}),
|
||||
...(subject ? { subject } : {}),
|
||||
...(body ? { body } : {}),
|
||||
createdAt: Date.now(),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { toast, useToastStore } from '@/stores/toast-store';
|
||||
import { runUndoSendCountdown } from '../send-progress-toast';
|
||||
|
||||
// compose-p2-12: "Sending in 5 seconds" stayed the same for 5 s and then
|
||||
// went, with no word that the message went.
|
||||
|
||||
const T0 = Date.parse('2026-11-04T10:00:00Z');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(T0);
|
||||
useToastStore.setState({ toasts: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function start(view?: () => void, maxSeconds = 5) {
|
||||
const id = toast.success('Sending in 5 seconds', {
|
||||
duration: 5000,
|
||||
pauseOnHover: false,
|
||||
action: { label: 'Undo', onClick: () => {} },
|
||||
secondaryAction: { label: 'Send now', onClick: () => {} },
|
||||
});
|
||||
const stop = runUndoSendCountdown(id, {
|
||||
sendAt: T0 + 5600,
|
||||
undoEndsAt: T0 + 4600,
|
||||
maxSeconds,
|
||||
sendingIn: (s) => `Sending in ${s} seconds`,
|
||||
sent: 'Sent',
|
||||
view: view ? { label: 'View', onClick: view } : undefined,
|
||||
});
|
||||
const current = () => useToastStore.getState().toasts.find((t) => t.id === id);
|
||||
return { id, stop, current };
|
||||
}
|
||||
|
||||
describe('the undo send toast', () => {
|
||||
it('counts down, never above the delay the user picked', () => {
|
||||
const { current } = start();
|
||||
// 5.6 s left would round up to 6; the setting says 5.
|
||||
expect(current()?.title).toBe('Sending in 5 seconds');
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(current()?.title).toBe('Sending in 5 seconds');
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(current()?.title).toBe('Sending in 4 seconds');
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(current()?.title).toBe('Sending in 3 seconds');
|
||||
});
|
||||
|
||||
it('turns into "Sent" with View as Undo ends, with a fresh time to read it', () => {
|
||||
const view = vi.fn();
|
||||
const { current } = start(view);
|
||||
vi.advanceTimersByTime(4600);
|
||||
const shown = current()!;
|
||||
expect(shown.title).toBe('Sent');
|
||||
expect(shown.action?.label).toBe('View');
|
||||
expect(shown.secondaryAction).toBeUndefined();
|
||||
expect(shown.pauseOnHover).toBeUndefined();
|
||||
expect(shown.duration).not.toBe(5000);
|
||||
shown.action!.onClick();
|
||||
expect(view).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says "Sent" without View when the message can\'t be opened from here', () => {
|
||||
const { current } = start(undefined);
|
||||
vi.advanceTimersByTime(4600);
|
||||
expect(current()?.title).toBe('Sent');
|
||||
expect(current()?.action).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a toast that is gone (Undo, Send now) alone', () => {
|
||||
const { id, current } = start();
|
||||
toast.dismiss(id);
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(current()).toBeUndefined();
|
||||
expect(useToastStore.getState().toasts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* stay on screen, greyed and without a word, until the server answered.
|
||||
*/
|
||||
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { toast, useToastStore } from '@/stores/toast-store';
|
||||
import { announce } from '@/lib/announce';
|
||||
import { effectiveToastDuration } from '@/lib/toast-duration';
|
||||
|
||||
@@ -46,3 +46,78 @@ export function startSendProgress(labels: SendProgressLabels): SendProgress {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** How long past the end of Undo the undo toast is given, so the change to "Sent" comes first. */
|
||||
export const UNDO_TOAST_SLACK_MS = 1000;
|
||||
|
||||
export interface UndoCountdownOptions {
|
||||
/** When the server lets the message go (ms since epoch). */
|
||||
sendAt: number;
|
||||
/** When Undo stops being offered (ms since epoch); the toast turns into "Sent" then. */
|
||||
undoEndsAt: number;
|
||||
/** The delay the user picked, in seconds: the count never shows more. */
|
||||
maxSeconds?: number;
|
||||
sendingIn: (seconds: number) => string;
|
||||
sent: string;
|
||||
/** View on "Sent": opens the message. Without it there is no View. */
|
||||
view?: { label: string; onClick: () => void };
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The undo-send toast `id`, counting down to the send ("Sending in 5
|
||||
* seconds", 4, 3…), then "Sent" with View for the usual time, as Gmail does
|
||||
* (compose-p2-12). It used to show the same number until it went, with no
|
||||
* word that the message went. Stops by itself once the toast is gone (Undo,
|
||||
* Send now, closed). Returns a stop for the caller.
|
||||
*/
|
||||
/**
|
||||
* Whole seconds until a held send goes, at least 1 and never more than the
|
||||
* delay the user picked: the server's time runs a moment past it, which
|
||||
* showed "Sending in 6 seconds" for a 5 s delay.
|
||||
*/
|
||||
export function secondsUntilSend(sendAt: number, now: number, maxSeconds?: number): number {
|
||||
const left = Math.max(1, Math.ceil((sendAt - now) / 1000));
|
||||
return maxSeconds && maxSeconds > 0 ? Math.min(left, maxSeconds) : left;
|
||||
}
|
||||
|
||||
export function runUndoSendCountdown(id: string, options: UndoCountdownOptions): () => void {
|
||||
const now = options.now ?? Date.now;
|
||||
const present = () => useToastStore.getState().toasts.some((t) => t.id === id);
|
||||
const secondsLeft = () => secondsUntilSend(options.sendAt, now(), options.maxSeconds);
|
||||
let shown = -1;
|
||||
const tick = () => {
|
||||
if (!present()) return stop();
|
||||
const seconds = secondsLeft();
|
||||
if (seconds === shown) return;
|
||||
shown = seconds;
|
||||
toast.update(id, { title: options.sendingIn(seconds) });
|
||||
};
|
||||
const finish = () => {
|
||||
stop();
|
||||
const current = useToastStore.getState().toasts.find((t) => t.id === id);
|
||||
if (!current) return;
|
||||
const action = options.view;
|
||||
let duration = effectiveToastDuration({ type: 'success', action });
|
||||
// A new duration starts the toast's countdown afresh; the same one wouldn't.
|
||||
if (duration === current.duration) duration += 1;
|
||||
toast.update(id, {
|
||||
type: 'success',
|
||||
title: options.sent,
|
||||
message: undefined,
|
||||
action,
|
||||
secondaryAction: undefined,
|
||||
pauseOnHover: undefined,
|
||||
duration,
|
||||
});
|
||||
announce(options.sent);
|
||||
};
|
||||
const interval = setInterval(tick, 250);
|
||||
const timer = setTimeout(finish, Math.max(0, options.undoEndsAt - now()));
|
||||
function stop() {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
tick();
|
||||
return stop;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface PopoutSentNotice {
|
||||
localAccountId?: string;
|
||||
submissionAccountId?: string;
|
||||
} | null;
|
||||
/** A send held for undo in the pop-out itself: when the server lets it go. */
|
||||
releasesAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +129,8 @@ export interface DraftKeptNotice {
|
||||
accountId?: string;
|
||||
/** The login it was saved through. */
|
||||
localAccountId?: string;
|
||||
/** Thrown away with the bin: "Draft discarded" with Undo, deleted after. */
|
||||
discarded?: boolean;
|
||||
}
|
||||
|
||||
export interface PopoutChannel {
|
||||
|
||||
@@ -109,7 +109,9 @@ export function createComposeHandoff(): ComposeHandoff | null {
|
||||
/** Keeps the draft with this window, so a reload starts from it. */
|
||||
export function rememberComposePayload(handoffId: string, payload: ComposePopoutPayload): void {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(payload));
|
||||
// A File doesn't go into JSON: once attached, the draft holds it.
|
||||
const { initialFiles: _files, ...kept } = payload;
|
||||
sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(kept));
|
||||
} catch {
|
||||
// Full or unavailable: a reload then starts from the saved draft's first state.
|
||||
}
|
||||
|
||||
@@ -20,4 +20,9 @@ export interface ComposePopoutPayload {
|
||||
sourceEmailSource?: EmailWriteSource | null;
|
||||
/** The mailbox a new message was started from; picks the identity (see the composer's prop). */
|
||||
composeFromAccountEmail?: string;
|
||||
/**
|
||||
* Files to attach as it opens: Send to and Open with in the desktop app. They cross to the
|
||||
* window as they are (BroadcastChannel clones a File) and aren't kept for a reload.
|
||||
*/
|
||||
initialFiles?: File[];
|
||||
}
|
||||
|
||||
@@ -357,3 +357,14 @@ export function subscribeToPendingWebcal(callback: () => void): () => void {
|
||||
export function hasPendingWebcal(): boolean {
|
||||
return hasPending(WEBCAL_KEY, isParsedWebcal);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a mail link from outside goes out from, with no question: the one marked
|
||||
* default, else the one in use, as Outlook and Thunderbird do. The composer's From can
|
||||
* change it. Only signed-in accounts count.
|
||||
*/
|
||||
export function mailLinkAccountId(accounts: readonly { id: string }[], defaultId: string | null, activeId: string | null): string | null {
|
||||
if (defaultId && accounts.some((account) => account.id === defaultId)) return defaultId;
|
||||
if (activeId && accounts.some((account) => account.id === activeId)) return activeId;
|
||||
return accounts[0]?.id ?? activeId ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { stripMessageIdBrackets } from '@/lib/email-threading';
|
||||
|
||||
/**
|
||||
* A reply saved as a draft, found from the message it answers (compose-p2-6).
|
||||
* The reader marks that message with it, and Reply opens it again instead of
|
||||
* starting a second draft, as Gmail and Outlook do.
|
||||
*/
|
||||
|
||||
/** The message's own Message-IDs, bare. JMAP sends a list; older code a string. */
|
||||
function messageIdsOf(email: Pick<Email, 'messageId'>): string[] {
|
||||
const raw = email.messageId as unknown;
|
||||
const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
|
||||
return list.filter((id): id is string => typeof id === 'string').map(stripMessageIdBrackets).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Whether `draft` is a reply to `email` (its In-Reply-To names it). */
|
||||
export function answersMessage(draft: Pick<Email, 'inReplyTo' | 'keywords'>, email: Pick<Email, 'messageId'>): boolean {
|
||||
if (!draft.keywords?.$draft) return false;
|
||||
const ids = messageIdsOf(email);
|
||||
if (ids.length === 0) return false;
|
||||
return (draft.inReplyTo ?? []).some((id) => ids.includes(stripMessageIdBrackets(id)));
|
||||
}
|
||||
|
||||
const newestFirst = (a: Pick<Email, 'receivedAt'>, b: Pick<Email, 'receivedAt'>) =>
|
||||
(b.receivedAt ?? '').localeCompare(a.receivedAt ?? '');
|
||||
|
||||
/**
|
||||
* The newest of a conversation's drafts that answers `email`, in full. List
|
||||
* rows carry no In-Reply-To, so a draft without it is read once more through
|
||||
* `getEmail`; one that has it (a full fetch) is taken as it is.
|
||||
*/
|
||||
export async function findReplyDraft(
|
||||
drafts: readonly Email[],
|
||||
email: Pick<Email, 'id' | 'messageId'>,
|
||||
getEmail: (id: string) => Promise<Email | null>,
|
||||
): Promise<Email | null> {
|
||||
if (messageIdsOf(email).length === 0) return null;
|
||||
const candidates = drafts.filter((d) => d.id !== email.id && d.keywords?.$draft).sort(newestFirst);
|
||||
for (const candidate of candidates) {
|
||||
const full = candidate.inReplyTo !== undefined ? candidate : await getEmail(candidate.id).catch(() => null);
|
||||
if (full && answersMessage(full, email)) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A conversation's drafts, read fresh (after a composer closed or a draft went). */
|
||||
export async function threadDrafts(
|
||||
client: { getThread(threadId: string, accountId?: string): Promise<{ emailIds: string[] } | null>; getSomeEmails(ids: string[], accountId?: string): Promise<Email[]> },
|
||||
threadId: string,
|
||||
accountId: string | undefined,
|
||||
): Promise<Email[]> {
|
||||
const thread = await client.getThread(threadId, accountId);
|
||||
const ids = thread?.emailIds ?? [];
|
||||
if (ids.length < 2) return [];
|
||||
const members = await client.getSomeEmails(ids, accountId);
|
||||
return members.filter((e) => e.keywords?.$draft);
|
||||
}
|
||||
+39
-9
@@ -12,23 +12,53 @@ export function isApplePlatform(): boolean {
|
||||
|
||||
const MOD = /^(mod|ctrl\/⌘|ctrl\/cmd|cmd\/ctrl|⌘\/ctrl)$/i;
|
||||
|
||||
/** One key or combination ("Mod + Enter") in the notation of this platform. */
|
||||
export function formatShortcutCombo(combo: string, apple = isApplePlatform()): string {
|
||||
/**
|
||||
* Key names as Windows writes them in each language, on its keyboards and in every app's
|
||||
* menus: German "Strg+Umschalt+R", "Entf". Languages not here keep the English names, as
|
||||
* Windows does for them. macOS has symbols, which need no words.
|
||||
*/
|
||||
const KEY_NAMES: Record<string, Record<string, string>> = {
|
||||
de: { Ctrl: 'Strg', Shift: 'Umschalt', Del: 'Entf', Delete: 'Entf', Enter: 'Eingabe', Backspace: 'Rücktaste', Home: 'Pos1', End: 'Ende', PageUp: 'Bild↑', PageDown: 'Bild↓', Insert: 'Einfg', Space: 'Leertaste' },
|
||||
fr: { Shift: 'Maj', Del: 'Suppr', Delete: 'Suppr', Enter: 'Entrée', Backspace: 'Retour arrière', Home: 'Origine', End: 'Fin', PageUp: 'Pg.Préc', PageDown: 'Pg.Suiv', Insert: 'Inser', Space: 'Espace' },
|
||||
es: { Shift: 'Mayús', Del: 'Supr', Delete: 'Supr', Enter: 'Entrar', Backspace: 'Retroceso', Home: 'Inicio', End: 'Fin', PageUp: 'Re Pág', PageDown: 'Av Pág', Space: 'Espacio' },
|
||||
it: { Shift: 'Maiusc', Del: 'Canc', Delete: 'Canc', Enter: 'Invio', Space: 'Spazio' },
|
||||
};
|
||||
|
||||
/** The app's language, from <html lang> (components/providers/intl-provider.tsx keeps it). */
|
||||
function pageLanguage(): string {
|
||||
if (typeof document === 'undefined') return 'en';
|
||||
return (document.documentElement.lang || 'en').toLowerCase().split('-')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* One key's name in a language: "Ctrl" is "Strg" in German. Takes the English names this
|
||||
* notation and Electron use ("Ctrl", "Shift", "Del", "Delete", "Enter", "PageUp"); anything
|
||||
* else comes back as it is. `language` defaults to the app's.
|
||||
*/
|
||||
export function localKeyName(name: string, language: string = pageLanguage()): string {
|
||||
const table = KEY_NAMES[language.toLowerCase().split('-')[0]];
|
||||
if (!table) return name;
|
||||
const canonical = Object.keys(table).find((key) => key.toLowerCase() === name.toLowerCase());
|
||||
return canonical ? table[canonical] : name;
|
||||
}
|
||||
|
||||
/** One key or combination ("Mod + Enter") in the notation of this platform and the app's language. */
|
||||
export function formatShortcutCombo(combo: string, apple = isApplePlatform(), language: string = pageLanguage()): string {
|
||||
// Keeps the spacing it was written with ("Ctrl+Shift+Y" from a plugin).
|
||||
const separator = /\s\+\s/.test(combo) ? " + " : "+";
|
||||
return combo
|
||||
.split(/\s*\+\s*/)
|
||||
.map((part) => {
|
||||
if (MOD.test(part)) return apple ? '⌘' : 'Ctrl';
|
||||
if (/^ctrl$/i.test(part)) return apple ? '⌘' : 'Ctrl';
|
||||
if (/^(cmd|⌘)$/i.test(part)) return apple ? '⌘' : 'Ctrl';
|
||||
if (MOD.test(part)) return apple ? '⌘' : localKeyName('Ctrl', language);
|
||||
if (/^ctrl$/i.test(part)) return apple ? '⌘' : localKeyName('Ctrl', language);
|
||||
if (/^(cmd|⌘)$/i.test(part)) return apple ? '⌘' : localKeyName('Ctrl', language);
|
||||
if (/^alt$/i.test(part)) return apple ? '⌥' : 'Alt';
|
||||
return part;
|
||||
return apple ? part : localKeyName(part, language);
|
||||
})
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
/** The alternatives of a shortcut ("j / ↓"), each in the notation of this platform. */
|
||||
export function shortcutAlternatives(key: string, apple = isApplePlatform()): string[] {
|
||||
return key.split(' / ').map((combo) => formatShortcutCombo(combo, apple));
|
||||
/** The alternatives of a shortcut ("j / ↓"), each in the notation of this platform and the app's language. */
|
||||
export function shortcutAlternatives(key: string, apple = isApplePlatform(), language: string = pageLanguage()): string[] {
|
||||
return key.split(' / ').map((combo) => formatShortcutCombo(combo, apple, language));
|
||||
}
|
||||
|
||||
@@ -94,9 +94,14 @@ export function applyControlsArea(area: TitlebarArea | null, root: HTMLElement =
|
||||
|
||||
/**
|
||||
* Calls `listener` now and whenever the overlay's size or visibility changes
|
||||
* (`geometrychange`). Without the API it calls it once with null and listens
|
||||
* to nothing, so a browser without it runs no extra code. Returns the
|
||||
* unsubscribe.
|
||||
* (`geometrychange`), and again when the page's width does (`resize`).
|
||||
* Without the API it calls it once with null and listens to nothing, so a
|
||||
* browser without it runs no extra code. Returns the unsubscribe.
|
||||
*
|
||||
* `geometrychange` comes before the page has its new width: after a maximise,
|
||||
* restore, snap or drag the rect is the new one and innerWidth still the old,
|
||||
* so the room worked out from the two is wrong (none at all after growing, more
|
||||
* than the window after shrinking). The `resize` that follows has both.
|
||||
*/
|
||||
export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void {
|
||||
listener(readTitlebarArea());
|
||||
@@ -104,10 +109,6 @@ export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void)
|
||||
if (!current) return () => {};
|
||||
const onChange = () => listener(readTitlebarArea());
|
||||
current.addEventListener('geometrychange', onChange);
|
||||
// Electron sends geometrychange before the page has its new width, so the
|
||||
// controls' share (width minus the strip) came out of the old width: 393 px
|
||||
// at 1024, 361 at 800, the toolbar folding with room to spare (mail-p2-16).
|
||||
// The resize that follows has the new width.
|
||||
window.addEventListener('resize', onChange);
|
||||
return () => {
|
||||
current.removeEventListener('geometrychange', onChange);
|
||||
|
||||
+12
-3
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "اختر الحساب",
|
||||
"select_mailto_account": "اختر الحساب الذي سيفتح رابط البريد هذا.",
|
||||
"select_webcal_account": "اختر الحساب الذي سيفتح رابط التقويم هذا.",
|
||||
"select_account_note": "ينطبق هذا فقط على رابط البروتوكول هذا.",
|
||||
"detail_to": "إلى",
|
||||
"detail_subject": "الموضوع",
|
||||
"detail_no_subject": "بلا موضوع",
|
||||
@@ -1081,7 +1080,7 @@
|
||||
"recipients_partly_refused": "رفض الخادم {recipients}. سيتلقى باقي المستلمين الرسالة.",
|
||||
"send_offline": "يبدو أنك غير متصل. الرسالة ما زالت هنا، أرسلها مجددًا عند عودة الاتصال.",
|
||||
"formatting": "التنسيق",
|
||||
"send_recipients_rejected": "لم تُرسل الرسالة - رفض الخادم جميع المستلمين.",
|
||||
"send_recipients_rejected": "لم تُرسل الرسالة: رفض الخادم جميع المستلمين.",
|
||||
"send_plugin_failed": "لم يُرسل - فشلت إضافة تتولى الإرسال أو استغرقت وقتًا طويلًا. تحقق من مجلد المرسل قبل الإرسال مرة أخرى.",
|
||||
"send_some_recipients_rejected": "أُرسلت الرسالة، لكن ليس إلى هؤلاء المستلمين - رفضهم الخادم.",
|
||||
"continue_draft": "متابعة المسودة",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "جدولة الإرسال",
|
||||
"schedule_send_description": "اختر متى يجب على الخادم إرسال هذه الرسالة.",
|
||||
"schedule_send_description": "اختر متى يتم إرسالها.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "هذا الصباح",
|
||||
"this_afternoon": "بعد ظهر اليوم",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "تعديل الرابط",
|
||||
"remove_link": "إزالة الرابط",
|
||||
"link_address": "عنوان الرابط",
|
||||
"link_text": "النص",
|
||||
"link_invalid": "يجب ألا يحتوي العنوان على مسافات",
|
||||
"table": "جدول",
|
||||
"clear_formatting": "مسح التنسيق",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "جارٍ الإرسال…",
|
||||
"sent": "تم الإرسال",
|
||||
"view": "عرض",
|
||||
"template_saved": "تم حفظ القالب",
|
||||
"schedule_anyway": "الجدولة على أي حال",
|
||||
"schedule_warnings_title": "هل تريد جدولة هذه الرسالة على أي حال؟",
|
||||
"picker_button": "إضافة من جهات الاتصال",
|
||||
"picker_title": "إضافة مستلمين",
|
||||
"tab_contacts": "جهات الاتصال",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "شغّل تقارير الأعطال أولًا.",
|
||||
"send_no_url": "لم يُحدَّد عنوان، لذا لا توجد وجهة للإرسال. أدخل عنوانًا في الأعلى.",
|
||||
"send_failed": "تعذّر إرسال تقرير العطل."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "عدد الرسائل غير المقروءة على أيقونة شريط المهام",
|
||||
"unread_badge_description": "لترى البريد الجديد أثناء عملك في تطبيق آخر.",
|
||||
"tip_compose_keys": "اضغط {keys} لكتابة رسالة جديدة",
|
||||
"tip_palette_keys": "اضغط {keys} للعثور على أي أمر"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Trieu un compte",
|
||||
"select_mailto_account": "Trieu quin compte ha d'obrir aquest enllaç de correu.",
|
||||
"select_webcal_account": "Trieu quin compte ha d'obrir aquest enllaç de calendari.",
|
||||
"select_account_note": "Això només s'aplica a aquest enllaç de protocol.",
|
||||
"detail_to": "Per a",
|
||||
"detail_subject": "Assumpte",
|
||||
"detail_no_subject": "Sense assumpte",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "Programa l'enviament",
|
||||
"schedule_send_description": "Trieu quan ha d'alliberar el servidor aquest missatge.",
|
||||
"schedule_send_description": "Tria quan s'ha d'enviar.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Aquest matí",
|
||||
"this_afternoon": "Aquesta tarda",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Edita l'enllaç",
|
||||
"remove_link": "Treu l'enllaç",
|
||||
"link_address": "Adreça de l'enllaç",
|
||||
"link_text": "Text",
|
||||
"link_invalid": "L'adreça no pot tenir espais",
|
||||
"table": "Taula",
|
||||
"clear_formatting": "Neteja el format",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "S'està enviant…",
|
||||
"sent": "Enviat",
|
||||
"view": "Mostra",
|
||||
"template_saved": "Plantilla desada",
|
||||
"schedule_anyway": "Programa igualment",
|
||||
"schedule_warnings_title": "Vols programar aquest missatge igualment?",
|
||||
"picker_button": "Afegeix des dels contactes",
|
||||
"picker_title": "Afegeix destinataris",
|
||||
"tab_contacts": "Contactes",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Activa primer els informes d'errors greus.",
|
||||
"send_no_url": "No hi ha cap adreça, així que no hi ha on enviar-ho. Escriu-ne una a dalt.",
|
||||
"send_failed": "No s'ha pogut enviar l'informe d'error."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Nombre de no llegits a la icona de la barra de tasques",
|
||||
"unread_badge_description": "Per veure el correu nou mentre treballeu en una altra aplicació.",
|
||||
"tip_compose_keys": "Premeu {keys} per escriure un missatge nou",
|
||||
"tip_palette_keys": "Premeu {keys} per trobar qualsevol ordre"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Vybrat účet",
|
||||
"select_mailto_account": "Vyberte účet, ve kterém se má tento e-mailový odkaz otevřít.",
|
||||
"select_webcal_account": "Vyberte účet, ve kterém se má tento kalendářový odkaz otevřít.",
|
||||
"select_account_note": "Tato volba platí jen pro tento odkaz protokolu.",
|
||||
"detail_to": "Komu",
|
||||
"detail_subject": "Předmět",
|
||||
"detail_no_subject": "Bez předmětu",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "Zadejte URL",
|
||||
"sending": "Odesílání...",
|
||||
"schedule_send": "Naplánovat odeslání",
|
||||
"schedule_send_description": "Vyberte, kdy má server tuto zprávu odeslat.",
|
||||
"schedule_send_description": "Vyberte, kdy se má odeslat.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Dnes ráno",
|
||||
"this_afternoon": "Dnes odpoledne",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Upravit odkaz",
|
||||
"remove_link": "Odebrat odkaz",
|
||||
"link_address": "Adresa odkazu",
|
||||
"link_text": "Text",
|
||||
"link_invalid": "Adresa nesmí obsahovat mezery",
|
||||
"table": "Tabulka",
|
||||
"clear_formatting": "Vymazat formátování",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Odesílání…",
|
||||
"sent": "Odesláno",
|
||||
"view": "Zobrazit",
|
||||
"template_saved": "Šablona uložena",
|
||||
"schedule_anyway": "Přesto naplánovat",
|
||||
"schedule_warnings_title": "Přesto tuto zprávu naplánovat?",
|
||||
"picker_button": "Přidat z kontaktů",
|
||||
"picker_title": "Přidat příjemce",
|
||||
"tab_contacts": "Kontakty",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Nejprve zapněte hlášení o pádech.",
|
||||
"send_no_url": "Není nastavena žádná adresa, takže to nejde kam odeslat. Zadejte ji výše.",
|
||||
"send_failed": "Hlášení o pádu se nepodařilo odeslat."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Počet nepřečtených na ikoně na hlavním panelu",
|
||||
"unread_badge_description": "Abyste viděli novou poštu, i když pracujete v jiné aplikaci.",
|
||||
"tip_compose_keys": "Stiskněte {keys} a napište novou zprávu",
|
||||
"tip_palette_keys": "Stiskněte {keys} a najděte jakýkoli příkaz"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Vælg konto",
|
||||
"select_mailto_account": "Vælg hvilken konto der skal åbne dette e-mail-link.",
|
||||
"select_webcal_account": "Vælg hvilken konto der skal åbne dette kalenderlink.",
|
||||
"select_account_note": "Dette gælder kun for dette protokollink.",
|
||||
"detail_to": "Til",
|
||||
"detail_subject": "Emne",
|
||||
"detail_no_subject": "Intet emne",
|
||||
@@ -1080,7 +1079,7 @@
|
||||
"recipients_partly_refused": "Serveren afviste {recipients}. De øvrige modtagere får beskeden.",
|
||||
"send_offline": "Du ser ud til at være offline. Beskeden er her stadig, send den igen, når du er online.",
|
||||
"formatting": "Formatering",
|
||||
"send_recipients_rejected": "Ikke sendt - serveren afviste alle modtagere.",
|
||||
"send_recipients_rejected": "Ikke sendt: serveren afviste alle modtagere.",
|
||||
"send_plugin_failed": "Ikke sendt - et plugin, der står for afsendelsen, fejlede eller tog for lang tid. Tjek mappen Sendt, før du sender igen.",
|
||||
"send_some_recipients_rejected": "Sendt, men ikke til disse modtagere - serveren afviste dem.",
|
||||
"continue_draft": "Fortsæt kladde",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
},
|
||||
"link_url_prompt": "Indtast URL'en",
|
||||
"schedule_send": "Planlæg afsendelse",
|
||||
"schedule_send_description": "Vælg hvornår serveren skal frigive denne besked.",
|
||||
"schedule_send_description": "Vælg hvornår den skal sendes.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "I dag formiddag",
|
||||
"this_afternoon": "I eftermiddag",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Rediger link",
|
||||
"remove_link": "Fjern link",
|
||||
"link_address": "Linkadresse",
|
||||
"link_text": "Tekst",
|
||||
"link_invalid": "Adressen må ikke indeholde mellemrum",
|
||||
"table": "Tabel",
|
||||
"clear_formatting": "Ryd formatering",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Sender…",
|
||||
"sent": "Sendt",
|
||||
"view": "Vis",
|
||||
"template_saved": "Skabelon gemt",
|
||||
"schedule_anyway": "Planlæg alligevel",
|
||||
"schedule_warnings_title": "Planlæg denne besked alligevel?",
|
||||
"picker_button": "Tilføj fra kontakter",
|
||||
"picker_title": "Tilføj modtagere",
|
||||
"tab_contacts": "Kontakter",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Slå først nedbrudsrapporter til.",
|
||||
"send_no_url": "Der er ikke angivet en adresse, så den kan ikke sendes nogen steder. Skriv en ovenfor.",
|
||||
"send_failed": "Nedbrudsrapporten kunne ikke sendes."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Antal ulæste på ikonet på proceslinjen",
|
||||
"unread_badge_description": "Så du ser ny mail, mens du arbejder i en anden app.",
|
||||
"tip_compose_keys": "Tryk på {keys} for at skrive en ny besked",
|
||||
"tip_palette_keys": "Tryk på {keys} for at finde en kommando"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Account auswählen",
|
||||
"select_mailto_account": "Wählen Sie aus, mit welchem Account dieser E-Mail-Link geöffnet werden soll.",
|
||||
"select_webcal_account": "Wählen Sie aus, mit welchem Account dieser Kalender-Link geöffnet werden soll.",
|
||||
"select_account_note": "Diese Auswahl gilt nur für diesen Protokoll-Link.",
|
||||
"detail_to": "An",
|
||||
"detail_subject": "Betreff",
|
||||
"detail_no_subject": "Ohne Betreff",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "URL eingeben",
|
||||
"sending": "Wird gesendet...",
|
||||
"schedule_send": "Senden planen",
|
||||
"schedule_send_description": "Wählen Sie, wann der Server diese Nachricht freigeben soll.",
|
||||
"schedule_send_description": "Wählen Sie, wann gesendet werden soll.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Heute früh",
|
||||
"this_afternoon": "Heute Nachmittag",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Link bearbeiten",
|
||||
"remove_link": "Link entfernen",
|
||||
"link_address": "Linkadresse",
|
||||
"link_text": "Text",
|
||||
"link_invalid": "Die Adresse darf keine Leerzeichen enthalten",
|
||||
"table": "Tabelle",
|
||||
"clear_formatting": "Formatierung löschen",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Wird gesendet…",
|
||||
"sent": "Gesendet",
|
||||
"view": "Anzeigen",
|
||||
"template_saved": "Vorlage gespeichert",
|
||||
"schedule_anyway": "Trotzdem planen",
|
||||
"schedule_warnings_title": "Diese Nachricht trotzdem planen?",
|
||||
"picker_button": "Aus Kontakten hinzufügen",
|
||||
"picker_title": "Empfänger hinzufügen",
|
||||
"tab_contacts": "Kontakte",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Schalten Sie zuerst die Absturzberichte ein.",
|
||||
"send_no_url": "Es ist keine Adresse eingetragen, daher gibt es kein Ziel. Geben Sie oben eine ein.",
|
||||
"send_failed": "Der Absturzbericht konnte nicht gesendet werden."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Anzahl ungelesener Nachrichten am Taskleistensymbol",
|
||||
"unread_badge_description": "So sehen Sie neue Mail, während Sie in einer anderen App arbeiten.",
|
||||
"tip_compose_keys": "Drücken Sie {keys}, um eine neue Nachricht zu schreiben",
|
||||
"tip_palette_keys": "Drücken Sie {keys}, um jeden Befehl zu finden"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Choose account",
|
||||
"select_mailto_account": "Choose which account should open this email link.",
|
||||
"select_webcal_account": "Choose which account should open this calendar link.",
|
||||
"select_account_note": "This only applies to this protocol link.",
|
||||
"detail_to": "To",
|
||||
"detail_subject": "Subject",
|
||||
"detail_no_subject": "No subject",
|
||||
@@ -1081,7 +1080,7 @@
|
||||
"submission_limit": "The server refused to send the message because a limit was reached: {reason}",
|
||||
"recipients_refused": "Not sent: the server refused {recipients}.",
|
||||
"recipients_partly_refused": "The server refused {recipients}. The other recipients get the message.",
|
||||
"send_recipients_rejected": "Not sent - the server rejected every recipient.",
|
||||
"send_recipients_rejected": "Not sent: the server rejected every recipient.",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"send_plugin_failed": "Not sent - a plugin that handles sending failed or took too long. Check your Sent folder before sending again.",
|
||||
@@ -1126,7 +1125,7 @@
|
||||
"unfinished": ["TODO", "TBD", "TBA", "TBC", "FIXME", "XXX"]
|
||||
},
|
||||
"schedule_send": "Schedule send",
|
||||
"schedule_send_description": "Choose when the server should release this message.",
|
||||
"schedule_send_description": "Pick when to send it.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "This morning",
|
||||
"this_afternoon": "This afternoon",
|
||||
@@ -1177,6 +1176,7 @@
|
||||
"edit_link": "Edit link",
|
||||
"remove_link": "Remove link",
|
||||
"link_address": "Link address",
|
||||
"link_text": "Text",
|
||||
"link_invalid": "The address can't contain spaces",
|
||||
"table": "Table",
|
||||
"clear_formatting": "Clear formatting",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Sending…",
|
||||
"sent": "Sent",
|
||||
"view": "View",
|
||||
"template_saved": "Template saved",
|
||||
"schedule_anyway": "Schedule anyway",
|
||||
"schedule_warnings_title": "Schedule this message anyway?",
|
||||
"picker_button": "Add from contacts",
|
||||
"picker_title": "Add recipients",
|
||||
"tab_contacts": "Contacts",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Turn on crash reports first.",
|
||||
"send_no_url": "No address is set, so there's nowhere to send this. Enter one above.",
|
||||
"send_failed": "The crash report couldn't be sent."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Unread count on the taskbar icon",
|
||||
"unread_badge_description": "So you see new mail while you work in another app.",
|
||||
"tip_compose_keys": "Press {keys} to write a new message",
|
||||
"tip_palette_keys": "Press {keys} to find any command"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Elegir cuenta",
|
||||
"select_mailto_account": "Elige qué cuenta debe abrir este enlace de correo.",
|
||||
"select_webcal_account": "Elige qué cuenta debe abrir este enlace de calendario.",
|
||||
"select_account_note": "Esta selección solo se aplica a este enlace de protocolo.",
|
||||
"detail_to": "Para",
|
||||
"detail_subject": "Asunto",
|
||||
"detail_no_subject": "Sin asunto",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "Introduce la URL",
|
||||
"sending": "Enviando...",
|
||||
"schedule_send": "Programar envío",
|
||||
"schedule_send_description": "Elige cuándo debe liberar el servidor este mensaje.",
|
||||
"schedule_send_description": "Elige cuándo enviarlo.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Esta mañana",
|
||||
"this_afternoon": "Esta tarde",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Editar enlace",
|
||||
"remove_link": "Quitar enlace",
|
||||
"link_address": "Dirección del enlace",
|
||||
"link_text": "Texto",
|
||||
"link_invalid": "La dirección no puede contener espacios",
|
||||
"table": "Tabla",
|
||||
"clear_formatting": "Borrar formato",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Enviando…",
|
||||
"sent": "Enviado",
|
||||
"view": "Ver",
|
||||
"template_saved": "Plantilla guardada",
|
||||
"schedule_anyway": "Programar de todos modos",
|
||||
"schedule_warnings_title": "¿Programar este mensaje de todos modos?",
|
||||
"picker_button": "Añadir desde contactos",
|
||||
"picker_title": "Añadir destinatarios",
|
||||
"tab_contacts": "Contactos",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Activa primero los informes de fallos.",
|
||||
"send_no_url": "No hay ninguna dirección, así que no hay adónde enviarlo. Escribe una arriba.",
|
||||
"send_failed": "No se ha podido enviar el informe de fallo."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Número de no leídos en el icono de la barra de tareas",
|
||||
"unread_badge_description": "Para ver el correo nuevo mientras trabaja en otra aplicación.",
|
||||
"tip_compose_keys": "Pulse {keys} para escribir un mensaje nuevo",
|
||||
"tip_palette_keys": "Pulse {keys} para buscar cualquier comando"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Vali konto",
|
||||
"select_mailto_account": "Vali, milline konto peaks selle e-posti lingi avama.",
|
||||
"select_webcal_account": "Vali, milline konto peaks selle kalendrilingi avama.",
|
||||
"select_account_note": "See kehtib ainult selle protokollilingi kohta.",
|
||||
"detail_to": "Kellele",
|
||||
"detail_subject": "Teema",
|
||||
"detail_no_subject": "Teema puudub",
|
||||
@@ -1081,7 +1080,7 @@
|
||||
"submission_limit": "Server keeldus kirja saatmast, sest piirang on täis: {reason}",
|
||||
"recipients_refused": "Ei saadetud: server lükkas tagasi {recipients}.",
|
||||
"recipients_partly_refused": "Server lükkas tagasi {recipients}. Teised adressaadid saavad kirja kätte.",
|
||||
"send_recipients_rejected": "Ei saadetud - server lükkas kõik adressaadid tagasi.",
|
||||
"send_recipients_rejected": "Ei saadetud: server lükkas kõik adressaadid tagasi.",
|
||||
"send_plugin_failed": "Ei saadetud - saatmist käsitlev pistikprogramm ebaõnnestus või võttis liiga kaua aega. Kontrolli enne uuesti saatmist kausta Saadetud.",
|
||||
"send_some_recipients_rejected": "Saadetud, kuid mitte neile adressaatidele - server lükkas nad tagasi.",
|
||||
"continue_draft": "Jätka mustandiga",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "Ajasta saatmine",
|
||||
"schedule_send_description": "Vali, millal server peaks selle sõnumi välja saatma.",
|
||||
"schedule_send_description": "Vali, millal see saata.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Täna hommikul",
|
||||
"this_afternoon": "Täna pärastlõunal",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Muuda linki",
|
||||
"remove_link": "Eemalda link",
|
||||
"link_address": "Lingi aadress",
|
||||
"link_text": "Tekst",
|
||||
"link_invalid": "Aadressis ei tohi olla tühikuid",
|
||||
"table": "Tabel",
|
||||
"clear_formatting": "Eemalda vormindus",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Saatmine…",
|
||||
"sent": "Saadetud",
|
||||
"view": "Vaata",
|
||||
"template_saved": "Mall salvestatud",
|
||||
"schedule_anyway": "Ajasta ikkagi",
|
||||
"schedule_warnings_title": "Kas ajastada see kiri ikkagi?",
|
||||
"picker_button": "Lisa kontaktidest",
|
||||
"picker_title": "Lisa saajaid",
|
||||
"tab_contacts": "Kontaktid",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Lülitage esmalt kokkujooksmise aruanded sisse.",
|
||||
"send_no_url": "Aadressi pole määratud, seega pole seda kuhugi saata. Sisestage see ülal.",
|
||||
"send_failed": "Kokkujooksmise aruannet ei õnnestunud saata."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Lugemata kirjade arv tegumiriba ikoonil",
|
||||
"unread_badge_description": "Nii näete uut kirja ka siis, kui töötate teises rakenduses.",
|
||||
"tip_compose_keys": "Uue kirja kirjutamiseks vajutage {keys}",
|
||||
"tip_palette_keys": "Mis tahes käsu leidmiseks vajutage {keys}"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "انتخاب حساب",
|
||||
"select_mailto_account": "انتخاب کنید کدام حساب این لینک ایمیل را باز کند.",
|
||||
"select_webcal_account": "انتخاب کنید کدام حساب این لینک تقویم را باز کند.",
|
||||
"select_account_note": "فقط برای این لینک پروتکل اعمال میشود.",
|
||||
"detail_to": "به",
|
||||
"detail_subject": "موضوع",
|
||||
"detail_no_subject": "بدون موضوع",
|
||||
@@ -1081,7 +1080,7 @@
|
||||
"recipients_partly_refused": "سرور {recipients} را رد کرد. سایر گیرندگان پیام را دریافت میکنند.",
|
||||
"send_offline": "به نظر میرسد آفلاین هستید. پیام هنوز اینجاست، وقتی آنلاین شدید دوباره ارسالش کنید.",
|
||||
"formatting": "قالببندی",
|
||||
"send_recipients_rejected": "ارسال نشد - سرور همه گیرندگان را رد کرد.",
|
||||
"send_recipients_rejected": "ارسال نشد: سرور همه گیرندگان را رد کرد.",
|
||||
"send_plugin_failed": "ارسال نشد - افزونهای که ارسال را انجام میدهد با خطا مواجه شد یا بیش از حد طول کشید. پیش از ارسال دوباره، پوشه ارسالشده را بررسی کنید.",
|
||||
"send_some_recipients_rejected": "ارسال شد، اما نه برای این گیرندگان - سرور آنها را رد کرد.",
|
||||
"continue_draft": "ادامه پیشنویس",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "زمانبندی ارسال",
|
||||
"schedule_send_description": "زمان انتشار پیام توسط سرور را انتخاب کنید.",
|
||||
"schedule_send_description": "زمان ارسال را انتخاب کنید.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "امروز صبح",
|
||||
"this_afternoon": "امروز بعدازظهر",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "ویرایش پیوند",
|
||||
"remove_link": "حذف پیوند",
|
||||
"link_address": "نشانی پیوند",
|
||||
"link_text": "متن",
|
||||
"link_invalid": "نشانی نباید فاصله داشته باشد",
|
||||
"table": "جدول",
|
||||
"clear_formatting": "پاک کردن قالببندی",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "در حال ارسال…",
|
||||
"sent": "ارسال شد",
|
||||
"view": "مشاهده",
|
||||
"template_saved": "الگو ذخیره شد",
|
||||
"schedule_anyway": "زمانبندی به هر حال",
|
||||
"schedule_warnings_title": "این پیام به هر حال زمانبندی شود؟",
|
||||
"picker_button": "افزودن از مخاطبین",
|
||||
"picker_title": "افزودن گیرندگان",
|
||||
"tab_contacts": "مخاطبین",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "ابتدا گزارشهای خرابی را روشن کنید.",
|
||||
"send_no_url": "نشانیای تنظیم نشده، پس جایی برای ارسال نیست. در بالا یکی وارد کنید.",
|
||||
"send_failed": "گزارش خرابی ارسال نشد."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "تعداد خواندهنشدهها روی نماد نوار وظیفه",
|
||||
"unread_badge_description": "تا ایمیل جدید را هنگام کار در برنامهای دیگر ببینید.",
|
||||
"tip_compose_keys": "برای نوشتن پیام جدید {keys} را فشار دهید",
|
||||
"tip_palette_keys": "برای یافتن هر فرمانی {keys} را فشار دهید"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Choisir un compte",
|
||||
"select_mailto_account": "Choisissez le compte qui doit ouvrir ce lien e-mail.",
|
||||
"select_webcal_account": "Choisissez le compte qui doit ouvrir ce lien de calendrier.",
|
||||
"select_account_note": "Cette sélection s'applique uniquement à ce lien de protocole.",
|
||||
"detail_to": "À",
|
||||
"detail_subject": "Objet",
|
||||
"detail_no_subject": "Sans objet",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "Saisissez l'URL",
|
||||
"sending": "Envoi...",
|
||||
"schedule_send": "Planifier l’envoi",
|
||||
"schedule_send_description": "Choisissez quand le serveur doit libérer ce message.",
|
||||
"schedule_send_description": "Choisissez quand l’envoyer.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Ce matin",
|
||||
"this_afternoon": "Cet après-midi",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Modifier le lien",
|
||||
"remove_link": "Retirer le lien",
|
||||
"link_address": "Adresse du lien",
|
||||
"link_text": "Texte",
|
||||
"link_invalid": "L'adresse ne peut pas contenir d'espaces",
|
||||
"table": "Tableau",
|
||||
"clear_formatting": "Effacer la mise en forme",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Envoi…",
|
||||
"sent": "Envoyé",
|
||||
"view": "Afficher",
|
||||
"template_saved": "Modèle enregistré",
|
||||
"schedule_anyway": "Planifier quand même",
|
||||
"schedule_warnings_title": "Planifier ce message quand même ?",
|
||||
"picker_button": "Ajouter depuis les contacts",
|
||||
"picker_title": "Ajouter des destinataires",
|
||||
"tab_contacts": "Contacts",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Activez d'abord les rapports de plantage.",
|
||||
"send_no_url": "Aucune adresse n'est définie, il n'y a donc nulle part où envoyer ce rapport. Saisissez-en une ci-dessus.",
|
||||
"send_failed": "Le rapport de plantage n'a pas pu être envoyé."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Nombre de non-lus sur l'icône de la barre des tâches",
|
||||
"unread_badge_description": "Pour voir les nouveaux messages pendant que vous travaillez dans une autre application.",
|
||||
"tip_compose_keys": "Appuyez sur {keys} pour écrire un nouveau message",
|
||||
"tip_palette_keys": "Appuyez sur {keys} pour trouver n'importe quelle commande"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -1003,7 +1003,7 @@
|
||||
"recipients_partly_refused": "השרת דחה את {recipients}. שאר הנמענים יקבלו את ההודעה.",
|
||||
"send_offline": "נראה שאין חיבור לרשת. ההודעה עדיין כאן, שלחו אותה שוב כשתחזרו לרשת.",
|
||||
"formatting": "עיצוב",
|
||||
"send_recipients_rejected": "לא נשלח - השרת דחה את כל הנמענים.",
|
||||
"send_recipients_rejected": "לא נשלח: השרת דחה את כל הנמענים.",
|
||||
"send_plugin_failed": "לא נשלח - תוסף שמטפל בשליחה נכשל או לקח יותר מדי זמן. בדקו את תיקיית דואר יוצא לפני שליחה חוזרת.",
|
||||
"send_some_recipients_rejected": "נשלח, אך לא לנמענים האלה - השרת דחה אותם.",
|
||||
"continue_draft": "המשך טיוטה",
|
||||
@@ -1067,7 +1067,7 @@
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "תזמן שליחה",
|
||||
"schedule_send_description": "בחר מתי השרת צריך לשחרר הודעה זו.",
|
||||
"schedule_send_description": "בחר מתי לשלוח אותה.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "הבוקר",
|
||||
"this_afternoon": "היום אחר הצהריים",
|
||||
@@ -1118,6 +1118,7 @@
|
||||
"edit_link": "עריכת קישור",
|
||||
"remove_link": "הסרת קישור",
|
||||
"link_address": "כתובת הקישור",
|
||||
"link_text": "טקסט",
|
||||
"link_invalid": "הכתובת לא יכולה להכיל רווחים",
|
||||
"table": "טבלה",
|
||||
"clear_formatting": "ניקוי עיצוב",
|
||||
@@ -3966,6 +3967,9 @@
|
||||
"sending": "שולח…",
|
||||
"sent": "נשלח",
|
||||
"view": "הצג",
|
||||
"template_saved": "התבנית נשמרה",
|
||||
"schedule_anyway": "תזמן בכל זאת",
|
||||
"schedule_warnings_title": "לתזמן את ההודעה בכל זאת?",
|
||||
"picker_button": "הוסף מאנשי הקשר",
|
||||
"picker_title": "הוספת נמענים",
|
||||
"tab_contacts": "אנשי קשר",
|
||||
@@ -5537,7 +5541,6 @@
|
||||
"select_account_title": "בחר חשבון",
|
||||
"select_mailto_account": "בחר איזה חשבון צריך לפתוח קישור דוא״ל זה.",
|
||||
"select_webcal_account": "בחר איזה חשבון צריך לפתוח קישור לוח שנה זה.",
|
||||
"select_account_note": "זה חל רק על קישור פרוטוקול זה.",
|
||||
"detail_to": "אל",
|
||||
"detail_subject": "נושא",
|
||||
"detail_no_subject": "אין נושא",
|
||||
@@ -7625,5 +7628,11 @@
|
||||
"send_no_consent": "הפעילו קודם את דוחות הקריסה.",
|
||||
"send_no_url": "לא הוגדרה כתובת, ולכן אין לאן לשלוח. הזינו כתובת למעלה.",
|
||||
"send_failed": "לא ניתן היה לשלוח את דוח הקריסה."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "מספר ההודעות שלא נקראו על הסמל בשורת המשימות",
|
||||
"unread_badge_description": "כדי לראות דואר חדש בזמן שאתם עובדים באפליקציה אחרת.",
|
||||
"tip_compose_keys": "הקישו {keys} כדי לכתוב הודעה חדשה",
|
||||
"tip_palette_keys": "הקישו {keys} כדי למצוא כל פקודה"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Fiók kiválasztása",
|
||||
"select_mailto_account": "Válaszd ki, melyik fiók nyissa meg ezt az e-mail hivatkozást.",
|
||||
"select_webcal_account": "Válaszd ki, melyik fiók nyissa meg ezt a naptár hivatkozást.",
|
||||
"select_account_note": "Ez csak erre a protokoll hivatkozásra vonatkozik.",
|
||||
"detail_to": "Címzett",
|
||||
"detail_subject": "Tárgy",
|
||||
"detail_no_subject": "Nincs tárgy",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "Késleltetett küldés",
|
||||
"schedule_send_description": "Válaszd ki, mikor küldje el a szerver az üzenetet.",
|
||||
"schedule_send_description": "Válaszd ki, mikor menjen el.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Ma reggel",
|
||||
"this_afternoon": "Ma délután",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Hivatkozás szerkesztése",
|
||||
"remove_link": "Hivatkozás eltávolítása",
|
||||
"link_address": "Hivatkozás címe",
|
||||
"link_text": "Szöveg",
|
||||
"link_invalid": "A cím nem tartalmazhat szóközt",
|
||||
"table": "Táblázat",
|
||||
"clear_formatting": "Formázás törlése",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Küldés…",
|
||||
"sent": "Elküldve",
|
||||
"view": "Megtekintés",
|
||||
"template_saved": "Sablon mentve",
|
||||
"schedule_anyway": "Időzítés így is",
|
||||
"schedule_warnings_title": "Időzíti így is ezt az üzenetet?",
|
||||
"picker_button": "Hozzáadás a névjegyekből",
|
||||
"picker_title": "Címzettek hozzáadása",
|
||||
"tab_contacts": "Névjegyek",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Először kapcsolja be az összeomlási jelentéseket.",
|
||||
"send_no_url": "Nincs megadva cím, így nincs hová küldeni. Adjon meg egyet fent.",
|
||||
"send_failed": "Az összeomlási jelentést nem sikerült elküldeni."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Olvasatlan levelek száma a tálca ikonján",
|
||||
"unread_badge_description": "Így akkor is látja az új leveleket, ha egy másik alkalmazásban dolgozik.",
|
||||
"tip_compose_keys": "Új üzenet írásához nyomja meg: {keys}",
|
||||
"tip_palette_keys": "Bármely parancs kereséséhez nyomja meg: {keys}"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Scegli account",
|
||||
"select_mailto_account": "Scegli quale account deve aprire questo link e-mail.",
|
||||
"select_webcal_account": "Scegli quale account deve aprire questo link calendario.",
|
||||
"select_account_note": "Questa scelta si applica solo a questo link di protocollo.",
|
||||
"detail_to": "A",
|
||||
"detail_subject": "Oggetto",
|
||||
"detail_no_subject": "Senza oggetto",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "Inserisci l'URL",
|
||||
"sending": "Invio in corso...",
|
||||
"schedule_send": "Programma invio",
|
||||
"schedule_send_description": "Scegli quando il server deve rilasciare questo messaggio.",
|
||||
"schedule_send_description": "Scegli quando inviarlo.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Stamattina",
|
||||
"this_afternoon": "Oggi pomeriggio",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Modifica link",
|
||||
"remove_link": "Rimuovi link",
|
||||
"link_address": "Indirizzo del link",
|
||||
"link_text": "Testo",
|
||||
"link_invalid": "L'indirizzo non può contenere spazi",
|
||||
"table": "Tabella",
|
||||
"clear_formatting": "Cancella formattazione",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Invio in corso…",
|
||||
"sent": "Inviato",
|
||||
"view": "Visualizza",
|
||||
"template_saved": "Modello salvato",
|
||||
"schedule_anyway": "Programma comunque",
|
||||
"schedule_warnings_title": "Programmare comunque questo messaggio?",
|
||||
"picker_button": "Aggiungi dai contatti",
|
||||
"picker_title": "Aggiungi destinatari",
|
||||
"tab_contacts": "Contatti",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Attiva prima le segnalazioni di arresto anomalo.",
|
||||
"send_no_url": "Non è impostato alcun indirizzo, quindi non c'è dove inviarla. Inseriscine uno qui sopra.",
|
||||
"send_failed": "Impossibile inviare la segnalazione di arresto."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Numero di messaggi non letti sull'icona della barra delle applicazioni",
|
||||
"unread_badge_description": "Per vedere la nuova posta mentre lavori in un'altra app.",
|
||||
"tip_compose_keys": "Premi {keys} per scrivere un nuovo messaggio",
|
||||
"tip_palette_keys": "Premi {keys} per trovare qualsiasi comando"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "アカウントを選択",
|
||||
"select_mailto_account": "このメールリンクを開くアカウントを選択してください。",
|
||||
"select_webcal_account": "このカレンダーリンクを開くアカウントを選択してください。",
|
||||
"select_account_note": "この選択は、このプロトコル リンクにのみ適用されます。",
|
||||
"detail_to": "宛先",
|
||||
"detail_subject": "件名",
|
||||
"detail_no_subject": "件名なし",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "URLを入力してください",
|
||||
"sending": "送信中...",
|
||||
"schedule_send": "送信を予約",
|
||||
"schedule_send_description": "サーバーがこのメッセージを送信する日時を選択します。",
|
||||
"schedule_send_description": "送信する日時を選んでください。",
|
||||
"schedule_preset": {
|
||||
"this_morning": "今朝",
|
||||
"this_afternoon": "今日の午後",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "リンクを編集",
|
||||
"remove_link": "リンクを解除",
|
||||
"link_address": "リンク先のアドレス",
|
||||
"link_text": "テキスト",
|
||||
"link_invalid": "アドレスに空白は使えません",
|
||||
"table": "表",
|
||||
"clear_formatting": "書式をクリア",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "送信中…",
|
||||
"sent": "送信しました",
|
||||
"view": "表示",
|
||||
"template_saved": "テンプレートを保存しました",
|
||||
"schedule_anyway": "そのまま予約",
|
||||
"schedule_warnings_title": "このメッセージをそのまま予約しますか?",
|
||||
"picker_button": "連絡先から追加",
|
||||
"picker_title": "宛先を追加",
|
||||
"tab_contacts": "連絡先",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "先にクラッシュレポートをオンにしてください。",
|
||||
"send_no_url": "アドレスが設定されていないため、送信先がありません。上で入力してください。",
|
||||
"send_failed": "クラッシュレポートを送信できませんでした。"
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "タスクバーのアイコンに未読数を表示",
|
||||
"unread_badge_description": "別のアプリで作業中でも新着メールがわかります。",
|
||||
"tip_compose_keys": "{keys} を押して新しいメッセージを作成",
|
||||
"tip_palette_keys": "{keys} を押してコマンドを検索"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "계정 선택",
|
||||
"select_mailto_account": "이 이메일 링크를 열 계정을 선택하세요.",
|
||||
"select_webcal_account": "이 캘린더 링크를 열 계정을 선택하세요.",
|
||||
"select_account_note": "이 선택은 이 프로토콜 링크에만 적용됩니다.",
|
||||
"detail_to": "받는 사람",
|
||||
"detail_subject": "제목",
|
||||
"detail_no_subject": "제목 없음",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "URL을 입력하세요",
|
||||
"sending": "전송 중...",
|
||||
"schedule_send": "보내기 예약",
|
||||
"schedule_send_description": "서버가 이 메시지를 보낼 시간을 선택하세요.",
|
||||
"schedule_send_description": "보낼 시간을 고르세요.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "오늘 아침",
|
||||
"this_afternoon": "오늘 오후",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "링크 편집",
|
||||
"remove_link": "링크 제거",
|
||||
"link_address": "링크 주소",
|
||||
"link_text": "텍스트",
|
||||
"link_invalid": "주소에 공백을 넣을 수 없어요",
|
||||
"table": "표",
|
||||
"clear_formatting": "서식 지우기",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "보내는 중…",
|
||||
"sent": "보냈어요",
|
||||
"view": "보기",
|
||||
"template_saved": "템플릿을 저장했습니다",
|
||||
"schedule_anyway": "그래도 예약하기",
|
||||
"schedule_warnings_title": "이 메일을 그래도 예약할까요?",
|
||||
"picker_button": "연락처에서 추가",
|
||||
"picker_title": "받는 사람 추가",
|
||||
"tab_contacts": "연락처",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "먼저 충돌 보고서를 켜세요.",
|
||||
"send_no_url": "주소가 설정되지 않아 보낼 곳이 없습니다. 위에 입력하세요.",
|
||||
"send_failed": "충돌 보고서를 보낼 수 없습니다."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "작업 표시줄 아이콘에 읽지 않은 수 표시",
|
||||
"unread_badge_description": "다른 앱에서 작업하는 동안에도 새 메일을 확인할 수 있습니다.",
|
||||
"tip_compose_keys": "{keys}를 눌러 새 메시지 쓰기",
|
||||
"tip_palette_keys": "{keys}를 눌러 명령 찾기"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Izvēlieties kontu",
|
||||
"select_mailto_account": "Izvēlieties, kurā kontā atvērt šo e-pasta saiti.",
|
||||
"select_webcal_account": "Izvēlieties, kurā kontā atvērt šo kalendāra saiti.",
|
||||
"select_account_note": "Šī izvēle attiecas tikai uz šo protokola saiti.",
|
||||
"detail_to": "Kam",
|
||||
"detail_subject": "Temats",
|
||||
"detail_no_subject": "Bez temata",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "Ievadiet URL",
|
||||
"sending": "Sūta...",
|
||||
"schedule_send": "Ieplānot sūtīšanu",
|
||||
"schedule_send_description": "Izvēlieties, kad serverim jānosūta šis ziņojums.",
|
||||
"schedule_send_description": "Izvēlieties, kad to nosūtīt.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Šorīt",
|
||||
"this_afternoon": "Šodien pēcpusdienā",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Rediģēt saiti",
|
||||
"remove_link": "Noņemt saiti",
|
||||
"link_address": "Saites adrese",
|
||||
"link_text": "Teksts",
|
||||
"link_invalid": "Adresē nedrīkst būt atstarpju",
|
||||
"table": "Tabula",
|
||||
"clear_formatting": "Notīrīt formatējumu",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Sūta…",
|
||||
"sent": "Nosūtīts",
|
||||
"view": "Skatīt",
|
||||
"template_saved": "Veidne saglabāta",
|
||||
"schedule_anyway": "Ieplānot tik un tā",
|
||||
"schedule_warnings_title": "Vai tomēr ieplānot šo vēstuli?",
|
||||
"picker_button": "Pievienot no kontaktiem",
|
||||
"picker_title": "Pievienot saņēmējus",
|
||||
"tab_contacts": "Kontakti",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Vispirms ieslēdziet avāriju atskaites.",
|
||||
"send_no_url": "Adrese nav iestatīta, tāpēc to nav kur sūtīt. Ievadiet to augstāk.",
|
||||
"send_failed": "Avārijas atskaiti neizdevās nosūtīt."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Nelasīto skaits uzdevumjoslas ikonā",
|
||||
"unread_badge_description": "Lai redzētu jaunu pastu, kamēr strādājat citā lietotnē.",
|
||||
"tip_compose_keys": "Nospiediet {keys}, lai rakstītu jaunu ziņojumu",
|
||||
"tip_palette_keys": "Nospiediet {keys}, lai atrastu jebkuru komandu"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Бүртгэл сонгох",
|
||||
"select_mailto_account": "Энэ имэйлийн холбоосыг аль бүртгэлээр нээхийг сонгоно уу.",
|
||||
"select_webcal_account": "Энэ хуанлийн холбоосыг аль данс нээхийг сонгоно уу.",
|
||||
"select_account_note": "Энэ нь зөвхөн энэ протоколын холбоост хамаарна.",
|
||||
"detail_to": "Хэнд",
|
||||
"detail_subject": "Сэдэв",
|
||||
"detail_no_subject": "Сэдэв байхгүй",
|
||||
@@ -1081,7 +1080,7 @@
|
||||
"recipients_partly_refused": "Сервер дараах хүлээн авагчдыг татгалзлаа: {recipients}. Бусад хүлээн авагчид мессежийг хүлээн авна.",
|
||||
"send_offline": "Та офлайн байх шиг байна. Мессеж энд хадгалагдсан тул онлайн болмогц дахин илгээнэ үү.",
|
||||
"formatting": "Формат",
|
||||
"send_recipients_rejected": "Илгээгдсэнгүй - сервер бүх хүлээн авагчийг татгалзсан.",
|
||||
"send_recipients_rejected": "Илгээгдсэнгүй: сервер бүх хүлээн авагчийг татгалзсан.",
|
||||
"send_plugin_failed": "Илгээгдсэнгүй - илгээлтийг гүйцэтгэдэг залгаас алдаа гаргасан эсвэл хэт удсан. Дахин илгээхээсээ өмнө Илгээсэн хавтсаа шалгана уу.",
|
||||
"send_some_recipients_rejected": "Илгээсэн, гэхдээ эдгээр хүлээн авагчид хүрэхгүй - сервер тэднийг татгалзсан.",
|
||||
"continue_draft": "Ноорог үргэлжлүүлэх",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "Хуваарь илгээх",
|
||||
"schedule_send_description": "Сервер энэ мессежийг хэзээ гаргахыг сонгоно уу.",
|
||||
"schedule_send_description": "Хэзээ илгээхээ сонгоно уу.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Өнөө өглөө",
|
||||
"this_afternoon": "Өнөөдөр үдээс хойш",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Холбоос засах",
|
||||
"remove_link": "Холбоос хасах",
|
||||
"link_address": "Холбоосын хаяг",
|
||||
"link_text": "Текст",
|
||||
"link_invalid": "Хаяг хоосон зай агуулж болохгүй",
|
||||
"table": "Хүснэгт",
|
||||
"clear_formatting": "Форматыг арилгах",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Илгээж байна…",
|
||||
"sent": "Илгээгдлээ",
|
||||
"view": "Харах",
|
||||
"template_saved": "Загвар хадгалагдлаа",
|
||||
"schedule_anyway": "Ямар ч байсан хуваарьт оруулах",
|
||||
"schedule_warnings_title": "Энэ захиаг ямар ч байсан хуваарьт оруулах уу?",
|
||||
"picker_button": "Харилцагчдаас нэмэх",
|
||||
"picker_title": "Хүлээн авагч нэмэх",
|
||||
"tab_contacts": "Харилцагчид",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Эхлээд гэмтлийн тайланг асаана уу.",
|
||||
"send_no_url": "Хаяг тохируулаагүй тул илгээх газаргүй. Дээр нэгийг оруулна уу.",
|
||||
"send_failed": "Гэмтлийн тайланг илгээж чадсангүй."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Ажлын самбарын дүрс дээрх уншаагүй тоо",
|
||||
"unread_badge_description": "Өөр апп дээр ажиллаж байхдаа шинэ шуудангаа харахын тулд.",
|
||||
"tip_compose_keys": "Шинэ зурвас бичихийн тулд {keys} дарна уу",
|
||||
"tip_palette_keys": "Ямар ч тушаал олохын тулд {keys} дарна уу"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Velg konto",
|
||||
"select_mailto_account": "Velg hvilken konto som skal åpne denne e-postlenken.",
|
||||
"select_webcal_account": "Velg hvilken konto som skal åpne denne kalenderlenken.",
|
||||
"select_account_note": "Dette gjelder bare denne lenken.",
|
||||
"detail_to": "Til",
|
||||
"detail_subject": "Emne",
|
||||
"detail_no_subject": "Uten emne",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "Planlegg sending",
|
||||
"schedule_send_description": "Velg når serveren skal sende denne meldingen.",
|
||||
"schedule_send_description": "Velg når den skal sendes.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "I dag tidlig",
|
||||
"this_afternoon": "I ettermiddag",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Rediger lenke",
|
||||
"remove_link": "Fjern lenke",
|
||||
"link_address": "Lenkeadresse",
|
||||
"link_text": "Tekst",
|
||||
"link_invalid": "Adressen kan ikke inneholde mellomrom",
|
||||
"table": "Tabell",
|
||||
"clear_formatting": "Fjern formatering",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Sender…",
|
||||
"sent": "Sendt",
|
||||
"view": "Vis",
|
||||
"template_saved": "Mal lagret",
|
||||
"schedule_anyway": "Planlegg likevel",
|
||||
"schedule_warnings_title": "Planlegge denne meldingen likevel?",
|
||||
"picker_button": "Legg til fra kontakter",
|
||||
"picker_title": "Legg til mottakere",
|
||||
"tab_contacts": "Kontakter",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Slå på krasjrapporter først.",
|
||||
"send_no_url": "Ingen adresse er angitt, så den kan ikke sendes noe sted. Skriv inn en ovenfor.",
|
||||
"send_failed": "Krasjrapporten kunne ikke sendes."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Antall uleste på ikonet på oppgavelinjen",
|
||||
"unread_badge_description": "Slik ser du ny e-post mens du jobber i en annen app.",
|
||||
"tip_compose_keys": "Trykk på {keys} for å skrive en ny melding",
|
||||
"tip_palette_keys": "Trykk på {keys} for å finne en kommando"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Account kiezen",
|
||||
"select_mailto_account": "Kies welk account deze e-maillink moet openen.",
|
||||
"select_webcal_account": "Kies welk account deze kalenderlink moet openen.",
|
||||
"select_account_note": "Deze keuze geldt alleen voor deze protocol-link.",
|
||||
"detail_to": "Aan",
|
||||
"detail_subject": "Onderwerp",
|
||||
"detail_no_subject": "Geen onderwerp",
|
||||
@@ -1085,7 +1084,7 @@
|
||||
"recipients_partly_refused": "De server heeft {recipients} geweigerd. De andere ontvangers krijgen het bericht.",
|
||||
"send_offline": "Je lijkt offline te zijn. Het bericht staat er nog; verstuur het opnieuw zodra je weer online bent.",
|
||||
"formatting": "Opmaak",
|
||||
"send_recipients_rejected": "Niet verzonden - de server heeft alle ontvangers geweigerd.",
|
||||
"send_recipients_rejected": "Niet verzonden: de server heeft alle ontvangers geweigerd.",
|
||||
"send_plugin_failed": "Niet verzonden - een plug-in die het verzenden afhandelt, is mislukt of duurde te lang. Controleer je map Verzonden voordat je opnieuw verzendt.",
|
||||
"send_some_recipients_rejected": "Verzonden, maar niet naar deze ontvangers - de server heeft ze geweigerd.",
|
||||
"continue_draft": "Concept voortzetten",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "Voer de URL in",
|
||||
"sending": "Bezig met verzenden...",
|
||||
"schedule_send": "Verzenden plannen",
|
||||
"schedule_send_description": "Kies wanneer de server dit bericht moet vrijgeven.",
|
||||
"schedule_send_description": "Kies wanneer het verzonden wordt.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Vanochtend",
|
||||
"this_afternoon": "Vanmiddag",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Link bewerken",
|
||||
"remove_link": "Link verwijderen",
|
||||
"link_address": "Linkadres",
|
||||
"link_text": "Tekst",
|
||||
"link_invalid": "Het adres mag geen spaties bevatten",
|
||||
"table": "Tabel",
|
||||
"clear_formatting": "Opmaak wissen",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Bezig met verzenden…",
|
||||
"sent": "Verzonden",
|
||||
"view": "Bekijken",
|
||||
"template_saved": "Sjabloon opgeslagen",
|
||||
"schedule_anyway": "Toch plannen",
|
||||
"schedule_warnings_title": "Dit bericht toch plannen?",
|
||||
"picker_button": "Toevoegen uit contacten",
|
||||
"picker_title": "Ontvangers toevoegen",
|
||||
"tab_contacts": "Contacten",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Zet eerst crashrapporten aan.",
|
||||
"send_no_url": "Er is geen adres ingesteld, dus dit kan nergens heen. Vul hierboven een adres in.",
|
||||
"send_failed": "Het crashrapport kon niet worden verzonden."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Aantal ongelezen op het taakbalkpictogram",
|
||||
"unread_badge_description": "Zo ziet u nieuwe mail terwijl u in een andere app werkt.",
|
||||
"tip_compose_keys": "Druk op {keys} om een nieuw bericht te schrijven",
|
||||
"tip_palette_keys": "Druk op {keys} om elke opdracht te vinden"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Wybierz konto",
|
||||
"select_mailto_account": "Wybierz konto, które ma otworzyć ten link e-mail.",
|
||||
"select_webcal_account": "Wybierz konto, które ma otworzyć ten link kalendarza.",
|
||||
"select_account_note": "Ten wybór dotyczy tylko tego linku protokołu.",
|
||||
"detail_to": "Do",
|
||||
"detail_subject": "Temat",
|
||||
"detail_no_subject": "Bez tematu",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "Wprowadź adres URL",
|
||||
"sending": "Wysyłanie...",
|
||||
"schedule_send": "Zaplanuj wysyłkę",
|
||||
"schedule_send_description": "Wybierz, kiedy serwer ma wysłać tę wiadomość.",
|
||||
"schedule_send_description": "Wybierz, kiedy ją wysłać.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Dziś rano",
|
||||
"this_afternoon": "Dziś po południu",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Edytuj link",
|
||||
"remove_link": "Usuń link",
|
||||
"link_address": "Adres linku",
|
||||
"link_text": "Tekst",
|
||||
"link_invalid": "Adres nie może zawierać spacji",
|
||||
"table": "Tabela",
|
||||
"clear_formatting": "Wyczyść formatowanie",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Wysyłanie…",
|
||||
"sent": "Wysłano",
|
||||
"view": "Pokaż",
|
||||
"template_saved": "Szablon zapisany",
|
||||
"schedule_anyway": "Zaplanuj mimo to",
|
||||
"schedule_warnings_title": "Zaplanować tę wiadomość mimo to?",
|
||||
"picker_button": "Dodaj z kontaktów",
|
||||
"picker_title": "Dodaj odbiorców",
|
||||
"tab_contacts": "Kontakty",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Najpierw włącz raporty o awariach.",
|
||||
"send_no_url": "Nie ustawiono adresu, więc nie ma dokąd tego wysłać. Wpisz go powyżej.",
|
||||
"send_failed": "Nie udało się wysłać raportu o awarii."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Liczba nieprzeczytanych na ikonie na pasku zadań",
|
||||
"unread_badge_description": "Aby widzieć nową pocztę, gdy pracujesz w innej aplikacji.",
|
||||
"tip_compose_keys": "Naciśnij {keys}, aby napisać nową wiadomość",
|
||||
"tip_palette_keys": "Naciśnij {keys}, aby znaleźć dowolne polecenie"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Escolher conta",
|
||||
"select_mailto_account": "Escolha qual conta deve abrir este link de e-mail.",
|
||||
"select_webcal_account": "Escolha qual conta deve abrir este link de calendário.",
|
||||
"select_account_note": "Esta escolha se aplica apenas a este link de protocolo.",
|
||||
"detail_to": "Para",
|
||||
"detail_subject": "Assunto",
|
||||
"detail_no_subject": "Sem assunto",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"link_url_prompt": "Insira a URL",
|
||||
"sending": "Enviando...",
|
||||
"schedule_send": "Agendar envio",
|
||||
"schedule_send_description": "Escolha quando o servidor deve liberar esta mensagem.",
|
||||
"schedule_send_description": "Escolha quando enviar.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Esta manhã",
|
||||
"this_afternoon": "Esta tarde",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Editar link",
|
||||
"remove_link": "Remover link",
|
||||
"link_address": "Endereço do link",
|
||||
"link_text": "Texto",
|
||||
"link_invalid": "O endereço não pode conter espaços",
|
||||
"table": "Tabela",
|
||||
"clear_formatting": "Limpar formatação",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Enviando…",
|
||||
"sent": "Enviado",
|
||||
"view": "Ver",
|
||||
"template_saved": "Modelo guardado",
|
||||
"schedule_anyway": "Agendar mesmo assim",
|
||||
"schedule_warnings_title": "Agendar esta mensagem mesmo assim?",
|
||||
"picker_button": "Adicionar dos contatos",
|
||||
"picker_title": "Adicionar destinatários",
|
||||
"tab_contacts": "Contatos",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Ligue primeiro os relatórios de falhas.",
|
||||
"send_no_url": "Não há nenhum endereço definido, por isso não há para onde enviar. Introduza um acima.",
|
||||
"send_failed": "Não foi possível enviar o relatório de falha."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Número de não lidas no ícone da barra de tarefas",
|
||||
"unread_badge_description": "Para ver novos e-mails enquanto trabalha noutra aplicação.",
|
||||
"tip_compose_keys": "Prima {keys} para escrever uma nova mensagem",
|
||||
"tip_palette_keys": "Prima {keys} para encontrar qualquer comando"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -245,7 +245,6 @@
|
||||
"select_account_title": "Alegeți contul",
|
||||
"select_mailto_account": "Alegeți contul în care să se deschidă acest link de e-mail.",
|
||||
"select_webcal_account": "Alegeți contul care ar trebui să deschidă acest link către calendar.",
|
||||
"select_account_note": "Aceasta se aplică numai acestui link de protocol.",
|
||||
"detail_to": "Către",
|
||||
"detail_subject": "Subiect",
|
||||
"detail_no_subject": "Fără subiect",
|
||||
@@ -1081,7 +1080,7 @@
|
||||
"recipients_partly_refused": "Serverul a respins {recipients}. Ceilalți destinatari primesc mesajul.",
|
||||
"send_offline": "Se pare că ești offline. Mesajul este încă aici; trimite-l din nou când revii online.",
|
||||
"formatting": "Formatare",
|
||||
"send_recipients_rejected": "Netrimis - serverul a respins toți destinatarii.",
|
||||
"send_recipients_rejected": "Netrimis: serverul a respins toți destinatarii.",
|
||||
"send_plugin_failed": "Nu a fost trimis - un plugin care se ocupă de trimitere a eșuat sau a durat prea mult. Verifică dosarul Trimise înainte de a trimite din nou.",
|
||||
"send_some_recipients_rejected": "Trimis, dar nu și acestor destinatari - serverul i-a respins.",
|
||||
"continue_draft": "Continuă schița",
|
||||
@@ -1124,7 +1123,7 @@
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "Programează trimiterea",
|
||||
"schedule_send_description": "Alegeți momentul în care serverul ar trebui să transmită acest mesaj.",
|
||||
"schedule_send_description": "Alegeți când să fie trimis.",
|
||||
"schedule_preset": {
|
||||
"this_morning": "Azi dimineață",
|
||||
"this_afternoon": "Azi după-amiază",
|
||||
@@ -1175,6 +1174,7 @@
|
||||
"edit_link": "Editează linkul",
|
||||
"remove_link": "Elimină linkul",
|
||||
"link_address": "Adresa linkului",
|
||||
"link_text": "Text",
|
||||
"link_invalid": "Adresa nu poate conține spații",
|
||||
"table": "Tabel",
|
||||
"clear_formatting": "Șterge formatarea",
|
||||
@@ -4082,6 +4082,9 @@
|
||||
"sending": "Se trimite…",
|
||||
"sent": "Trimis",
|
||||
"view": "Vezi",
|
||||
"template_saved": "Șablon salvat",
|
||||
"schedule_anyway": "Programează oricum",
|
||||
"schedule_warnings_title": "Programați totuși acest mesaj?",
|
||||
"picker_button": "Adaugă din contacte",
|
||||
"picker_title": "Adaugă destinatari",
|
||||
"tab_contacts": "Contacte",
|
||||
@@ -7624,5 +7627,11 @@
|
||||
"send_no_consent": "Porniți mai întâi rapoartele de blocare.",
|
||||
"send_no_url": "Nu este setată nicio adresă, deci nu există unde să fie trimis. Introduceți una mai sus.",
|
||||
"send_failed": "Raportul de blocare nu a putut fi trimis."
|
||||
},
|
||||
"uxp2_desktop": {
|
||||
"unread_badge_label": "Numărul de necitite pe pictograma din bara de activități",
|
||||
"unread_badge_description": "Ca să vedeți e-mailurile noi în timp ce lucrați în altă aplicație.",
|
||||
"tip_compose_keys": "Apăsați {keys} pentru a scrie un mesaj nou",
|
||||
"tip_palette_keys": "Apăsați {keys} pentru a găsi orice comandă"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user