diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx index f71f24a0d..437bb596a 100644 --- a/components/email/__tests__/composer-a11y.test.tsx +++ b/components/email/__tests__/composer-a11y.test.tsx @@ -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(); + expect(screen.getByRole('combobox', { name: 'to:' })).toHaveClass('focus-inset'); + }); + it('labels the subject field', () => { render(); 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(); + 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(); const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem'); diff --git a/components/email/__tests__/composer-attachment-list-scroll.test.tsx b/components/email/__tests__/composer-attachment-list-scroll.test.tsx index 04bc59023..c4fe570c2 100644 --- a/components/email/__tests__/composer-attachment-list-scroll.test.tsx +++ b/components/email/__tests__/composer-attachment-list-scroll.test.tsx @@ -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(); + + 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(); diff --git a/components/email/__tests__/composer-discard-uploads.test.tsx b/components/email/__tests__/composer-discard-uploads.test.tsx index 36cfc0199..cf1960591 100644 --- a/components/email/__tests__/composer-discard-uploads.test.tsx +++ b/components/email/__tests__/composer-discard-uploads.test.tsx @@ -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')); diff --git a/components/email/__tests__/composer-discard.test.tsx b/components/email/__tests__/composer-discard.test.tsx index 4df7b6d90..bc90773ee 100644 --- a/components/email/__tests__/composer-discard.test.tsx +++ b/components/email/__tests__/composer-discard.test.tsx @@ -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(); 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( {}} />); + + 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( {}} />); + + 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(); diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 817a277ad..66357fe00 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -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(); + + 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', () => { diff --git a/components/email/__tests__/composer-reply-focus.test.tsx b/components/email/__tests__/composer-reply-focus.test.tsx index 62bb0752e..8ac680ecf 100644 --- a/components/email/__tests__/composer-reply-focus.test.tsx +++ b/components/email/__tests__/composer-reply-focus.test.tsx @@ -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()), + 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(); + 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(); + fireEvent.mouseDown(emptySpace(), { button: 0 }); + expect(editorFocus).toHaveBeenCalledWith('end'); + }); + + it('leaves clicks on buttons alone', () => { + caret.textEnd = 7; + render(); + const button = document.createElement('button'); + emptySpace().appendChild(button); + fireEvent.mouseDown(button, { button: 0 }); + expect(caret.selected).toEqual([]); + }); +}); diff --git a/components/email/__tests__/composer-template-reply.test.tsx b/components/email/__tests__/composer-template-reply.test.tsx index d2d5abc3e..bcb2f5b92 100644 --- a/components/email/__tests__/composer-template-reply.test.tsx +++ b/components/email/__tests__/composer-template-reply.test.tsx @@ -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(); diff --git a/components/email/__tests__/composer-windows-layout.test.tsx b/components/email/__tests__/composer-windows-layout.test.tsx index a5a5dd5a4..9c03feea7 100644 --- a/components/email/__tests__/composer-windows-layout.test.tsx +++ b/components/email/__tests__/composer-windows-layout.test.tsx @@ -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'); diff --git a/components/email/__tests__/reply-quote-collapse.test.ts b/components/email/__tests__/reply-quote-collapse.test.ts index 86c22986c..eeb00d1a8 100644 --- a/components/email/__tests__/reply-quote-collapse.test.ts +++ b/components/email/__tests__/reply-quote-collapse.test.ts @@ -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('
On Monday, Bob wrote:
'); const ORIGINAL = '

Original message

Earlier reply
'; @@ -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(`

My response

${HEADER}${QUOTE}

Postscript

`); + 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('My signature'); + const e = mount(`

My response

${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('

Just text

'); + expect(userTextEnd(e.state)).toBeNull(); + }); +}); diff --git a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx index c15a51ce4..23d7ac5a2 100644 --- a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx +++ b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx @@ -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('docs 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('

See it

'); + 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]*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('

See it

'); + 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
]*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('

docs here

'); + 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(/]*href="https:\/\/example\.com"[^>]*>the docs<\/a> here/); }); it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => { diff --git a/components/email/__tests__/thread-conversation-drafts.test.tsx b/components/email/__tests__/thread-conversation-drafts.test.tsx new file mode 100644 index 000000000..80bc7f7ed --- /dev/null +++ b/components/email/__tests__/thread-conversation-drafts.test.tsx @@ -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) { + render( + {}} + 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' })); + }); +}); diff --git a/components/email/__tests__/viewer-quick-reply-words.test.tsx b/components/email/__tests__/viewer-quick-reply-words.test.tsx index f980a7c93..2262a4ba1 100644 --- a/components/email/__tests__/viewer-quick-reply-words.test.tsx +++ b/components/email/__tests__/viewer-quick-reply-words.test.tsx @@ -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(); + 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(); + }); +}); diff --git a/components/email/__tests__/viewer-reply-draft.test.tsx b/components/email/__tests__/viewer-reply-draft.test.tsx new file mode 100644 index 000000000..b3dd05f39 --- /dev/null +++ b/components/email/__tests__/viewer-reply-draft.test.tsx @@ -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) => + 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(); + 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(); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index bbcff311d..8c0637bfd 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -70,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 { @@ -412,6 +412,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 @@ -445,6 +450,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; @@ -607,6 +614,7 @@ export function EmailComposer({ requestCloseRef, onDiscardDraft, onSaveState, + onRestoreDiscarded, popOut, popOutToMainLabel, ownWindow, @@ -1260,7 +1268,9 @@ export function EmailComposer({ const [previewAttachment, setPreviewAttachment] = useState(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); @@ -1456,6 +1466,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(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) => { + 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 @@ -1546,9 +1569,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 @@ -1843,9 +1868,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['add']>[0]) => personalTemplatesFor(composingAccountId).add(data), - [composingAccountId], + (data: Parameters['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); @@ -3837,10 +3866,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; @@ -3987,10 +4018,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 @@ -4475,6 +4510,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. @@ -4688,7 +4725,9 @@ export function EmailComposer({ ); - 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; @@ -4706,7 +4745,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, @@ -4742,6 +4781,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(); @@ -4750,7 +4794,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 @@ -4952,7 +5011,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} @@ -5505,7 +5565,7 @@ export function EmailComposer({ -
+
{wantsPluginDraft && ( )} @@ -5637,12 +5697,13 @@ export function EmailComposer({

)}
{attachmentsForDisplay(attachments).map(({ att, index }) => { @@ -6145,7 +6206,8 @@ export function EmailComposer({ 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} @@ -6156,6 +6218,7 @@ export function EmailComposer({ { const first = sendWarnings?.items[0]; setSendWarnings(null); @@ -6173,7 +6236,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; @@ -6188,7 +6251,8 @@ export function EmailComposer({ } } setSendWarnings(null); - handleSend({ warningsConfirmed: true, delayedUntil }); + if (schedule) setShowScheduleDialog(true); + else handleSend({ warningsConfirmed: true, delayedUntil }); }} /> @@ -6204,7 +6268,7 @@ export function EmailComposer({ - {closeDialogKind === 'close' && ( @@ -6817,6 +6881,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" @@ -6933,7 +7005,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" diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 80ffc1c4c..91e56c5da 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -304,6 +304,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. */ @@ -1376,6 +1381,7 @@ export function EmailViewer({ onReplyAllConversationEmail, mvInlineComposer, replyComposerOpen, + replyDraft, onEditDraft, onCancelScheduledForEdit, onCancelScheduledSend, @@ -1400,6 +1406,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'); @@ -3514,8 +3521,25 @@ export function EmailViewer({ const latestSrcDocRef = useRef(null); const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc; // Not on spam: a reply confirms the address to the sender. - 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(null); // Read aloud (browser speech): the body as shown, HTML frame or plain text. @@ -6190,8 +6214,31 @@ ${printWatermark ? printWatermark.html : ''}
)} + {/* The reply already started on this message, as Gmail shows it. */} + {showReplyDraft && replyDraft && ( +
+
+ + + {tList('draft')} + + {replyDraft.preview} + + + + +
+
+ )} + {/* Quick Reply Section - hidden for drafts and while loading a new email */} - {showQuickReply && (
+ {showQuickReply && (
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(null); + const hrefRef = useRef(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 ( + + {onRemove && ( + + )} + + + + } + > +
+
+ + setText(e.target.value)} /> +
+
+ + 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 && ( + + )} +
+
+
+ ); +} diff --git a/components/email/reply-quote-collapse.ts b/components/email/reply-quote-collapse.ts index 5dbd658f2..b97485888 100644 --- a/components/email/reply-quote-collapse.ts +++ b/components/email/reply-quote-collapse.ts @@ -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(); + 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(); const decorations: Decoration[] = []; diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 952a9da30..e0a9720a3 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -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(null); const [colorMenuOpen, setColorMenuOpen] = useState(false); @@ -1465,16 +1482,13 @@ export function RichTextEditor({ document.body )} - 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} /> 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(["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 = (
    {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 ( 0} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index eb6c91992..d949f8b25 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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, @@ -219,12 +226,12 @@ export function ThreadConversationView({ if (emails.length > 0) { const idsToExpand = new Set(); - // Always expand most recent - idsToExpand.add(emails[0].id); + // Always expand the most recent message; a draft isn't one. + idsToExpand.add((emails.find((e) => !e.keywords?.$draft) ?? emails[0]).id); // Also expand all unread emails emails.forEach(email => { - if (!email.keywords?.$seen) { + if (!email.keywords?.$seen && !email.keywords?.$draft) { idsToExpand.add(email.id); } }); @@ -280,7 +287,9 @@ export function ThreadConversationView({
    - {emails.map((email, index) => ( + {emails.map((email, index) => { + const draft = !!email.keywords?.$draft; + return ( 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} /> - ))} + ); + })}
@@ -317,6 +330,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; @@ -355,6 +371,9 @@ function EmailCard({ onReply, onReplyAll, onForward, + isDraft = false, + onEditDraft, + onDiscardDraft, onDownloadAttachment, blobSource, onMarkAsRead, @@ -410,6 +429,11 @@ function EmailCard({ + {isDraft && ( + + {t('email_list.draft')} + + )} {email.isScheduled && ( @@ -475,6 +499,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. */}
+ {onEditDraft && ( + + )} + {onDiscardDraft && ( + + )} {onReply && ( , +})); +// 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) => void; + onSaveState: (data: Record) => 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
stub composer
; + }, + }; +}); +vi.mock('@/components/search/mv-search-form', () => ({ + MvSearchForm: ({ onSearch }: { onSearch: () => void }) => , +})); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ onReply, onReplyAll, onForward }: { onReply: () => void; onReplyAll: () => void; onForward: () => void }) => ( +
+ message viewer + + + +
+ ), +})); +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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']); + }); +}); diff --git a/components/mail/__tests__/mail-app-drafts.test.tsx b/components/mail/__tests__/mail-app-drafts.test.tsx new file mode 100644 index 000000000..980410488 --- /dev/null +++ b/components/mail/__tests__/mail-app-drafts.test.tsx @@ -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 }) => ( +
stub composer
+ ), +})); +vi.mock('@/components/search/mv-search-form', () => ({ + MvSearchForm: ({ onSearch }: { onSearch: () => void }) => , +})); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ onReply, replyDraft }: { onReply: () => void; replyDraft?: { preview?: string; onEdit: () => void } | null }) => ( +
+ message viewer + + {replyDraft && } +
+ ), +})); +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 }) => ( + + ), +})); + +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 & { getEmail: ReturnType }; +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(); + 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(); + expect(await screen.findByText('stub reply draft: my answer')).toBeInTheDocument(); + }); + + it('opens that draft on Reply instead of a new reply', async () => { + render(); + 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(); + 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', ''); + }); +}); diff --git a/components/mail/__tests__/undo-send-toast.test.tsx b/components/mail/__tests__/undo-send-toast.test.tsx index 92d7e83e3..d3c87a094 100644 --- a/components/mail/__tests__/undo-send-toast.test.tsx +++ b/components/mail/__tests__/undo-send-toast.test.tsx @@ -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(); + 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 () => { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index ca0f59b53..905710938 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -43,9 +43,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"; @@ -305,7 +306,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(null); + const [dockedSession, setDockedSession] = useState(null); + // A reply draft opened again from its message (compose-p2-6): it docks too. + const [resumedReplySession, setResumedReplySession] = useState(null); const showComposerRef = useRef(showComposer); showComposerRef.current = showComposer; // Fields a plugin filled in through onBeforeCompose for the composer about @@ -320,6 +323,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((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(); @@ -358,6 +370,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { threadId: string; emailId: string; startedAt: number; promise: Promise; } | 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([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null); @@ -1177,12 +1196,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; @@ -2859,11 +2873,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 => { + 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); @@ -2895,13 +2949,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 @@ -2943,7 +3000,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, @@ -3063,10 +3122,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) ? { @@ -3138,6 +3198,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) { @@ -3156,6 +3233,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); @@ -3245,6 +3325,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); @@ -3291,6 +3372,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 @@ -3359,6 +3441,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, @@ -4750,6 +4833,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)); @@ -4779,6 +4866,62 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message, not on every list refresh }, [mvConversationThreadId, mvConversationRef?.id, mvBlobSource.client, mvBlobSource.accountId, client]); + // 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), @@ -4852,14 +4995,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), @@ -4957,9 +5094,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; const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread) @@ -5189,6 +5327,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'); @@ -5201,6 +5342,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'); @@ -5213,6 +5357,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'); @@ -5310,6 +5455,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { sessionKey={`inline-${composerSessionId}`} 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; @@ -5341,6 +5492,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const outcome = await handleEmailSend(data); setPendingDraft(null); setComposerPrefill(null); + scanReplyDrafts(); return outcome; }} onSendFailed={(state) => { @@ -5371,7 +5523,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} /> @@ -5853,7 +6016,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) => { @@ -5916,12 +6085,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); @@ -5964,12 +6128,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { button no longer sits on the list's rows. */} {!mvShell && (isMobile || isEmbedded) && )} -
+ ); } @@ -324,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) @@ -381,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)) { @@ -389,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; @@ -491,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); @@ -510,6 +543,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: { if (held) return ; return ( + <> + {sending && } +
+
+ ); } diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index 1308ca0bf..dcec88a15 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -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>(); + 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]); diff --git a/components/ui/__tests__/toast-layout.test.tsx b/components/ui/__tests__/toast-layout.test.tsx index 335d03634..7b1e42f30 100644 --- a/components/ui/__tests__/toast-layout.test.tsx +++ b/components/ui/__tests__/toast-layout.test.tsx @@ -43,6 +43,14 @@ describe('toast layout', () => { 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(); + 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', () => { render(); const container = toastContainer(); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 33ebeab9b..8de8acca0 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -475,10 +475,13 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: // 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-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" diff --git a/lib/__tests__/reply-drafts.test.ts b/lib/__tests__/reply-drafts.test.ts new file mode 100644 index 000000000..47454c9a3 --- /dev/null +++ b/lib/__tests__/reply-drafts.test.ts @@ -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 = {}) => ({ + 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: [''] }), 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 = { + 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); + }); +}); diff --git a/lib/compose/__tests__/attachment-order.test.ts b/lib/compose/__tests__/attachment-order.test.ts index d5337caea..de1376bed 100644 --- a/lib/compose/__tests__/attachment-order.test.ts +++ b/lib/compose/__tests__/attachment-order.test.ts @@ -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]); }); }); diff --git a/lib/compose/attachment-order.ts b/lib/compose/attachment-order.ts index 158d340d6..594011a40 100644 --- a/lib/compose/attachment-order.ts +++ b/lib/compose/attachment-order.ts @@ -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( 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); diff --git a/lib/outgoing/__tests__/undo-send-countdown.test.ts b/lib/outgoing/__tests__/undo-send-countdown.test.ts new file mode 100644 index 000000000..cbc39721d --- /dev/null +++ b/lib/outgoing/__tests__/undo-send-countdown.test.ts @@ -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); + }); +}); diff --git a/lib/outgoing/send-progress-toast.ts b/lib/outgoing/send-progress-toast.ts index b94e8753f..e4fda0f13 100644 --- a/lib/outgoing/send-progress-toast.ts +++ b/lib/outgoing/send-progress-toast.ts @@ -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; +} diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts index b249c5bb4..cd7bcb987 100644 --- a/lib/popout/channel.ts +++ b/lib/popout/channel.ts @@ -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 { diff --git a/lib/reply-drafts.ts b/lib/reply-drafts.ts new file mode 100644 index 000000000..08d2d9b33 --- /dev/null +++ b/lib/reply-drafts.ts @@ -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): 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: Pick): 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, b: Pick) => + (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, + getEmail: (id: string) => Promise, +): Promise { + 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 }, + threadId: string, + accountId: string | undefined, +): Promise { + 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); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index f256e1e26..d6d95ffa1 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1111,7 +1111,7 @@ "unfinished": [] }, "schedule_send": "جدولة الإرسال", - "schedule_send_description": "اختر متى يجب على الخادم إرسال هذه الرسالة.", + "schedule_send_description": "اختر متى يتم إرسالها.", "schedule_preset": { "this_morning": "هذا الصباح", "this_afternoon": "بعد ظهر اليوم", @@ -1162,6 +1162,7 @@ "edit_link": "تعديل الرابط", "remove_link": "إزالة الرابط", "link_address": "عنوان الرابط", + "link_text": "النص", "link_invalid": "يجب ألا يحتوي العنوان على مسافات", "table": "جدول", "clear_formatting": "مسح التنسيق", @@ -4056,6 +4057,9 @@ "sending": "جارٍ الإرسال…", "sent": "تم الإرسال", "view": "عرض", + "template_saved": "تم حفظ القالب", + "schedule_anyway": "الجدولة على أي حال", + "schedule_warnings_title": "هل تريد جدولة هذه الرسالة على أي حال؟", "picker_button": "إضافة من جهات الاتصال", "picker_title": "إضافة مستلمين", "tab_contacts": "جهات الاتصال", diff --git a/locales/ca/common.json b/locales/ca/common.json index 77da50f6d..40313f2cb 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 096fc06d7..e36eecc96 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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í", @@ -4056,6 +4057,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", diff --git a/locales/da/common.json b/locales/da/common.json index 362701bff..65ed31a22 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/de/common.json b/locales/de/common.json index 82a20557d..495ad989d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/en/common.json b/locales/en/common.json index fdacbd37a..c7cb60a76 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1113,7 +1113,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", @@ -1164,6 +1164,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", @@ -4056,6 +4057,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", diff --git a/locales/es/common.json b/locales/es/common.json index ca3d0dbee..2db08e8f2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/et/common.json b/locales/et/common.json index e4271a161..179cb1f36 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/fa/common.json b/locales/fa/common.json index b988e84ea..ec1c773f8 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1111,7 +1111,7 @@ "unfinished": [] }, "schedule_send": "زمان‌بندی ارسال", - "schedule_send_description": "زمان انتشار پیام توسط سرور را انتخاب کنید.", + "schedule_send_description": "زمان ارسال را انتخاب کنید.", "schedule_preset": { "this_morning": "امروز صبح", "this_afternoon": "امروز بعدازظهر", @@ -1162,6 +1162,7 @@ "edit_link": "ویرایش پیوند", "remove_link": "حذف پیوند", "link_address": "نشانی پیوند", + "link_text": "متن", "link_invalid": "نشانی نباید فاصله داشته باشد", "table": "جدول", "clear_formatting": "پاک کردن قالب‌بندی", @@ -4056,6 +4057,9 @@ "sending": "در حال ارسال…", "sent": "ارسال شد", "view": "مشاهده", + "template_saved": "الگو ذخیره شد", + "schedule_anyway": "زمان‌بندی به هر حال", + "schedule_warnings_title": "این پیام به هر حال زمان‌بندی شود؟", "picker_button": "افزودن از مخاطبین", "picker_title": "افزودن گیرندگان", "tab_contacts": "مخاطبین", diff --git a/locales/fr/common.json b/locales/fr/common.json index fb24a83b4..f3649581a 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/he/common.json b/locales/he/common.json index 31f84ee84..65429b8ff 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1055,7 +1055,7 @@ "unfinished": [] }, "schedule_send": "תזמן שליחה", - "schedule_send_description": "בחר מתי השרת צריך לשחרר הודעה זו.", + "schedule_send_description": "בחר מתי לשלוח אותה.", "schedule_preset": { "this_morning": "הבוקר", "this_afternoon": "היום אחר הצהריים", @@ -1106,6 +1106,7 @@ "edit_link": "עריכת קישור", "remove_link": "הסרת קישור", "link_address": "כתובת הקישור", + "link_text": "טקסט", "link_invalid": "הכתובת לא יכולה להכיל רווחים", "table": "טבלה", "clear_formatting": "ניקוי עיצוב", @@ -3950,6 +3951,9 @@ "sending": "שולח…", "sent": "נשלח", "view": "הצג", + "template_saved": "התבנית נשמרה", + "schedule_anyway": "תזמן בכל זאת", + "schedule_warnings_title": "לתזמן את ההודעה בכל זאת?", "picker_button": "הוסף מאנשי הקשר", "picker_title": "הוספת נמענים", "tab_contacts": "אנשי קשר", diff --git a/locales/hu/common.json b/locales/hu/common.json index 1ec1ffbd2..b74427804 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/it/common.json b/locales/it/common.json index c49c320a6..f597b1026 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/ja/common.json b/locales/ja/common.json index aa6bd1a99..5f53a1a75 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1111,7 +1111,7 @@ "link_url_prompt": "URLを入力してください", "sending": "送信中...", "schedule_send": "送信を予約", - "schedule_send_description": "サーバーがこのメッセージを送信する日時を選択します。", + "schedule_send_description": "送信する日時を選んでください。", "schedule_preset": { "this_morning": "今朝", "this_afternoon": "今日の午後", @@ -1162,6 +1162,7 @@ "edit_link": "リンクを編集", "remove_link": "リンクを解除", "link_address": "リンク先のアドレス", + "link_text": "テキスト", "link_invalid": "アドレスに空白は使えません", "table": "表", "clear_formatting": "書式をクリア", @@ -4056,6 +4057,9 @@ "sending": "送信中…", "sent": "送信しました", "view": "表示", + "template_saved": "テンプレートを保存しました", + "schedule_anyway": "そのまま予約", + "schedule_warnings_title": "このメッセージをそのまま予約しますか?", "picker_button": "連絡先から追加", "picker_title": "宛先を追加", "tab_contacts": "連絡先", diff --git a/locales/ko/common.json b/locales/ko/common.json index 5afe4d566..59abe35aa 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1111,7 +1111,7 @@ "link_url_prompt": "URL을 입력하세요", "sending": "전송 중...", "schedule_send": "보내기 예약", - "schedule_send_description": "서버가 이 메시지를 보낼 시간을 선택하세요.", + "schedule_send_description": "보낼 시간을 고르세요.", "schedule_preset": { "this_morning": "오늘 아침", "this_afternoon": "오늘 오후", @@ -1162,6 +1162,7 @@ "edit_link": "링크 편집", "remove_link": "링크 제거", "link_address": "링크 주소", + "link_text": "텍스트", "link_invalid": "주소에 공백을 넣을 수 없어요", "table": "표", "clear_formatting": "서식 지우기", @@ -4056,6 +4057,9 @@ "sending": "보내는 중…", "sent": "보냈어요", "view": "보기", + "template_saved": "템플릿을 저장했습니다", + "schedule_anyway": "그래도 예약하기", + "schedule_warnings_title": "이 메일을 그래도 예약할까요?", "picker_button": "연락처에서 추가", "picker_title": "받는 사람 추가", "tab_contacts": "연락처", diff --git a/locales/lv/common.json b/locales/lv/common.json index 32cce2034..d6fc62eab 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1111,7 +1111,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ā", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/mn/common.json b/locales/mn/common.json index f98596654..257393fc1 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1111,7 +1111,7 @@ "unfinished": [] }, "schedule_send": "Хуваарь илгээх", - "schedule_send_description": "Сервер энэ мессежийг хэзээ гаргахыг сонгоно уу.", + "schedule_send_description": "Хэзээ илгээхээ сонгоно уу.", "schedule_preset": { "this_morning": "Өнөө өглөө", "this_afternoon": "Өнөөдөр үдээс хойш", @@ -1162,6 +1162,7 @@ "edit_link": "Холбоос засах", "remove_link": "Холбоос хасах", "link_address": "Холбоосын хаяг", + "link_text": "Текст", "link_invalid": "Хаяг хоосон зай агуулж болохгүй", "table": "Хүснэгт", "clear_formatting": "Форматыг арилгах", @@ -4056,6 +4057,9 @@ "sending": "Илгээж байна…", "sent": "Илгээгдлээ", "view": "Харах", + "template_saved": "Загвар хадгалагдлаа", + "schedule_anyway": "Ямар ч байсан хуваарьт оруулах", + "schedule_warnings_title": "Энэ захиаг ямар ч байсан хуваарьт оруулах уу?", "picker_button": "Харилцагчдаас нэмэх", "picker_title": "Хүлээн авагч нэмэх", "tab_contacts": "Харилцагчид", diff --git a/locales/nb/common.json b/locales/nb/common.json index 2beb9225a..1241426f3 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 2001ca069..4b04b6a64 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/pl/common.json b/locales/pl/common.json index fa5c549ca..f9fea5b39 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/pt/common.json b/locales/pt/common.json index f90148149..8aa335306 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1111,7 +1111,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", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 93fa2b0eb..1093040bc 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1111,7 +1111,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ă", @@ -1162,6 +1162,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", @@ -4056,6 +4057,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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 8052d5874..357d7cd8b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1111,7 +1111,7 @@ "link_url_prompt": "Введите URL", "sending": "Отправка...", "schedule_send": "Запланировать отправку", - "schedule_send_description": "Выберите, когда сервер должен отправить это сообщение.", + "schedule_send_description": "Выберите, когда отправить.", "schedule_preset": { "this_morning": "Сегодня утром", "this_afternoon": "Сегодня днём", @@ -1162,6 +1162,7 @@ "edit_link": "Изменить ссылку", "remove_link": "Убрать ссылку", "link_address": "Адрес ссылки", + "link_text": "Текст", "link_invalid": "В адресе не должно быть пробелов", "table": "Таблица", "clear_formatting": "Очистить форматирование", @@ -4056,6 +4057,9 @@ "sending": "Отправка…", "sent": "Отправлено", "view": "Открыть", + "template_saved": "Шаблон сохранён", + "schedule_anyway": "Всё равно запланировать", + "schedule_warnings_title": "Всё равно запланировать это письмо?", "picker_button": "Добавить из контактов", "picker_title": "Добавить получателей", "tab_contacts": "Контакты", diff --git a/locales/sk/common.json b/locales/sk/common.json index 790488e48..f367b0a3f 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1111,7 +1111,7 @@ "unfinished": [] }, "schedule_send": "Naplánovať odoslanie", - "schedule_send_description": "Vyberte, kedy má server túto správu odoslať.", + "schedule_send_description": "Vyberte, kedy sa má odoslať.", "schedule_preset": { "this_morning": "Dnes ráno", "this_afternoon": "Dnes popoludní", @@ -1162,6 +1162,7 @@ "edit_link": "Upraviť odkaz", "remove_link": "Odstrániť odkaz", "link_address": "Adresa odkazu", + "link_text": "Text", "link_invalid": "Adresa nesmie obsahovať medzery", "table": "Tabuľka", "clear_formatting": "Vymazať formátovanie", @@ -4056,6 +4057,9 @@ "sending": "Odosielanie…", "sent": "Odoslané", "view": "Zobraziť", + "template_saved": "Šablóna uložená", + "schedule_anyway": "Naplánovať aj tak", + "schedule_warnings_title": "Naplánovať túto správu aj tak?", "picker_button": "Pridať z kontaktov", "picker_title": "Pridať príjemcov", "tab_contacts": "Kontakty", diff --git a/locales/th/common.json b/locales/th/common.json index 9d4190ec3..82b7c69f6 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1233,6 +1233,9 @@ "sending": "กำลังส่ง…", "sent": "ส่งแล้ว", "view": "ดู", + "template_saved": "บันทึกเทมเพลตแล้ว", + "schedule_anyway": "กำหนดเวลาต่อไป", + "schedule_warnings_title": "กำหนดเวลาส่งข้อความนี้ต่อไปหรือไม่", "picker_button": "เพิ่มจากผู้ติดต่อ", "picker_title": "เพิ่มผู้รับ", "tab_contacts": "ผู้ติดต่อ", @@ -1501,7 +1504,7 @@ "unfinished": [] }, "schedule_send": "กำหนดเวลาส่ง", - "schedule_send_description": "เลือกเวลาที่ต้องการให้เซิร์ฟเวอร์ส่งข้อความนี้", + "schedule_send_description": "เลือกเวลาที่จะส่ง", "schedule_preset": { "this_morning": "เช้านี้", "this_afternoon": "บ่ายนี้", @@ -1552,6 +1555,7 @@ "edit_link": "แก้ไขลิงก์", "remove_link": "นำลิงก์ออก", "link_address": "ที่อยู่ลิงก์", + "link_text": "ข้อความ", "link_invalid": "ที่อยู่ต้องไม่มีช่องว่าง", "table": "ตาราง", "clear_formatting": "ล้างการจัดรูปแบบ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 0d6afff90..09704500b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1111,7 +1111,7 @@ "unfinished": [] }, "schedule_send": "Göndermeyi zamanla", - "schedule_send_description": "Sunucunun bu iletiyi ne zaman göndereceğini seçin.", + "schedule_send_description": "Ne zaman gönderileceğini seçin.", "schedule_preset": { "this_morning": "Bu sabah", "this_afternoon": "Bu öğleden sonra", @@ -1162,6 +1162,7 @@ "edit_link": "Bağlantıyı düzenle", "remove_link": "Bağlantıyı kaldır", "link_address": "Bağlantı adresi", + "link_text": "Metin", "link_invalid": "Adres boşluk içeremez", "table": "Tablo", "clear_formatting": "Biçimlendirmeyi temizle", @@ -4056,6 +4057,9 @@ "sending": "Gönderiliyor…", "sent": "Gönderildi", "view": "Görüntüle", + "template_saved": "Şablon kaydedildi", + "schedule_anyway": "Yine de zamanla", + "schedule_warnings_title": "Bu ileti yine de zamanlansın mı?", "picker_button": "Kişilerden ekle", "picker_title": "Alıcı ekle", "tab_contacts": "Kişiler", diff --git a/locales/uk/common.json b/locales/uk/common.json index b85307979..d08bfd3cc 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1111,7 +1111,7 @@ "link_url_prompt": "Введіть URL", "sending": "Надсилання...", "schedule_send": "Запланувати надсилання", - "schedule_send_description": "Виберіть, коли сервер має надіслати це повідомлення.", + "schedule_send_description": "Виберіть, коли надіслати.", "schedule_preset": { "this_morning": "Сьогодні вранці", "this_afternoon": "Сьогодні вдень", @@ -1162,6 +1162,7 @@ "edit_link": "Змінити посилання", "remove_link": "Прибрати посилання", "link_address": "Адреса посилання", + "link_text": "Текст", "link_invalid": "Адреса не може містити пробілів", "table": "Таблиця", "clear_formatting": "Очистити форматування", @@ -4056,6 +4057,9 @@ "sending": "Надсилання…", "sent": "Надіслано", "view": "Переглянути", + "template_saved": "Шаблон збережено", + "schedule_anyway": "Усе одно запланувати", + "schedule_warnings_title": "Усе одно запланувати цей лист?", "picker_button": "Додати з контактів", "picker_title": "Додати одержувачів", "tab_contacts": "Контакти", diff --git a/locales/vi/common.json b/locales/vi/common.json index e7ecb326e..2bfcf273b 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -1125,7 +1125,7 @@ ] }, "schedule_send": "Lên lịch gửi", - "schedule_send_description": "Chọn thời điểm máy chủ sẽ gửi thư này.", + "schedule_send_description": "Chọn thời điểm gửi.", "schedule_preset": { "this_morning": "Sáng nay", "this_afternoon": "Chiều nay", @@ -1176,6 +1176,7 @@ "edit_link": "Chỉnh sửa liên kết", "remove_link": "Xóa liên kết", "link_address": "Địa chỉ liên kết", + "link_text": "Văn bản", "link_invalid": "Địa chỉ không được chứa dấu cách", "table": "Bảng", "clear_formatting": "Xóa định dạng", @@ -4068,6 +4069,9 @@ "sending": "Đang gửi…", "sent": "Đã gửi", "view": "Xem", + "template_saved": "Đã lưu mẫu", + "schedule_anyway": "Vẫn lên lịch", + "schedule_warnings_title": "Vẫn lên lịch gửi thư này?", "picker_button": "Thêm từ danh bạ", "picker_title": "Thêm người nhận", "tab_contacts": "Danh bạ", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 248d6f5c9..25236cb6e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1111,7 +1111,7 @@ "unfinished": [] }, "schedule_send": "排程寄送", - "schedule_send_description": "選擇伺服器要在何時送出這封郵件。", + "schedule_send_description": "選擇寄送時間。", "schedule_preset": { "this_morning": "今天早上", "this_afternoon": "今天下午", @@ -1162,6 +1162,7 @@ "edit_link": "編輯連結", "remove_link": "移除連結", "link_address": "連結網址", + "link_text": "文字", "link_invalid": "網址中不能包含空格", "table": "表格", "clear_formatting": "清除格式", @@ -4056,6 +4057,9 @@ "sending": "正在傳送…", "sent": "已傳送", "view": "檢視", + "template_saved": "範本已儲存", + "schedule_anyway": "仍然排程", + "schedule_warnings_title": "仍然排程這封郵件?", "picker_button": "從聯絡人新增", "picker_title": "新增收件者", "tab_contacts": "聯絡人", diff --git a/locales/zh/common.json b/locales/zh/common.json index 892b9af80..372818e22 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1111,7 +1111,7 @@ "link_url_prompt": "输入 URL", "sending": "发送中...", "schedule_send": "计划发送", - "schedule_send_description": "选择服务器应何时发送此邮件。", + "schedule_send_description": "选择发送时间。", "schedule_preset": { "this_morning": "今天早上", "this_afternoon": "今天下午", @@ -1162,6 +1162,7 @@ "edit_link": "编辑链接", "remove_link": "移除链接", "link_address": "链接地址", + "link_text": "文本", "link_invalid": "地址中不能包含空格", "table": "表格", "clear_formatting": "清除格式", @@ -4056,6 +4057,9 @@ "sending": "正在发送…", "sent": "已发送", "view": "查看", + "template_saved": "模板已保存", + "schedule_anyway": "仍然计划发送", + "schedule_warnings_title": "仍然计划发送这封邮件?", "picker_button": "从联系人添加", "picker_title": "添加收件人", "tab_contacts": "联系人",