diff --git a/app/globals.css b/app/globals.css index b99ba0f7e..40b0d1c17 100644 --- a/app/globals.css +++ b/app/globals.css @@ -593,8 +593,10 @@ optgroup { animation: toast-exit 0.28s cubic-bezier(0.06, 0.71, 0.55, 1) forwards; } -/* The reader toolbar with no room for its labels: icons only, the names stay in the tooltips. */ -[data-toolbar-compact] [data-toolbar-label] { +/* The reader toolbar with no room for its labels: icons only, the names stay in the tooltips. + "secondary" first keeps the names of Reply, Archive, Delete and Move (mail-p2-15). */ +[data-toolbar-compact="secondary"] [data-toolbar-label]:not([data-toolbar-label="primary"]), +[data-toolbar-compact="all"] [data-toolbar-label] { display: none !important; } diff --git a/components/email/__tests__/archive-gate-viewer.test.tsx b/components/email/__tests__/archive-gate-viewer.test.tsx index 8fb33636e..9713c8519 100644 --- a/components/email/__tests__/archive-gate-viewer.test.tsx +++ b/components/email/__tests__/archive-gate-viewer.test.tsx @@ -82,13 +82,13 @@ function viewer() { describe('the viewer and the Archive action gate', () => { it('shows the Archive button while the gate is on', () => { viewer(); - expect(screen.getByTitle('tooltips.archive')).toBeInTheDocument(); + expect(screen.getByTitle('archive (E)')).toBeInTheDocument(); }); it('has no Archive button with the gate off, Delete still there', () => { setArchiveGate(false); viewer(); - expect(screen.queryByTitle('tooltips.archive')).toBeNull(); - expect(screen.getByTitle('tooltips.delete')).toBeInTheDocument(); + expect(screen.queryByTitle('archive (E)')).toBeNull(); + expect(screen.getByTitle('delete (Del)')).toBeInTheDocument(); }); }); 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-docked.test.tsx b/components/email/__tests__/composer-docked.test.tsx new file mode 100644 index 000000000..3c221d5fe --- /dev/null +++ b/components/email/__tests__/composer-docked.test.tsx @@ -0,0 +1,237 @@ +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'; + +// mail-p2-2: a reply docked under its message starts compact: no Subject +// line, no From line when there's only one address to send from, and the +// formatting bar folded behind its button. Full screen shows them again. + +// ─── Heavy component mocks (mirrors composer-mobile-formatting.test.tsx) ────── + +const toastError = vi.hoisted(() => vi.fn()); + +// Shows whether the composer asked for the toolbar to be folded away. +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ toolbarPlacement }: { toolbarPlacement?: string }) => + React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-toolbar': toolbarPlacement ?? 'top' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +const updateSetting = vi.fn(); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: (...args: unknown[]) => updateSetting(...args), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: toastError, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onComposeSend: { takeOver: async () => 'default' }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +const DATA = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Re: Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'reply' as const, + draftId: null, +}; + +const hidden = (el: Element | null) => !!el && el.className.split(/\s+/).includes('hidden'); + +describe('a reply docked under its message', () => { + beforeEach(() => { + toastError.mockClear(); + updateSetting.mockClear(); + }); + + it('starts compact, with the formatting bar behind its button', () => { + const { container } = render(); + expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(true); + expect(hidden(container.querySelector('[data-composer-from]'))).toBe(true); + const editor = screen.getByTestId('rich-text-editor'); + expect(editor).toHaveAttribute('data-toolbar', 'hidden'); + fireEvent.click(container.querySelector('[data-dock-formatting-toggle]')!); + expect(editor).toHaveAttribute('data-toolbar', 'top'); + }); + + it('shows everything in full screen', () => { + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: 'maximize' })); + expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(false); + expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-toolbar', 'top'); + }); + + it('keeps every line when not docked', () => { + const { container } = render(); + expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(false); + expect(hidden(container.querySelector('[data-composer-from]'))).toBe(false); + }); +}); 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__/conversation-pane-headers.test.tsx b/components/email/__tests__/conversation-pane-headers.test.tsx new file mode 100644 index 000000000..a08d817c2 --- /dev/null +++ b/components/email/__tests__/conversation-pane-headers.test.tsx @@ -0,0 +1,130 @@ +import { render, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useSettingsStore } from '@/stores/settings-store'; + +// mail-p2-4: in the default layout's conversation every message has the same +// header (sender, short date with the full one on hover, Reply, Reply all and +// Forward), the subject line has no date of its own, and the newest message +// is open, also when it is your own reply. + +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, conversationOpenPlan, conversationShortDate } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function mail(n: number, overrides: Partial = {}): Email { + return { + id: `m${n}`, blobId: `b${n}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: `Sender ${n}`, email: `s${n}@example.com` }], to: [{ name: 'Me', email: 'me@example.com' }], + subject: 'Offsite agenda', receivedAt: `2026-01-0${n}T10:00:00Z`, sentAt: `2026-01-0${n}T10:00:00Z`, preview: `Preview ${n}`, + hasAttachment: false, size: 100, ...overrides, + } as unknown as Email; +} +function withBody(e: Email, text: string): Email { + return { + ...e, textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], attachments: [], + bodyValues: { '1': { value: text, isTruncated: false } }, + } as unknown as Email; +} + +const expandedIds = (c: HTMLElement) => + [...c.querySelectorAll('[data-mv-conversation-expanded]')].map((e) => e.getAttribute('data-email-id')); + +describe('conversation headers in the default layout', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'default', markAsReadDelay: 0, timeFormat: '24h' }); + }); + + it('gives the open message the date and Reply, Reply all, Forward on its sender line', () => { + const open = withBody(mail(3), 'Newest body'); + const onReply = vi.fn(); + const onReplyAll = vi.fn(); + const onForward = vi.fn(); + const { container } = render( + , + ); + const header = container.querySelector('[data-mv-open-header]') as HTMLElement; + const actions = header.querySelector('[data-mv-sender-actions]') as HTMLElement; + expect(actions).not.toBeNull(); + fireEvent.click(within(actions).getByRole('button', { name: 'reply' })); + fireEvent.click(within(actions).getByRole('button', { name: 'reply_all' })); + fireEvent.click(within(actions).getByRole('button', { name: 'forward' })); + expect(onReply).toHaveBeenCalled(); + expect(onReplyAll).toHaveBeenCalled(); + expect(onForward).toHaveBeenCalled(); + const date = actions.querySelector('time[data-conversation-date]') as HTMLElement; + expect(date.getAttribute('title')!.length).toBeGreaterThan(date.textContent!.length); + // No second date on the subject line. + expect(container.querySelectorAll('time[data-conversation-date]').length).toBe(3); + expect(container.querySelector('.sm\\:block .whitespace-nowrap')).toBeNull(); + }); + + it('gives an unfolded older message Forward too, in the same short date style', () => { + const open = withBody(mail(3), 'Newest body'); + const onForward = vi.fn(); + const older = withBody(mail(2, { keywords: {} }), 'Older body'); + const { container } = render( + , + ); + const card = container.querySelector('[data-mv-conversation-expanded][data-email-id="m2"]') as HTMLElement; + fireEvent.click(within(card).getByRole('button', { name: 'forward' })); + expect(onForward).toHaveBeenCalledWith(expect.objectContaining({ id: 'm2' })); + // Not Gmail's "(n days ago)". + expect(card.querySelector('time')!.textContent).not.toMatch(/\(/); + }); + + it('opens your own newer reply and folds the read message you clicked', () => { + const open = withBody(mail(2), 'Their message'); + const mine = mail(3, { from: [{ name: 'Me', email: 'me@example.com' }], mailboxIds: { sent: true } }); + const { container } = render( + , + ); + expect(expandedIds(container)).toEqual(['m3']); + expect(container.querySelector('[data-mv-open-folded]')).not.toBeNull(); + expect(container.querySelector('[data-mv-open-header]')).toBeNull(); + }); +}); + +describe('conversationOpenPlan', () => { + it('keeps the open message when it is the newest', () => { + expect(conversationOpenPlan([mail(1), mail(2)], 'm2')).toEqual({ expanded: [], foldOpen: false, newestId: null }); + }); + it('keeps an unread open message open beside the newest', () => { + const plan = conversationOpenPlan([mail(1, { keywords: {} }), mail(2)], 'm1'); + expect(plan).toEqual({ expanded: ['m2'], foldOpen: false, newestId: 'm2' }); + }); + it('never picks a draft or a scheduled message as the newest', () => { + const plan = conversationOpenPlan([mail(1), mail(2, { keywords: { $draft: true, $seen: true } }), mail(3, { isScheduled: true })], 'm1'); + expect(plan.newestId).toBeNull(); + }); +}); + +describe('conversationShortDate', () => { + const now = new Date('2026-11-04T12:00:00Z'); + it('has no relative part and no year this year', () => { + const text = conversationShortDate('2026-10-21T13:40:00Z', '24h', now); + expect(text).not.toMatch(/2026|ago|\(/); + expect(text).toMatch(/21/); + }); + it('has the year for older mail', () => { + expect(conversationShortDate('2025-10-21T13:40:00Z', '24h', now)).toMatch(/2025/); + }); +}); diff --git a/components/email/__tests__/email-context-menu-row.test.tsx b/components/email/__tests__/email-context-menu-row.test.tsx index 87eb9c323..f6aaa871c 100644 --- a/components/email/__tests__/email-context-menu-row.test.tsx +++ b/components/email/__tests__/email-context-menu-row.test.tsx @@ -125,6 +125,25 @@ describe('Restore in Trash', () => { expect(onMoveToMailbox).toHaveBeenCalledWith('inbox'); }); + // Pass 2, mail-9: Trash's Delete removes for good, so it says so, and + // reporting spam from the bin makes no sense. + it('says Delete forever and offers no Report spam', () => { + renderMenu({ email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash' }); + expect(screen.getByTestId('ctx-delete').textContent).toContain('delete_forever'); + expect(screen.queryByTestId('ctx-spam')).toBeNull(); + fireEvent.click(screen.getByTestId('ctx-more')); + expect(screen.queryByTestId('ctx-spam')).toBeNull(); + }); + + it('says Delete forever for a selection in Trash too', () => { + renderMenu({ + email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', isMultiSelect: true, selectedCount: 2, + batchEmails: [trashed, { ...trashed, id: 'e2' } as Email], onBatchDelete: vi.fn(), onBatchMarkAsSpam: vi.fn(), + }); + expect(screen.getByTestId('ctx-delete').textContent).toContain('delete_forever'); + expect(screen.queryByTestId('ctx-spam')).toBeNull(); + }); + it('is in the selection menu too', () => { rememberTrashOrigins([{ id: 'e1', mailboxIds: { work: true } }, { id: 'e2', mailboxIds: { work: true } }], mailboxes); const onBatchMoveToMailbox = vi.fn(); diff --git a/components/email/__tests__/email-list-batch-bar.test.tsx b/components/email/__tests__/email-list-batch-bar.test.tsx index bdb8aa174..67bddb70b 100644 --- a/components/email/__tests__/email-list-batch-bar.test.tsx +++ b/components/email/__tests__/email-list-batch-bar.test.tsx @@ -47,6 +47,15 @@ describe('selection bar', () => { await waitFor(() => expect(batchMarkAsSpam).toHaveBeenCalledWith(expect.anything(), ['one', 'two'])); }); + it('counts conversations in its toasts, as its title does (mail-18)', async () => { + const { toast } = await import('@/stores/toast-store'); + const batchArchive = vi.fn().mockResolvedValue(true); + useEmailStore.setState({ batchArchive }); + render(); + fireEvent.click(screen.getByTestId('batch-archive')); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('mail_p2_list.conversations_archived', expect.anything())); + }); + it('opens the selection’s menu from ⋮, with Move to and Forward as attachments', () => { render(); fireEvent.click(screen.getByTestId('batch-more')); @@ -99,6 +108,43 @@ describe('selection bar: Move, read state and the reading pane', () => { }); }); +describe('select all in the folder (mail-p2-11)', () => { + it('offers the rest of the folder once every loaded row is ticked, and ticks it', async () => { + const third = { ...useEmailStore.getState().emails[0], id: 'three', threadId: 't-three', blobId: 'b-three' } as Email; + const loadMoreEmails = vi.fn(async () => { + useEmailStore.setState({ emails: [...useEmailStore.getState().emails, third], hasMoreEmails: false }); + }); + useEmailStore.setState({ hasMoreEmails: true, loadMoreEmails, totalEmails: 3 }); + render(); + const offer = screen.getByTestId('select-all-in-view'); + expect(offer.textContent).toBe('mail_p2_list.select_all_in_folder'); + fireEvent.click(offer.querySelector('button')!); + await waitFor(() => expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['one', 'three', 'two'])); + expect(loadMoreEmails).toHaveBeenCalledTimes(1); + }); + + it('offers nothing while only some loaded rows are ticked', () => { + useEmailStore.setState({ hasMoreEmails: true, selectedEmailKeys: new Set(['one']) }); + render(); + expect(screen.queryByTestId('select-all-in-view')).toBeNull(); + }); +}); + +describe('the reading pane for a selection (mail-p2-15)', () => { + it('names its buttons in words, while the bar beside the list keeps icons', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + render(); + const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement; + for (const id of ['archive', 'delete', 'move', 'mark-read', 'tag']) { + const button = pane.querySelector(`[data-testid="pane-batch-${id}"]`) as HTMLElement; + expect(button.textContent?.trim()).toBe(button.getAttribute('aria-label')); + } + expect(screen.getByTestId('batch-archive').textContent).toBe(''); + host.remove(); + }); +}); + describe('selection bar and rows from the keyboard', () => { it('keeps the collapsed bar out of the Tab order', () => { useEmailStore.setState({ selectedEmailKeys: new Set() }); diff --git a/components/email/__tests__/list-row-preview-lines.test.tsx b/components/email/__tests__/list-row-preview-lines.test.tsx index a32f33131..366ab7709 100644 --- a/components/email/__tests__/list-row-preview-lines.test.tsx +++ b/components/email/__tests__/list-row-preview-lines.test.tsx @@ -54,24 +54,38 @@ beforeEach(() => { useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); }); -// mail-15: the default density shows one preview line, so more rows fit; -// comfortable keeps two. +// mail-15: the default density puts the preview after the subject, on the +// subject's line, so about ten rows fit at 1366×768 (they were about 96 px, +// seven on screen); comfortable keeps two preview lines, and a phone keeps +// the preview under the subject. describe('list row preview lines', () => { - it.each([ - ['regular', 'line-clamp-1'], - ['comfortable', 'line-clamp-2'], - ] as const)('a %s row clamps its preview with %s', (density, clamp) => { - useSettingsStore.setState({ density }); - renderRow([makeEmail()]); - expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp); - }); + const onSubjectLine = () => { + const preview = screen.getByText(PREVIEW, { exact: false }); + return preview.closest('[data-row-preview]') !== null; + }; it.each([ - ['regular', 'line-clamp-1'], - ['comfortable', 'line-clamp-2'], - ] as const)('a %s conversation row clamps its preview with %s', (density, clamp) => { - useSettingsStore.setState({ density }); - renderRow([makeEmail({ id: 'a' }), makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' })]); - expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp); + ['message', [makeEmail()]], + ['conversation', [makeEmail({ id: 'a' }), makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' })]], + ] as const)('a regular %s row shows its preview on the subject line', (_kind, emails) => { + useSettingsStore.setState({ density: 'regular' }); + renderRow([...emails]); + expect(onSubjectLine()).toBe(true); + expect(document.querySelector('p.line-clamp-1')).toBeNull(); + }); + + it('a comfortable row keeps two preview lines of its own', () => { + useSettingsStore.setState({ density: 'comfortable' }); + renderRow([makeEmail()]); + expect(onSubjectLine()).toBe(false); + expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain('line-clamp-2'); + }); + + it('a regular row on a phone keeps the preview under the subject', () => { + useUIStore.setState({ isMobile: true }); + useSettingsStore.setState({ density: 'regular' }); + renderRow([makeEmail()]); + expect(onSubjectLine()).toBe(false); + expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain('line-clamp-1'); }); }); diff --git a/components/email/__tests__/mail-folder-menu.test.tsx b/components/email/__tests__/mail-folder-menu.test.tsx index 2eae04733..ce41761ff 100644 --- a/components/email/__tests__/mail-folder-menu.test.tsx +++ b/components/email/__tests__/mail-folder-menu.test.tsx @@ -29,6 +29,15 @@ describe('list header ⋮', () => { fireEvent.click(screen.getByText('mark_folder_read')); expect(onMarkFolderRead).toHaveBeenCalledTimes(1); }); + // p2-14: "Empty folder" read the same in Trash and Spam. + it('names Empty after Trash or Spam', () => { + const onEmptyFolder = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('mail-folder-menu')); + expect(screen.getByTestId('mail-empty-folder').textContent).toContain('empty_spam'); + fireEvent.click(screen.getByTestId('mail-empty-folder')); + expect(onEmptyFolder).toHaveBeenCalledTimes(1); + }); it('carries Refresh and Sort, which have no buttons of their own', () => { const onRefresh = vi.fn(); render(); diff --git a/components/email/__tests__/move-target-list.test.tsx b/components/email/__tests__/move-target-list.test.tsx index 149615116..c8ecf88d1 100644 --- a/components/email/__tests__/move-target-list.test.tsx +++ b/components/email/__tests__/move-target-list.test.tsx @@ -37,8 +37,10 @@ describe('MoveFolderList', () => { const onPick = vi.fn(); render(); // The i18n mock returns the key: the role's name, not the server's. - expect(screen.queryByText('Sent Items')).toBeNull(); - expect(screen.getByText('mailboxes.sent')).toBeTruthy(); + expect(screen.queryByText('Junk Mail')).toBeNull(); + expect(screen.getByText('mailboxes.archive')).toBeTruthy(); + // Sent is no Move target (pass 2 p2-18). + expect(screen.queryByText('mailboxes.sent')).toBeNull(); fireEvent.click(screen.getByText('mailboxes.spam')); expect(onPick).toHaveBeenCalledWith('junk'); }); diff --git a/components/email/__tests__/mv-list-toolbar.test.tsx b/components/email/__tests__/mv-list-toolbar.test.tsx index 96862dbfc..270ec716e 100644 --- a/components/email/__tests__/mv-list-toolbar.test.tsx +++ b/components/email/__tests__/mv-list-toolbar.test.tsx @@ -160,10 +160,10 @@ describe('MvListToolbar overflow menu', () => { render( {}} onEmptyFolder={() => {}} />); open(); - expect(screen.queryByText('empty_folder')).toBeNull(); + for (const label of ['empty_folder', 'empty_trash', 'empty_spam']) expect(screen.queryByText(label)).toBeNull(); }); - it('offers it in the bin', () => { + it('offers it in the bin, as Empty Trash', () => { useEmailStore.setState({ mailboxes: [mailbox('trash', 'Trash', 'trash')], selectedMailbox: 'trash', @@ -172,7 +172,7 @@ describe('MvListToolbar overflow menu', () => { render( {}} onEmptyFolder={onEmptyFolder} />); open(); - fireEvent.click(screen.getByText('empty_folder')); + fireEvent.click(screen.getByText('empty_trash')); expect(onEmptyFolder).toHaveBeenCalledOnce(); }); }); diff --git a/components/email/__tests__/reader-spam-trash-actions.test.tsx b/components/email/__tests__/reader-spam-trash-actions.test.tsx index 815449deb..0147f421e 100644 --- a/components/email/__tests__/reader-spam-trash-actions.test.tsx +++ b/components/email/__tests__/reader-spam-trash-actions.test.tsx @@ -56,7 +56,7 @@ async function viewer(folder: string, h = handlers()) { return h; } -const archiveButton = () => document.querySelector('button[title="tooltips.archive"]'); +const archiveButton = () => document.querySelector('button[title="archive (E)"]'); beforeEach(() => { useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false, quickReplyEnabled: true }); @@ -74,7 +74,7 @@ describe('reader toolbar in Spam', () => { it('has no quick reply', async () => { await viewer('junk'); - expect(screen.queryByPlaceholderText('quick_reply_placeholder')).toBeNull(); + expect(screen.queryByPlaceholderText(/^quick_reply/)).toBeNull(); }); }); @@ -86,6 +86,20 @@ describe('reader toolbar in Trash', () => { expect(h.onMoveToMailbox).toHaveBeenCalledWith('inbox'); }); + // Pass 2, mail-9: the button said "Move back to Inbox" while the row + // menu and ⋮ said "Restore", for the same action. + it('names Restore as the menus do', async () => { + await viewer('trash'); + const button = screen.getByTestId('reader-restore'); + expect(button.getAttribute('title')).toBe('restore'); + expect(button.getAttribute('aria-label')).toBe('restore'); + }); + + it('offers no Report spam', async () => { + await viewer('trash'); + expect(document.querySelector('button[title="spam.button_title"]')).toBeNull(); + }); + it('says Delete forever', async () => { await viewer('trash'); expect(screen.getByTestId('reader-delete').textContent).toContain('delete_forever'); @@ -99,7 +113,7 @@ describe('reader toolbar in the Inbox', () => { expect(screen.queryByTestId('reader-restore')).toBeNull(); expect(screen.queryByTestId('reader-not-spam')).toBeNull(); expect(screen.getByTestId('reader-delete').textContent).not.toContain('delete_forever'); - expect(screen.getByPlaceholderText('quick_reply_placeholder')).toBeTruthy(); + expect(screen.getByPlaceholderText(/^quick_reply/)).toBeTruthy(); }); }); diff --git a/components/email/__tests__/reader-toolbar-labels-first.test.tsx b/components/email/__tests__/reader-toolbar-labels-first.test.tsx index ea51036a2..47c8f6d50 100644 --- a/components/email/__tests__/reader-toolbar-labels-first.test.tsx +++ b/components/email/__tests__/reader-toolbar-labels-first.test.tsx @@ -96,6 +96,24 @@ describe('reader toolbar overflow', () => { expect(labels).toEqual(expect.arrayContaining(['reply', 'reply_all', 'forward', 'archive', 'delete', 'move'])); }); + it('keeps the names of Reply, Archive, Delete and Move while only those fit (mail-p2-15)', () => { + // 600 px: every label needs 400 per group, the main four's 280, none 250. + const spies = [ + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { + const step = this.closest('[data-toolbar-compact]')?.getAttribute('data-toolbar-compact'); + return step === 'all' ? 250 : step === 'secondary' ? 280 : 400; + }), + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(600), + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(40), + ]; + restore = () => spies.forEach((spy) => spy.mockRestore()); + const { container } = viewer(); + expect(toolbar(container).getAttribute('data-toolbar-compact')).toBe('secondary'); + expect(hiddenPriorities(container)).toEqual([]); + const kept = Array.from(container.querySelectorAll('[data-toolbar-label="primary"]')).map((label) => label.textContent); + expect(kept.sort()).toEqual(['archive', 'delete', 'move', 'reply']); + }); + it('then hides the optional buttons, never Reply, Reply all, Forward, Archive or Move', () => { restore = layout(500, 400); const { container } = viewer(); 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__/search-result-count.test.tsx b/components/email/__tests__/search-result-count.test.tsx index 79e2feb57..477e01c52 100644 --- a/components/email/__tests__/search-result-count.test.tsx +++ b/components/email/__tests__/search-result-count.test.tsx @@ -47,7 +47,13 @@ describe('SearchResultCount', () => { expect(container.textContent).toBe('advanced_search.results_found {"count":10}'); }); - it('keeps "10+ results" while more can load', () => { + it('counts conversations while more can load and the list groups them', () => { + const { container } = render(); + expect(container.textContent).toBe('email_list.conversations_count_plus {"count":3}'); + }); + + it('keeps "10+ results" while more can load and conversations are off', () => { + useSettingsStore.setState({ disableThreading: true }); const { container } = render(); expect(container.textContent).toBe('advanced_search.results_found_more {"count":10}'); }); diff --git a/components/email/__tests__/shortcut-hints-agree.test.tsx b/components/email/__tests__/shortcut-hints-agree.test.tsx new file mode 100644 index 000000000..04107fb83 --- /dev/null +++ b/components/email/__tests__/shortcut-hints-agree.test.tsx @@ -0,0 +1,63 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { MAIL_SHORTCUTS } from '@/lib/keyboard-shortcuts/mail'; +import { messageActionShortcut } from '../message-action-shortcuts'; + +// mail-p2-13: Reply all read "Reply all (a)" in the toolbar, "Shift + R" in +// the menus and "R" in the help; Delete "# or Del" against "Del". The three +// places now name the same key. + +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 email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', + preview: '', hasAttachment: false, size: 1, textBody: [], htmlBody: [], attachments: [], bodyValues: {}, +} as unknown as Email; + +const helpKey = (description: string) => + MAIL_SHORTCUTS.actions.find((entry) => entry.description === description)!.key; + +describe('shortcut hints', () => { + it('give Reply all and Delete the same key in the help as in the menus', () => { + expect(helpKey('shortcuts.actions.reply_all')).toBe(messageActionShortcut('reply_all', { singleKeys: true })); + expect(helpKey('shortcuts.actions.delete')).toBe(messageActionShortcut('delete', { singleKeys: true })); + }); + + it('name the menus\' keys in the toolbar tooltips', () => { + const { container } = render( + , + ); + const titles = Array.from(container.querySelectorAll('button[title]')).map((b) => b.title); + expect(titles).toContain('reply_all (Shift + R)'); + expect(titles).toContain('delete (Del)'); + expect(titles).toContain('reply (R)'); + }); +}); 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__/thread-conversation-order.test.tsx b/components/email/__tests__/thread-conversation-order.test.tsx new file mode 100644 index 000000000..c01610280 --- /dev/null +++ b/components/email/__tests__/thread-conversation-order.test.tsx @@ -0,0 +1,48 @@ +// mail-p2-6: the phone listed a conversation newest first while every other +// layout reads it oldest first. It now reads oldest first too, with the +// newest message (your own reply as well) open at the bottom. +import { describe, expect, it, vi } from 'vitest'; +import { render } 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 mail = (n: number, extra: Record = {}) => ({ + id: `e${n}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: `Sender ${n}`, email: `s${n}@t.example` }], to: [{ email: 'me@example.org' }], + subject: 'Lunch', receivedAt: `2026-10-2${n}T10:00:00Z`, sentAt: `2026-10-2${n}T10:00:00Z`, size: 1, preview: `hi ${n}`, hasAttachment: false, + htmlBody: [], textBody: [], attachments: [], bodyValues: {}, ...extra, +}); + +function renderView(emails: ReturnType[]) { + return render( + {}} + mailboxRole="inbox" + />, + ); +} + +const order = (c: HTMLElement) => [...c.querySelectorAll('[data-thread-email-id]')].map((e) => e.getAttribute('data-thread-email-id')); + +describe('phone conversation order', () => { + it('lists the messages oldest first, as the thread arrives newest first', () => { + const { container } = renderView([mail(3), mail(2), mail(1)]); + expect(order(container)).toEqual(['e1', 'e2', 'e3']); + }); + + it('opens the newest message, your own reply too, and not a draft after it', () => { + const mine = mail(3, { from: [{ name: 'Me', email: 'me@example.org' }], mailboxIds: { sent: true } }); + const draft = mail(4, { keywords: { $draft: true, $seen: true } }); + const { container } = renderView([draft, mine, mail(2), mail(1)]); + const expanded = [...container.querySelectorAll('[data-thread-email-id]')] + .filter((el) => el.querySelector('[aria-expanded="true"]')) + .map((el) => el.getAttribute('data-thread-email-id')); + expect(expanded).toEqual(['e3']); + }); +}); diff --git a/components/email/__tests__/thread-list-item-columns.test.tsx b/components/email/__tests__/thread-list-item-columns.test.tsx index 98c275ee0..ed27738ce 100644 --- a/components/email/__tests__/thread-list-item-columns.test.tsx +++ b/components/email/__tests__/thread-list-item-columns.test.tsx @@ -93,7 +93,9 @@ describe('message list rows under the bottom reading pane (#262)', () => { it('keeps stacked rows in the split layout', () => { useSettingsStore.setState({ mailLayout: 'split' }); const { container } = renderEmails([makeEmail()]); - expect(container.querySelector('p')).not.toBeNull(); + // Stacked: a preview line of its own, or the preview after the subject + // on the subject's line at the regular density (mail-15). + expect(container.querySelector('p, [data-row-preview]')).not.toBeNull(); }); describe('as columns', () => { diff --git a/components/email/__tests__/viewer-details-per-message.test.tsx b/components/email/__tests__/viewer-details-per-message.test.tsx new file mode 100644 index 000000000..953d760bc --- /dev/null +++ b/components/email/__tests__/viewer-details-per-message.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// mail-7: "Show details" stayed open for every message opened after it. +// mail-29: a mail received before it was sent showed a delivery time. + +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() }), +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const make = (id: string, sentAt: string, receivedAt: string) => ({ + id, blobId: `b-${id}`, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Sam', email: 'sam@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: `Message ${id}`, sentAt, receivedAt, preview: 'Hello', + hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {}, +}) as unknown as Email; + +const viewer = (email: Email) => ( + +); + +describe('message details panel', () => { + it('closes when another message opens', () => { + const first = make('e1', '2026-11-03T10:00:00Z', '2026-11-03T10:00:05Z'); + const second = make('e2', '2026-11-03T11:00:00Z', '2026-11-03T11:00:05Z'); + const { rerender } = render(viewer(first)); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + expect(screen.queryByText('details.recipients_routing')).not.toBeNull(); + rerender(viewer(second)); + expect(screen.queryByText('details.recipients_routing')).toBeNull(); + // And back on the first one it is closed too: it was opened for that view. + rerender(viewer(first)); + expect(screen.queryByText('details.recipients_routing')).toBeNull(); + }); + + it('shows no delivery time for a mail received before it was sent', () => { + render(viewer(make('e3', '2026-11-03T21:40:00Z', '2026-11-03T20:40:00Z'))); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + expect(screen.queryByText(/details\.delivery_time/i)).toBeNull(); + expect(document.body.textContent).not.toMatch(/delivery_time/); + }); + + it('still shows a real delivery time', () => { + render(viewer(make('e4', '2026-11-03T20:40:00Z', '2026-11-03T21:40:00Z'))); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + expect(document.body.textContent).toMatch(/delivery_time/); + }); +}); diff --git a/components/email/__tests__/viewer-quick-reply-back.test.tsx b/components/email/__tests__/viewer-quick-reply-back.test.tsx index b4717d125..df22850a8 100644 --- a/components/email/__tests__/viewer-quick-reply-back.test.tsx +++ b/components/email/__tests__/viewer-quick-reply-back.test.tsx @@ -63,6 +63,22 @@ describe('quick reply', () => { }); }); +// mail-p2-8: Undo send on a quick reply brings its text back into this box. +describe('quick reply after Undo send', () => { + it('takes the undone text back into the box of the message it answered', async () => { + const { useQuickReplyRestoreStore } = await import('@/stores/quick-reply-restore-store'); + useQuickReplyRestoreStore.setState({ pending: null }); + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + const box = screen.getByPlaceholderText('quick_reply_to') as HTMLTextAreaElement; + act(() => { useQuickReplyRestoreStore.getState().restore('other', 'Not for this one'); }); + expect(box.value).toBe(''); + act(() => { useQuickReplyRestoreStore.getState().restore('m1', 'My answer'); }); + await waitFor(() => expect(box.value).toBe('My answer')); + expect(useQuickReplyRestoreStore.getState().pending).toBeNull(); + }); +}); + describe('automatic sign-out while the external recipient question is open', () => { it('answers Back and keeps the typed reply as a draft, without asking again', async () => { const { useExternalRecipientConfirmStore } = await import('@/stores/external-recipient-confirm-store'); 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/batch-tag-button.tsx b/components/email/batch-tag-button.tsx index 5b438ac2e..f569007e7 100644 --- a/components/email/batch-tag-button.tsx +++ b/components/email/batch-tag-button.tsx @@ -26,6 +26,7 @@ export function BatchTagButton({ disabled, scope = null, testId = "batch-tag", + showLabel = false, }: { title: string; /** Tags every selected message carries. */ @@ -40,6 +41,8 @@ export function BatchTagButton({ scope?: TagScope | null; /** The reading pane's copy has its own (mail-10). */ testId?: string; + /** Name the button in words beside the icon, as the reading pane does (mail-p2-15). */ + showLabel?: boolean; }) { const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const buttonRef = useRef(null); @@ -101,8 +104,10 @@ export function BatchTagButton({ aria-haspopup="menu" aria-expanded={isOpen} disabled={disabled} + {...(showLabel ? { size: "sm" as const, title: "", className: "gap-1.5" } : {})} > + {showLabel && {title}} {isOpen && createPortal( diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index bbcff311d..dd5845507 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 { @@ -386,7 +386,10 @@ interface EmailComposerProps { labelHeaders?: Record; /** The id plugins know this composer by, for onAfterEmailSend. */ composerId?: string; - }) => void | false | Promise; + }, + /** The composer as it was at Send, to bring it back as it was after Undo send (mail-p2-8). */ + state?: ComposerDraftData, + ) => void | false | Promise; /** * onSend failed. A host that closes the composer as soon as onSend starts * gets the message back here, to open it again: it must never vanish. @@ -412,6 +415,11 @@ interface EmailComposerProps { */ onDiscardDraft?: (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => void; onSaveState?: (data: ComposerDraftData) => void; + /** + * Undo after the bin: opens a composer again with what this one held. The + * draft on the server is still there then. + */ + onRestoreDiscarded?: (data: ComposerDraftData) => void; /** * Offers "Open in new window". Called in the click, since a browser only * opens a window for a user action: the host opens it and answers with what @@ -421,6 +429,12 @@ interface EmailComposerProps { popOut?: () => ComposerPopOut | null; /** The move goes back to the main window: the button says that, and shows at any width. */ popOutToMainLabel?: string; + /** + * Docked under the message it answers (mail-p2-2): the composer starts + * compact, without the Subject line, the From line when there's nothing to + * pick, and the formatting bar until asked for. Full screen shows them all. + */ + docked?: boolean; /** * This composer is all a window shows (lib/popout). It closes itself once a * message has gone. Closing the window with unsaved text hands the draft to @@ -445,6 +459,8 @@ interface EmailComposerProps { * this one is gone before a toast here could be read. */ onDraftKept?: (draftId: string, where: { accountId?: string; localAccountId?: string }) => void; + /** The bin closed the window: another window says "Draft discarded" with Undo, and deletes it after. */ + onDraftDiscarded?: (draftId: string, where: { accountId?: string; localAccountId?: string }) => void; }; /** The subject as typed, for a host that shows it (a Pro compose tab's title). */ onSubjectChange?: (subject: string) => void; @@ -607,8 +623,10 @@ export function EmailComposer({ requestCloseRef, onDiscardDraft, onSaveState, + onRestoreDiscarded, popOut, popOutToMainLabel, + docked = false, ownWindow, onSubjectChange, className, @@ -1250,6 +1268,8 @@ export function EmailComposer({ // button or Escape puts it back. Not in a window of its own, which is all composer. const [maximized, setMaximized] = useState(false); const canMaximize = !ownWindow && !mvWindow; + const dockCompact = docked && !maximized; + const [showDockFormatting, setShowDockFormatting] = useState(false); // "close" (X, Escape): save or discard. "discard" (the Discard button): the // user already chose, so only confirm it. const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close'); @@ -1260,7 +1280,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 +1478,19 @@ export function EmailComposer({ // when the user switches identity in "above quote" mode without rebuilding // the whole body (which would lose user edits to the surrounding draft). const editorRef = useRef(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 +1581,11 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision, autoSignatureEnabled]); + // The send checks run first, so they don't open on top of the picked time + // saying "Send anyway" when nothing is sent now (compose-p2-14). const openScheduleDialog = useCallback(() => { - setShowScheduleDialog(true); closeSendMenu(); + checkThenScheduleRef.current(); }, [closeSendMenu]); // `autoSelectReplyIdentity` fused two behaviours: matching one of the user's @@ -1843,9 +1880,13 @@ export function EmailComposer({ const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); // "Save as template" keeps it with the account the message is written from. + // It says so: the dialog closing was the only sign before (compose-p2-15). const addTemplate = useCallback( - (data: Parameters['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 +3878,12 @@ export function EmailComposer({ else editor.commands.focus(); }; - const handleSend = async ({ warningsConfirmed = false, delayedUntil }: { + const handleSend = async ({ warningsConfirmed = false, delayedUntil, checkOnly = false }: { /** "Send anyway" in the warnings dialog: don't check those again. */ warningsConfirmed?: boolean; delayedUntil?: string; + /** Only the checks, then the schedule dialog instead of a send. */ + checkOnly?: boolean; } = {}) => { if (isSendingRef.current) return; @@ -3987,10 +4030,14 @@ export function EmailComposer({ } if (warnings.length > 0) { - setSendWarnings({ items: warnings, delayedUntil }); + setSendWarnings({ items: warnings, delayedUntil, ...(checkOnly ? { schedule: true } : {}) }); return; } } + if (checkOnly) { + setShowScheduleDialog(true); + return; + } // Checks other features register (lib/outgoing/send-pipeline.ts), such // as the external-recipient confirm. They may ask the user, so a second @@ -4381,7 +4428,7 @@ export function EmailComposer({ labelHeaders: labelled.labelHeaders, delayedUntil: effectiveDelayedUntil, composerId: pluginComposerIdRef.current ?? undefined, - }); + }, handedToHost); // Not when nothing went: no host, or one that sent nothing. if (draftElsewhere && onSend && outcome !== false) await removeDraftVersion(finalDraftId!, draftLogin, draftAccountAtSend); @@ -4475,6 +4522,8 @@ export function EmailComposer({ }; + checkThenScheduleRef.current = () => void handleSend({ checkOnly: true }); + // A host can hand a follow-up to requestCloseRef - "close this session, then // do that". It runs only once the composer really closes, never when the // guard dialog is cancelled, and never before onClose has had its say. @@ -4688,7 +4737,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 +4757,7 @@ export function EmailComposer({ // From belongs to: switching From doesn't move the draft until it is // saved again, and the same id names another message on another server. const discardedDraftId = draftIdRef.current; - if (discardedDraftId && onDiscardDraft) { + if (discardedDraftId && onDiscardDraft && !keepDraft) { onDiscardDraft(discardedDraftId, { accountId: draftAccountIdRef.current, localAccountId: draftLocalAccountIdRef.current, @@ -4742,6 +4793,11 @@ export function EmailComposer({ // Discard deletes the draft, also one that has no unsaved changes, such as // a scheduled message reopened with "Cancel and edit". It used to share the // X button's path, which closed such a composer and kept the draft. + // It asks nothing (compose-p2-10): the composer goes at once with "Draft + // discarded · Undo", as Discard on the "Draft saved" toast does, and the + // draft is deleted once Undo is no longer offered. Undo brings the composer + // back with its text where the host can; elsewhere the last saved draft + // stays in Drafts. A window of its own has the main window say it. const handleDiscardClick = () => { if (saveCloseAttemptRef.current?.saving) return; cancelSaveAndClose(); @@ -4750,7 +4806,22 @@ export function EmailComposer({ return; } afterCloseRef.current = null; - openCloseDialog('discard'); + const draftId = draftIdRef.current; + const where = { accountId: draftAccountIdRef.current, localAccountId: draftLocalAccountIdRef.current }; + const snapshot = snapshotStateRef.current(); + const discard = onDiscardDraft; + const restore = onRestoreDiscarded; + const windowDiscarded = ownWindowRef.current?.onDraftDiscarded; + handleDiscardAndClose({ keepDraft: true }); + if (windowDiscarded) { + if (draftId) windowDiscarded(draftId, where); + return; + } + discardWithUndo( + () => { if (draftId) discard?.(draftId, where); }, + { discarded: tUx('draft_discarded'), undo: tUx('undo') }, + restore ? () => restore(snapshot) : undefined, + ); }; // X, Escape, another message, a host's tab: one rule, as drafts save @@ -4952,7 +5023,8 @@ export function EmailComposer({ "flex h-full bg-background relative", className, // Over everything but dialogs (z-60) and toasts; below the desktop app's window controls. - maximized && "fixed inset-0 z-40 h-auto pt-[var(--titlebar-row-height)]", + // At the folder sidebar's z-50 and after it in the page, so over it (compose-p2-5). + maximized && "fixed inset-0 z-50 h-auto pt-[var(--titlebar-row-height)]", )} data-maximized={maximized || undefined} onKeyDownCapture={handleComposerKeyDown} @@ -5170,7 +5242,7 @@ export function EmailComposer({ >
{/* From field */} -
+
{t('from')}:
{fromOverrideEnabled ? ( @@ -5472,7 +5544,7 @@ export function EmailComposer({ )} {/* Subject field */} -
+
-
+
{wantsPluginDraft && ( )} @@ -5571,7 +5643,7 @@ export function EmailComposer({ ariaDescribedBy={`${fieldIdBase}-body-error`} defaultTextStyle={composeStyle} collapseToolbarOnMobile={phoneLayout && !showFormattingOnMobile} - toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'} + toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : dockCompact && !showDockFormatting ? 'hidden' : 'top'} onEditorReady={handleEditorReady} mentionCandidates={recipientMentionsEnabled ? mentionCandidates : undefined} findMentionContacts={recipientMentionsEnabled && mentionAddsRecipientTo !== 'off' ? findMentionContacts : undefined} @@ -5637,12 +5709,13 @@ export function EmailComposer({

)}
{attachmentsForDisplay(attachments).map(({ att, index }) => { @@ -5853,16 +5926,17 @@ export function EmailComposer({ @@ -6145,7 +6219,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 +6231,7 @@ export function EmailComposer({ { const first = sendWarnings?.items[0]; setSendWarnings(null); @@ -6173,7 +6249,7 @@ export function EmailComposer({ }} onSendAnyway={(dontAskAgain) => { if (!sendWarnings) return; - const { items, delayedUntil } = sendWarnings; + const { items, delayedUntil, schedule } = sendWarnings; for (const warning of items) { if (warning.kind === 'subject') { emptySubjectConfirmedRef.current = true; @@ -6188,7 +6264,8 @@ export function EmailComposer({ } } setSendWarnings(null); - handleSend({ warningsConfirmed: true, delayedUntil }); + if (schedule) setShowScheduleDialog(true); + else handleSend({ warningsConfirmed: true, delayedUntil }); }} /> @@ -6204,7 +6281,7 @@ export function EmailComposer({ - {closeDialogKind === 'close' && ( @@ -6817,6 +6894,14 @@ export function RecipientChipInput({ !isEditing && "focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary" )} onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)} + // A click opens the chip's menu too (address, Edit, Move to, + // Remove), as Gmail and Outlook do; it did nothing visible. + onClick={isEditing ? undefined : (e) => { + if (e.target instanceof Element && e.target.closest('button')) return; + // Not on to the field, whose click puts the caret in the input. + e.stopPropagation(); + openChipMenu(i); + }} data-invalid={isInvalid || undefined} title={isInvalid ? t('invalid_recipient') : undefined} role="listitem" @@ -6933,7 +7018,10 @@ export function RecipientChipInput({ onKeyDown={handleKeyDown} onPaste={handlePaste} onBlur={handleBlur} - className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none" + // focus-inset: the keyboard focus ring goes inside the box. Drawn + // outside, the field's own scroller cut it to a line along the top + // and the end (compose-p2-18, a pop-out focuses To from a key). + className="focus-inset flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none" role="combobox" aria-expanded={activeAutoField === field && autocompleteResults.length > 0} aria-autocomplete="list" diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 3ee1bd823..0d0d2eb0b 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -2,8 +2,9 @@ import { useMemo } from "react"; import { useLocale, useTranslations } from "next-intl"; +import { spamApplicableIn } from "@/lib/spam-applicable"; import { useSettingsStore } from "@/stores/settings-store"; -import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; +import { useEmailStore, removalFolder, canRemoveFromFolder, bulkDeleteIsPermanent } from "@/stores/email-store"; import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags"; import { Email, Mailbox } from "@/lib/jmap/types"; import { @@ -248,8 +249,10 @@ export function EmailContextMenu({ const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; // Marking your own outgoing mail as spam makes no sense - hide the action - // in Sent, Drafts and Scheduled. - const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); + // in Sent, Drafts and Scheduled, and in Trash, where it is already thrown out. + const spamApplicable = spamApplicableIn(currentMailboxRole); + const deleteForever = bulkDeleteIsPermanent({ role: currentMailboxRole }); + const tActions = useTranslations("reader_actions"); const isScheduled = email.isScheduled === true; // One report per selected message, behind one confirm (enterprise pack). const batchPhishing = usePhishingReport(showBatchActions ? (batchEmails ?? []) : [], currentMailboxRole); @@ -397,7 +400,7 @@ export function EmailContextMenu({ {/* Delete */} handleAction(onBatchDelete!)} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 62e8dd6f3..ec02b40c5 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -1,5 +1,6 @@ "use client"; +import { spamApplicableIn } from "@/lib/spam-applicable"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { MemoizedThreadListItem, listMailboxRole } from "./thread-list-item"; import { ListColumnsHeader } from "./list-columns"; @@ -25,6 +26,7 @@ import { usePhishingReport } from "@/hooks/use-phishing-report"; import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; +import { batchToastTitle, selectionRowCount } from "@/lib/selection-toast"; import { Spinner } from "@/components/ui/spinner"; import { EmptyState } from "@/components/ui/empty-state"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; @@ -37,7 +39,7 @@ import { useOpenEmailInWindow } from "@/hooks/use-popouts-available"; import { useIsDesktop } from "@/hooks/use-media-query"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useSettingsStore } from "@/stores/settings-store"; -import { useListRowShape } from "@/hooks/use-list-row-shape"; +import { useListRowShape, showsPreviewOnSubjectLine } from "@/hooks/use-list-row-shape"; import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton"; import type { Density } from "@/lib/density"; import { useUIStore } from "@/stores/ui-store"; @@ -142,6 +144,9 @@ type ListItem = | { kind: 'header'; section: 'unread' | 'rest' } | { kind: 'unread-empty' }; +/** The most messages "Select all N conversations" loads to tick them (mail-p2-11). */ +const SELECT_ALL_LIMIT = 5000; + /** Below this width the batch toolbar drops its labels (see batchNarrow). */ const BATCH_TOOLBAR_WIDE = 640; @@ -206,6 +211,7 @@ export function EmailList({ const tViewer = useTranslations('email_viewer'); const tSidebar = useTranslations('sidebar'); const tListExtra = useTranslations('mail_list_extra'); + const tRoot = useTranslations(); const tUnread = useTranslations('account_unread'); const mountainViewList = useSettingsStore((state) => state.interfaceLayout === 'mountain-view'); const isDesktopPane = useIsDesktop(); @@ -283,7 +289,7 @@ export function EmailList({ mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); // Spam is pointless on your own outgoing mail, as in the context menu. - const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(effectiveMailboxRole || ''); + const spamApplicable = spamApplicableIn(effectiveMailboxRole); const disableThreading = useSettingsStore((state) => state.disableThreading); // The order the current folder view was fetched in (#718), so thread @@ -486,7 +492,11 @@ export function EmailList({ // up after a jump down (scrollbar drag, End). So guess per row. const latest = item?.kind === 'row' ? item.thread.latestEmail : undefined; let size = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density]; - if (showPreview && density !== 'extra-compact') { + if (showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: mountainViewList })) { + // The preview follows the subject on its line, and the subject line + // drops its margin (mail-15). + size -= 4; + } else if (showPreview && density !== 'extra-compact') { // A mail without a preview draws a one-line "No preview available", // a line (23px) shorter than a real one. Only comfortable rows show a // second preview line (mail-15). @@ -497,7 +507,7 @@ export function EmailList({ const hasCode = !!latest && showVerificationCodes && !!listVerificationCode(latest); if (latest && (hasCode || (onOpenAttachment && listRowShowsChips(latest, loadAttachments)))) size += 28; return size; - }, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments]); + }, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments, isMobile, mountainViewList]); // Stable per list, so the virtualizer does not rebuild every row's // measurement on each scroll render. @@ -571,6 +581,9 @@ export function EmailList({ () => (hasSelection ? resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }) : []), [hasSelection, emails, threadEmailsCache, selectedEmailKeys], ); + // The rows the selection covers: what the bar, the pane and the toasts + // count (mail-18). A conversation row ticks all its loaded messages. + const selectionRows = selectionRowCount(selectedEmailsForRemoval, selectedEmailKeys.size, disableThreading); // Report phishing for the selection, one report per message (enterprise pack). const selectionPhishing = usePhishingReport(selectedEmailsForRemoval, effectiveMailboxRole); const tPhishing = useTranslations('phishing_report'); @@ -680,11 +693,11 @@ export function EmailList({ if (!client || isProcessing) return; if (!(await confirmMailRemoval('archive', selectedEmailKeys.size, confirmDialog, tRemove))) return; setIsProcessing(true); - const count = selectedEmailKeys.size; + const rows = selectionRows; const restore = restoreBatchesForSelection(client); try { await runBatchEmailAction(() => batchArchive(client), { - success: tNotifications('emails_archived', { count }), + success: batchToastTitle(tRoot, 'archive', rows, disableThreading), error: tNotifications('error_archiving'), describeError: (error) => error instanceof ArchiveMailboxNotFoundError ? tViewer('archive_mailbox_not_found') @@ -699,7 +712,7 @@ export function EmailList({ const handleBatchMoveToMailbox = async (mailboxId: string) => { if (!client || isProcessing) return; setIsProcessing(true); - const count = selectedEmailKeys.size; + const rows = selectionRows; // Undo puts the messages back; those a mixed-account list moved to // another account move back the same way (the receipts). const restore = restoreBatchesForSelection(client); @@ -707,10 +720,8 @@ export function EmailList({ try { const destination = mailboxes.find((m) => m.id === mailboxId); await runBatchEmailAction(() => batchMoveToMailbox(client, mailboxId, undefined, receipts), { - // "3 emails moved to Family" (mail-18). - success: destination - ? tListExtra('moved_count_to', { count, mailbox: getMailboxPath(destination, mailboxes) }) - : tNotifications('emails_moved', { count }), + // "3 conversations moved to Family" (mail-18). + success: batchToastTitle(tRoot, 'move', rows, disableThreading, destination ? getMailboxPath(destination, mailboxes) : undefined), error: tNotifications('move_failed'), undo: undoMoveAction(client, restore, undo, receipts), }); @@ -744,11 +755,11 @@ export function EmailList({ } setIsProcessing(true); - const count = selectedEmailKeys.size; + const rows = selectionRows; const restore = isInTrash ? null : restoreBatchesForSelection(client); try { await runBatchEmailAction(() => batchDelete(client, isInTrash), { - success: tNotifications('emails_deleted', { count }), + success: batchToastTitle(tRoot, isInTrash ? 'delete' : 'trash', rows, disableThreading), error: tNotifications('error_deleting'), undo: undoMoveAction(client, restore, undo), }); @@ -773,11 +784,17 @@ export function EmailList({ // reading pane. The pane's copies get their own test ids. const selectionButtons = (where: 'bar' | 'pane') => { const testId = (id: string) => (where === 'bar' ? id : `pane-${id}`); + // The pane has room: its buttons say what they do (mail-p2-15). The + // bar beside the list stays icons with tooltips. + const labelled = where === 'pane'; + const shape = labelled ? { size: 'sm' as const, title: '', className: 'gap-1.5' } : {}; + const name = (label: string) => (labelled ? {label} : null); return ( <> {archiveActionEnabled && effectiveMailboxRole !== 'archive' && ( - void handleBatchArchive()} disabled={isProcessing} data-testid={testId('batch-archive')}> + void handleBatchArchive()} disabled={isProcessing} data-testid={testId('batch-archive')} {...shape}> + {name(tContextMenu('archive'))} )} + {name(t('batch_actions.delete'))} {!moveMenuIsEmpty(selectionMoveModel) && ( moveMenu.openContextMenuFrom(e.currentTarget, null, 'end')} disabled={isProcessing} data-testid={testId('batch-move')} + {...shape} > + {name(tContextMenu('move_to'))} )} handleBatchMarkAsRead(selectionHasUnread)} disabled={isProcessing} data-testid={testId('batch-mark-read')} + {...shape} > {selectionHasUnread ? : } + {name(selectionHasUnread ? t('batch_actions.mark_read') : t('batch_actions.mark_unread'))} m.id === selectedMailbox); const isEmptyableFolder = currentMailbox?.role === 'trash' || currentMailbox?.role === 'junk'; + // Every loaded row ticked and more on the server: offer the rest, as Gmail + // does (mail-p2-11). Taking it loads the remaining pages and ticks them, + // so the batch actions, their Undo and their counts work as for any + // selection. At most SELECT_ALL_LIMIT messages are loaded for it. + const [selectingAll, setSelectingAll] = useState(false); + const allLoadedSelected = hasSelection && emails.length > 0 && emails.every((e) => selectedEmailKeys.has(emailKeyFor(e))); + const offerSelectAll = allLoadedSelected && footerHasMore && !isScheduledView && !hideBatchToolbar && !!client + && emails.length < SELECT_ALL_LIMIT; + const inPlainFolder = !!currentMailbox && !searchQuery.trim() && isFilterEmpty(searchFilters) && !isUnifiedView; + const viewTotal = Math.max( + inPlainFolder + ? (disableThreading ? currentMailbox.totalEmails : (currentMailbox.totalThreads || currentMailbox.totalEmails)) + : totalEmails, + selectionRows + 1, + ); + const selectAllLabel = inPlainFolder + ? tRoot(disableThreading ? 'mail_p2_list.select_all_emails_in_folder' : 'mail_p2_list.select_all_in_folder', { + count: viewTotal, + folder: localizeMailboxName(currentMailbox.role, currentMailbox.name, (k) => tSidebar(`mailboxes.${k}`)), + }) + : tRoot(disableThreading ? 'mail_p2_list.select_all_emails' : 'mail_p2_list.select_all_conversations', { count: viewTotal }); + const handleSelectAllInView = async () => { + if (!client || selectingAll) return; + const view = () => { + const state = useEmailStore.getState(); + return `${state.selectedMailbox}|${state.searchQuery}|${state.isUnifiedView}|${state.unifiedRole}`; + }; + const startedIn = view(); + setSelectingAll(true); + try { + for (let pages = 0; pages < 1000; pages++) { + const state = useEmailStore.getState(); + if (view() !== startedIn || state.selectedEmailKeys.size === 0) return; + if (!state.hasMoreEmails || state.emails.length >= SELECT_ALL_LIMIT) break; + if (state.isLoadingMore) { + await new Promise((resolve) => setTimeout(resolve, 50)); + continue; + } + const before = state.emails.length; + await state.loadMoreEmails(client); + const after = useEmailStore.getState(); + if (after.emails.length === before && !after.isLoadingMore) break; + } + // Another folder opened or the selection was cleared meanwhile: leave it. + if (view() !== startedIn || useEmailStore.getState().selectedEmailKeys.size === 0) return; + useEmailStore.getState().selectAllEmails(); + } finally { + setSelectingAll(false); + } + }; + const handleEmptyFolder = async () => { if (!client || isProcessing || !currentMailbox) return; @@ -1106,7 +1181,7 @@ export function EmailList({ inert={!(hasSelection && !isScheduledView && !hideBatchToolbar)} className={cn( "transition-all duration-300 ease-in-out overflow-hidden", - hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0" + hasSelection && !isScheduledView && !hideBatchToolbar ? (offerSelectAll || selectingAll ? "max-h-28 opacity-100" : "max-h-16 opacity-100") : "max-h-0 opacity-0" )} > {/* Only drawn while shown: collapsed it would still render every button. */} @@ -1115,7 +1190,7 @@ export function EmailList({
{/* "5 conversations": the bar is narrow beside its buttons. */} - {disableThreading ? selectionTitle : tListExtra('conversation_count', { count: selectedEmailKeys.size })} + {disableThreading ? selectionTitle : tListExtra('conversation_count', { count: selectionRows })}
@@ -1202,6 +1277,19 @@ export function EmailList({
)} + {hasSelection && !isScheduledView && !hideBatchToolbar && (offerSelectAll || selectingAll) && ( +
+ +
+ )}
{/* Advanced Search Filter Chips */} @@ -1430,7 +1518,7 @@ export function EmailList({

{selectionTitle}

-
+
{selectionButtons('pane')} = { const REQUIRED_TOOLBAR_PRIORITIES = [5, 3, 2, 4, 1] as const; /** Set on the toolbar while it shows icons without their labels. */ const TOOLBAR_COMPACT_ATTRIBUTE = 'data-toolbar-compact'; +/** Which labels the reader toolbar has dropped: none, all but the main four, or all. */ +type ToolbarCompact = false | 'secondary' | 'all'; const NO_ACTION_KEYS: ReadonlySet = new Set(); const NO_PLUGIN_KEYS: ReadonlySet = new Set(); @@ -300,10 +304,17 @@ interface EmailViewerProps { /** Mountain View: reply to one message of the conversation. */ onReplyConversationEmail?: (email: Email) => void; onReplyAllConversationEmail?: (email: Email) => void; + /** Forward on a message of the conversation. */ + onForwardConversationEmail?: (email: Email) => void; /** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */ mvInlineComposer?: React.ReactNode; /** A reply or forward to this message is open under it: no quick reply box as well. */ replyComposerOpen?: boolean; + /** + * 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. */ @@ -781,11 +792,82 @@ function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string { return ago ? `${when} (${ago})` : when; } +/** + * The date on a conversation message in the default layout: the time today, + * "Oct 21, 13:40" this year, "Oct 21, 2025" before. The full date is on hover. + */ +export function conversationShortDate(date: Date | string, timeFormat: '12h' | '24h', now: Date = new Date()): string { + const d = typeof date === 'string' ? new Date(date) : date; + const zone = getEffectiveTimeZone(); + const day = Number.isNaN(d.getTime()) ? null : getWallClock(d, zone); + const today = getWallClock(now, zone); + if (day && day.year === today.year && day.month === today.month && day.day === today.day) return formatDateTime(d, timeFormat); + if (day && day.year === today.year) return formatDateTime(d, timeFormat, { month: 'short', day: 'numeric' }); + return formatDateTime(d, timeFormat, { year: 'numeric', month: 'short', day: 'numeric', dateOnly: true }); +} + +function conversationFullDate(date: Date | string, timeFormat: '12h' | '24h'): string { + return formatDateTime(date, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); +} + +/** + * The date on a conversation message's sender line: Mountain View keeps + * Gmail's "Oct 21, 2026, 13:40 (14 days ago)", the phone the list's date, the + * default layout the short date. Every style has the full date on hover. + */ +/** + * How a conversation opens around the open message (mail-p2-4): its unread + * messages unfolded, as Gmail does, and the newest one too, also when it is + * your own reply. The open message folds when a newer one is shown and it had + * been read already. Drafts and messages still waiting to be sent are never + * "the newest". + */ +export function conversationOpenPlan(conversation: Email[], openId: string): { expanded: string[]; foldOpen: boolean; newestId: string | null } { + const expanded = conversation.filter((e) => e.id !== openId && !e.keywords?.$seen).map((e) => e.id); + const open = conversation.find((e) => e.id === openId); + const candidates = conversation.filter((e) => !e.keywords?.$draft && !e.isScheduled && !e.sentEcho); + const newest = candidates[candidates.length - 1]; + if (!open || !newest || newest.id === openId) return { expanded, foldOpen: false, newestId: null }; + if (!expanded.includes(newest.id)) expanded.push(newest.id); + return { expanded, foldOpen: !!open.keywords?.$seen, newestId: newest.id }; +} + +/** + * The reading pane's scroll position that shows the message being answered + * above a docked reply: its end at the pane's bottom edge, or its start at + * the top when the whole message doesn't fit. + */ +export function replyScrollTop(pane: HTMLElement, end: HTMLElement, start: HTMLElement | null): number { + const paneTop = pane.getBoundingClientRect().top; + const endY = end.getBoundingClientRect().top - paneTop + pane.scrollTop; + const startY = start ? start.getBoundingClientRect().top - paneTop + pane.scrollTop : 0; + const toEnd = endY - pane.clientHeight; + return Math.max(0, startY > toEnd ? toEnd : startY); +} + +/** A button's tooltip with the key the menus and the shortcut help show (mail-p2-13). */ +function useKeyTip() { + const shortcut = useMessageActionShortcut(); + return (label: string, action: MessageActionKey) => { + const key = shortcut(action); + return key ? `${label} (${key})` : label; + }; +} + +function ConversationDate({ date, timeFormat, variant, className }: { date: string; timeFormat: '12h' | '24h'; variant: 'mv' | 'list' | 'short'; className?: string }) { + const shown = variant === 'mv' ? mvSenderDate(date, timeFormat) : variant === 'list' ? formatDate(date) : conversationShortDate(date, timeFormat); + return ( + + ); +} + /** * One message of a Mountain View conversation, folded to its sender, date and * snippet. Clicking it unfolds the message in place. */ -function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean }) { +function MvConversationCard({ email, onOpen, timeFormat, compact, shortDates }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean; shortDates?: boolean }) { const tSidebar = useTranslations('sidebar'); const sender = email.from?.[0]; const senderWarning = useSenderWarning(email); @@ -810,9 +892,7 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema {email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')} )} - - {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} - + {cleanPreview(email.preview)} @@ -872,22 +952,28 @@ export function MvExpandedConversationCard({ onMarkRead, onReply, onReplyAll, + onForward, onDownloadAttachment, blobSource, compact, + shortDates, }: { email: Email; timeFormat: '12h' | '24h'; compact?: boolean; + /** The default layout's short date instead of Gmail's long one. */ + shortDates?: boolean; onCollapse: (email: Email) => void; onLoadBody?: (email: Email) => void; onMarkRead?: (email: Email) => void; onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; + onForward?: (email: Email) => void; onDownloadAttachment?: ConversationAttachmentHandler; blobSource?: EmailBlobContext; }) { const t = useTranslations('email_viewer'); + const keyTip = useKeyTip(); const tSidebar = useTranslations('sidebar'); const externalContent = useConversationExternalContent(); const sender = email.from?.[0]; @@ -940,9 +1026,7 @@ export function MvExpandedConversationCard({ - - {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} - + - {(onReply || onReplyAll) && !email.keywords?.$draft && !email.isScheduled && ( + {(onReply || onReplyAll || onForward) && !email.keywords?.$draft && !email.isScheduled && ( {onReply && ( )} + {onForward && ( + + )} )}
@@ -1022,9 +1117,10 @@ export function MvExpandedConversationCard({ * run of folded messages before it shows only its first and last message * around a count, as Gmail does, and the count unfolds the rest. */ -export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, expandedIds, renderExpanded, compact }: { +export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, expandedIds, renderExpanded, compact, shortDates }: { emails: Email[]; compact?: boolean; + shortDates?: boolean; /** A folded message was clicked. */ onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; @@ -1038,7 +1134,7 @@ export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, const isExpanded = (e: Email) => !!renderExpanded && !!expandedIds?.has(e.id); const renderOne = (e: Email) => isExpanded(e) ? {renderExpanded!(e)} - : ; + : ; const middle = emails.slice(1, -1); if (!collapseLongRun || showAll || emails.length <= 3 || middle.some(isExpanded)) { return <>{emails.map(renderOne)}; @@ -1374,8 +1470,10 @@ export function EmailViewer({ onMarkConversationEmailRead, onReplyConversationEmail, onReplyAllConversationEmail, + onForwardConversationEmail, mvInlineComposer, replyComposerOpen, + replyDraft, onEditDraft, onCancelScheduledForEdit, onCancelScheduledSend, @@ -1400,6 +1498,7 @@ export function EmailViewer({ const tWindows = useTranslations('pwaWindows'); const tComposer = useTranslations('email_composer'); const tWords = useTranslations('compose_words'); + const tList = useTranslations('email_list'); const tNotifications = useTranslations('notifications'); const tCommon = useTranslations('common'); const tFiles = useTranslations('files'); @@ -1491,14 +1590,13 @@ export function EmailViewer({ // destroys the mail, Delete says "Delete forever". const isInTrashFolder = currentMailboxRole === 'trash'; const tActions = useTranslations('reader_actions'); - const tSidebarNames = useTranslations('sidebar'); // Read for the re-render: bulkDeleteIsPermanent reads them from the store. useSettingsStore((state) => state.deleteAction); useSettingsStore((state) => state.permanentlyDeleteJunk); const deleteIsPermanent = bulkDeleteIsPermanent({ role: currentMailboxRole }); // Marking your own outgoing mail as spam makes no sense - hide the action // in Sent, Drafts and Scheduled. - const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); + const spamApplicable = spamApplicableIn(currentMailboxRole); // Detect if the email is a draft const isDraft = email?.keywords?.['$draft'] === true; @@ -1537,6 +1635,11 @@ export function EmailViewer({ const mvExpandedIdsRef = useRef(mvExpandedIds); mvExpandedIdsRef.current = mvExpandedIds; const mvExpandedThreadRef = useRef(null); + // The open message folds too, but only inside a conversation. + const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false); + useEffect(() => setMvOpenCollapsed(false), [email?.id]); + // The newest message to unfold and scroll to once the conversation is drawn. + const [mvNewestToShow, setMvNewestToShow] = useState(null); useEffect(() => { if (!mvConversation || mvConversation.length === 0 || !email) { mvExpandedThreadRef.current = null; @@ -1545,8 +1648,36 @@ export function EmailViewer({ const key = mvConversation[0].threadId; if (mvExpandedThreadRef.current === key) return; mvExpandedThreadRef.current = key; - setMvExpandedIds(new Set(mvConversation.filter((e) => e.id !== email.id && !e.keywords?.$seen).map((e) => e.id))); + const plan = conversationOpenPlan(mvConversation, email.id); + setMvExpandedIds(new Set(plan.expanded)); + if (plan.foldOpen) setMvOpenCollapsed(true); + setMvNewestToShow(plan.newestId); }, [mvConversation, email]); + useEffect(() => { + if (!mvNewestToShow) return; + const target = mvNewestToShow; + setMvNewestToShow(null); + const frame = requestAnimationFrame(() => { + const card = document.querySelector(`[data-mv-conversation-expanded][data-email-id="${CSS.escape(target)}"]`); + const pane = card?.closest('[data-reader-scroll]'); + if (!card || !pane) return; + // Bring the newest message's start into view when it's below the fold. + const cardTop = card.getBoundingClientRect().top - pane.getBoundingClientRect().top; + if (cardTop > pane.clientHeight - 96) pane.scrollTop += cardTop - 8; + }); + return () => cancelAnimationFrame(frame); + }, [mvNewestToShow]); + // A reply just sent joins the end of the conversation, folded like the + // others: bring it into view so it's seen to be there (mail-p2-1). + const lastEchoId = mvConversation?.filter((e) => e.sentEcho).at(-1)?.id ?? null; + useEffect(() => { + if (!lastEchoId) return; + const frame = requestAnimationFrame(() => { + document.querySelector(`[data-mv-conversation-card][data-email-id="${CSS.escape(lastEchoId)}"]`) + ?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }); + }); + return () => cancelAnimationFrame(frame); + }, [lastEchoId]); const toggleMvExpanded = useCallback((target: Email) => { emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id)); setMvExpandedIds((prev) => { @@ -1556,10 +1687,11 @@ export function EmailViewer({ return next; }); }, []); - // The open message folds too, but only inside a conversation. - const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false); - useEffect(() => setMvOpenCollapsed(false), [email?.id]); const mvOpenFolded = mvOpenIndex >= 0 && mvOpenCollapsed; + // The default layout's conversation (not Mountain View): the open message + // gets the same header as the others, with its date and its reply actions + // on the sender's line, and the subject line no date (mail-p2-4). + const inConversation = mvOpenIndex >= 0 && !mvViewer; // Folding or unfolding the open message of a conversation, told to plugins // like any other conversation message (onThreadExpand). const setMvOpenFolded = (folded: boolean) => { @@ -1579,6 +1711,8 @@ export function EmailViewer({ onMarkRead={onMarkConversationEmailRead} onReply={onReplyConversationEmail} onReplyAll={onReplyAllConversationEmail} + onForward={onForwardConversationEmail} + shortDates={!mvViewer} blobSource={target.sourceClientAccountId || target.sourceAccountId ? resolveEmailBlobContext(target, client) : blobSource ?? resolveEmailBlobContext(target, client)} @@ -1590,6 +1724,13 @@ export function EmailViewer({ const simpleMode = useSettingsStore((state) => state.simpleMode); // Simple mode always names the toolbar's buttons in words. const showToolbarLabels = (showToolbarLabelsSetting || simpleMode) && interfaceLayout !== 'mountain-view'; + // Tooltips name the same key the message menus and the shortcut help do + // (mail-p2-13), and none when single keys are off. + const messageActionShortcut = useMessageActionShortcut(); + const withShortcut = (label: string, action: MessageActionKey) => { + const key = messageActionShortcut(action); + return key ? `${label} (${key})` : label; + }; // Inside a Pro pane, `isMobile` above is pane-width based: overlays that // would go viewport-fixed must instead cover just the pane (via // PaneOverlay + absolute positioning), and viewport CSS breakpoints like @@ -1661,7 +1802,13 @@ export function EmailViewer({ }, [onRescheduleScheduled, t, tComposer, scheduledSendAt, timeFormat]); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const { startTour } = useTour(); - const [showFullHeaders, setShowFullHeaders] = useState(false); + // The details panel belongs to the message it was opened on: the next + // message opens without it (mail-7). + const shownEmailId = email?.id ?? null; + const [details, setDetails] = useState<{ emailId: string | null; open: boolean }>({ emailId: shownEmailId, open: false }); + if (details.emailId !== shownEmailId) setDetails({ emailId: shownEmailId, open: false }); + const showFullHeaders = details.open && details.emailId === shownEmailId; + const setShowFullHeaders = (open: boolean) => setDetails({ emailId: shownEmailId, open }); const [isDownloadingAll, setIsDownloadingAll] = useState(false); const [imageThumbUrls, setImageThumbUrls] = useState>({}); const [allowExternalContent, setAllowExternalContent] = useState(false); @@ -1854,7 +2001,7 @@ export function EmailViewer({ // looked like (see layoutKey), and the width it last had: an open that // brings back a toolbar already measured at this width needs no measuring. // A loading message and a loaded one can differ, so a few are kept. - const measuredToolbarRef = useRef(new Map; compact: boolean }>()); + const measuredToolbarRef = useRef(new Map; compact: ToolbarCompact }>()); const toolbarWidthRef = useRef(undefined); const currentTagIds = getEmailTagIds(email?.keywords); const sortedTagIds = sortTagIds(currentTagIds); @@ -2117,7 +2264,8 @@ export function EmailViewer({ const key = layoutKey(items); const known = toolbarWidthRef.current !== undefined ? measuredToolbarRef.current.get(key) : undefined; if (known) { - el.toggleAttribute(TOOLBAR_COMPACT_ATTRIBUTE, known.compact); + if (known.compact) el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, known.compact); + else el.removeAttribute(TOOLBAR_COMPACT_ATTRIBUTE); for (const item of items) item.style.display = known.hidden.has(Number(item.dataset.overflowPriority)) ? 'none' : ''; applyHidden(known.hidden); return; @@ -2135,10 +2283,13 @@ export function EmailViewer({ const containerWidth = el.clientWidth; const overflowNow = () => leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap - (containerWidth + 1); let overflow = overflowNow(); - // The labels go before any button does (icons keep their tooltips). - const compact = overflow > 0; - if (compact) { - el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, ''); + // The labels go before any button does (icons keep their tooltips): + // first all but Reply, Archive, Delete and Move, then those too. + let compact: ToolbarCompact = false; + for (const step of ['secondary', 'all'] as const) { + if (overflow <= 0) break; + compact = step; + el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, step); overflow = overflowNow(); } const hidden = new Set(); @@ -2382,6 +2533,23 @@ export function EmailViewer({ setEmbeddedEmailAttachments(NO_POSTAL_ATTACHMENTS); }, [email?.id, externalContentPolicy]); + // Undo send on a quick reply puts its text back here, where it was + // written (mail-p2-8). After the reset above, which runs on the same open. + const quickReplyRestoreWaiting = useQuickReplyRestoreStore((s) => !!email && s.pending?.emailId === email.id); + useEffect(() => { + if (!quickReplyRestoreWaiting || !email) return; + const text = useQuickReplyRestoreStore.getState().take(email.id); + if (text === null) return; + setQuickReplyText(text); + setIsQuickReplyFocused(true); + requestAnimationFrame(() => { + const box = quickReplyRef.current; + if (!box) return; + box.focus(); + box.setSelectionRange(box.value.length, box.value.length); + }); + }, [quickReplyRestoreWaiting, email]); + // Crypto-plugin body takeover (S/MIME, PGP, …). A privileged crypto plugin // can fetch the raw message via api.jmap.fetchBlob, decrypt/verify it, and // return a replaced body through the onRenderEmailBody hook. The host stays @@ -3514,8 +3682,46 @@ 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 + // A reply docks under the message it answers: keep that message in view + // above it, its end just over the dock, or its start when it is taller + // than what is left of the pane (mail-p2-2). + useEffect(() => { + if (!replyComposerOpen || !email) return; + // The message answered is read while answering: unfold it. + setMvOpenCollapsed(false); + let frame = 0; + const place = () => { + const pane = document.querySelector('[data-reader-scroll]'); + const end = pane?.querySelector('[data-open-message-end]'); + if (!pane || !end) return; + const start = pane.querySelector('[data-mv-open-header]') ?? pane.querySelector('[data-mv-open-folded]'); + pane.scrollTop = replyScrollTop(pane, end, start); + }; + // Once the dock has taken its room, and again when its height settles. + frame = requestAnimationFrame(() => { frame = requestAnimationFrame(place); }); + const timer = setTimeout(place, 250); + return () => { cancelAnimationFrame(frame); clearTimeout(timer); }; + }, [replyComposerOpen, email?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- once per reply opened + + 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. @@ -4474,10 +4680,10 @@ ${printWatermark ? printWatermark.html : ''} data-overflow-item data-overflow-priority="1" className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0" - title={t('tooltips.reply')} + title={withShortcut(t('reply'), 'reply')} > - {showToolbarLabels && {t('reply')}} + {showToolbarLabels && {t('reply')}} )} {isInTrashFolder && restoreTarget && onMoveToMailbox && ( @@ -4548,10 +4754,11 @@ ${printWatermark ? printWatermark.html : ''} data-overflow-action="" data-testid="reader-restore" className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-1")} - title={tActions('restore_to', { folder: localizeMailboxName(restoreTarget.role, restoreTarget.name, (key) => tSidebarNames(`mailboxes.${key}`)) })} + title={tActions('restore')} + aria-label={tActions('restore')} > - - {showToolbarLabels && {tActions('restore')}} + + {showToolbarLabels && {tActions('restore')}} )} {archiveActionEnabled && !isInJunkFolder && !isInTrashFolder && ( @@ -4562,10 +4769,10 @@ ${printWatermark ? printWatermark.html : ''} data-overflow-item data-overflow-priority="4" className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-1")} - title={t('tooltips.archive')} + title={withShortcut(t('archive'), 'archive')} > - {showToolbarLabels && {t('archive')}} + {showToolbarLabels && {t('archive')}} )} {/* Delete */} @@ -4574,11 +4781,11 @@ ${printWatermark ? printWatermark.html : ''} size="sm" onClick={onDelete} className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-3")} - title={deleteIsPermanent ? tActions('delete_forever') : t('tooltips.delete')} + title={deleteIsPermanent ? tActions('delete_forever') : withShortcut(t('delete'), 'delete')} data-testid="reader-delete" > - {showToolbarLabels && {deleteIsPermanent ? tActions('delete_forever') : t('delete')}} + {showToolbarLabels && {deleteIsPermanent ? tActions('delete_forever') : t('delete')}} {/* Move to folder */} {canMoveAnywhere && onMoveToMailbox && ( @@ -4598,7 +4805,7 @@ ${printWatermark ? printWatermark.html : ''} aria-expanded={moveMenuOpen} > - {showToolbarLabels && {t('move')}} + {showToolbarLabels && {t('move')}}
@@ -5092,7 +5299,7 @@ ${printWatermark ? printWatermark.html : ''} {/* Date/time on the right of subject row - hidden on mobile, shown next to sender. Mountain View puts it on the sender's row instead. The size is in the details. */} -
+
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -5115,7 +5322,7 @@ ${printWatermark ? printWatermark.html : ''} {/* Email Content Area. Mountain View reads the conversation on one plain sheet, so the reply buttons under the message sit on the page colour rather than on a tinted strip. */} -
+
{/* Above the messages of the open conversation. */} @@ -5126,14 +5333,14 @@ ${printWatermark ? printWatermark.html : ''} {/* Mountain View: the conversation's earlier messages, folded. */} {mvBefore.length > 0 && (
- +
)} {/* Mountain View: the open message folded like the rest of the conversation. */} {mvOpenFolded && email ? (
- setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} /> + setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} shortDates={!mvViewer} />
) : mvAwaitingConversation ? (
@@ -5193,6 +5400,9 @@ ${printWatermark ? printWatermark.html : ''} {mvViewer && sender?.email && sender?.name && ( <{sender.email}> )} + {inConversation && sender?.email && sender?.name && ( + <> + )} @@ -5210,12 +5420,12 @@ ${printWatermark ? printWatermark.html : ''} }} /> )} - {mvViewer && ( + {(mvViewer || inConversation) && ( - - {mvSenderDate(emailDisplayDate(email), timeFormat)} + + - + )} {!isDraft && !isScheduled && ( )} + {inConversation && !isDraft && !isScheduled && onForward && ( + + )} )}
- {/* Email address under name (on the name's line under Mountain View) */} - {!mvViewer && sender?.email && sender?.name && ( + {/* Email address under name (on the name's line under Mountain View and in a conversation) */} + {!mvViewer && !inConversation && sender?.email && sender?.name && (
)}
@@ -5462,7 +5683,7 @@ ${printWatermark ? printWatermark.html : ''} type="button" onClick={() => onReply?.()} className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" - title={t('tooltips.reply')} + title={withShortcut(t('reply'), 'reply')} aria-label={t('reply')} > @@ -5472,7 +5693,7 @@ ${printWatermark ? printWatermark.html : ''} type="button" onClick={onReplyAll} className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" - title={t('tooltips.reply_all')} + title={withShortcut(t('reply_all'), 'reply_all')} aria-label={t('reply_all')} > @@ -5502,8 +5723,10 @@ ${printWatermark ? printWatermark.html : ''} }; const replyToDifferent = !!email.replyTo?.length && (!email.from || email.replyTo[0].email !== email.from[0]?.email); + // Received before it was sent means a wrong clock somewhere, not a + // delivery time (mail-29). const deliveryDeltaMs = email.sentAt && email.receivedAt - ? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime()) + ? new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime() : 0; const formatDelta = (diff: number) => { const minutes = Math.floor(diff / 60000); @@ -6155,10 +6378,13 @@ ${printWatermark ? printWatermark.html : ''} + {/* Where the open message ends: a reply docked under it scrolls to here. */} + )} + {/* 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/mail-list-menus.tsx b/components/email/mail-list-menus.tsx index 673171369..c69264028 100644 --- a/components/email/mail-list-menus.tsx +++ b/components/email/mail-list-menus.tsx @@ -11,6 +11,7 @@ import { usePolicyStore } from "@/stores/policy-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { isDefaultOrder, sanitizeSortLevels, type SortLevel } from "@/lib/message-list-order"; +import { emptyFolderLabelKey } from "@/lib/empty-folder-text"; /** The orders the list header offers; Settings has the full editor. */ export const SORT_CHOICES = [ @@ -185,6 +186,7 @@ export const MailFolderMenu = memo(function MailFolderMenu({ onMarkFolderRead={onMarkFolderRead} onMarkAllFoldersRead={onMarkAllFoldersRead} onEmptyFolder={onEmptyFolder} + folderRole={folderRole} onPicked={menu.closeContextMenu} /> @@ -197,14 +199,18 @@ export function MailFolderMenuItems({ onMarkFolderRead, onMarkAllFoldersRead, onEmptyFolder, + folderRole, onPicked, }: { onMarkFolderRead?: () => void; onMarkAllFoldersRead?: () => void; onEmptyFolder?: () => void; + /** Names the entry "Empty Trash" or "Empty Spam". */ + folderRole?: string | null; onPicked: () => void; }) { const tFolder = useTranslations("mailbox_context_menu"); + const tRoot = useTranslations(); return ( <> { onPicked(); onEmptyFolder(); }} /> diff --git a/components/email/message-actions.ts b/components/email/message-actions.ts index 6fdfb03f7..8e707087e 100644 --- a/components/email/message-actions.ts +++ b/components/email/message-actions.ts @@ -2,8 +2,9 @@ import type { ComponentType } from "react"; import { useTranslations } from "next-intl"; +import { spamApplicableIn } from "@/lib/spam-applicable"; import { useSettingsStore } from "@/stores/settings-store"; -import { emailLinkSource } from "@/stores/email-store"; +import { emailLinkSource, bulkDeleteIsPermanent } from "@/stores/email-store"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { buildMailPath } from "@/lib/deep-links"; import { useCopyLink } from "@/hooks/use-copy-link"; @@ -161,6 +162,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr const tPhishing = useTranslations("phishing_report"); const tWindows = useTranslations("pwaWindows"); const tRecover = useTranslations("recover_deleted"); + const tActions = useTranslations("reader_actions"); const copyLink = useCopyLink(); const archiveEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); @@ -175,8 +177,11 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr const isDraft = email.keywords?.["$draft"] === true; const isScheduled = email.isScheduled === true; const inJunk = o.currentMailboxRole === "junk"; - // Marking your own outgoing mail as spam makes no sense. - const spamApplicable = !["sent", "drafts", "scheduled"].includes(o.currentMailboxRole || ""); + // Marking your own outgoing mail as spam makes no sense, nor reporting + // what already sits in the bin. + const spamApplicable = spamApplicableIn(o.currentMailboxRole); + // Deleting from Trash (or with deletes set to be permanent) can't be undone. + const deleteForever = bulkDeleteIsPermanent({ role: o.currentMailboxRole }); const item = ( key: MessageActionKey, @@ -243,7 +248,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr ? [item("restore", Undo, tRecover("restore"), o.onRestore, { testId: "ctx-restore" }), SEPARATOR] : []; const archive = archiveEnabled ? [item("archive", Archive, t("archive"), o.onArchive)] : []; - const del = item("delete", Trash2, t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" }); + const del = item("delete", Trash2, deleteForever ? tActions("delete_forever") : t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" }); const read = item("read", isUnread ? MailOpen : Mail, isUnread ? t("mark_read") : t("mark_unread"), () => o.onMarkAsRead?.(isUnread), { disabled: false, testId: isUnread ? "ctx-mark-read" : "ctx-mark-unread", diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index ae56cb2fc..6bb5d9516 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -18,6 +18,7 @@ import { RefreshCw, Fish, } from "@/components/icons"; +import { spamApplicableIn } from "@/lib/spam-applicable"; import { usePhishingReport } from "@/hooks/use-phishing-report"; import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; @@ -115,6 +116,7 @@ export function MvListToolbar({ const tBatch = useTranslations("email_list.batch_actions"); const archiveActionEnabled = useArchiveActionEnabled(); const tFolder = useTranslations("mailbox_context_menu"); + const tP2 = useTranslations("mail_p2_folders"); const tMenu = useTranslations("context_menu"); const tSidebar = useTranslations("sidebar"); const tCommon = useTranslations("common"); @@ -200,7 +202,7 @@ export function MvListToolbar({ const isInJunk = currentRole === "junk"; const isInTrash = currentRole === "trash"; // Marking your own drafts or sent mail as spam is meaningless. - const spamApplicable = !["sent", "drafts", "scheduled"].includes(currentRole ?? ""); + const spamApplicable = spamApplicableIn(currentRole); // Report phishing for the selection, one report per message (enterprise pack). const selectionForPhishing = useEmailStore(useShallow((state) => resolveSelectedEmails(state))); const phishing = usePhishingReport(selectionForPhishing, currentRole); @@ -429,7 +431,7 @@ export function MvListToolbar({ { closeMenu(); diff --git a/components/email/reader-open-failure.tsx b/components/email/reader-open-failure.tsx new file mode 100644 index 000000000..598ab25fd --- /dev/null +++ b/components/email/reader-open-failure.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { CloudOff, RefreshCw } from "@/components/icons"; +import { Button } from "@/components/ui/button"; + +/** + * The reading pane when a message couldn't be fetched: offline (in a browser + * that keeps no mail on the device), or another failure. A short line and + * Retry, instead of an empty pane that says nothing. + */ +export function ReaderOpenFailure({ offline, onRetry }: { offline: boolean; onRetry: () => void }) { + const t = useTranslations("mail_p2_desk"); + return ( +
+
+ ); +} 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 61b48b436..89835ab3d 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. */ @@ -747,26 +748,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(() => { @@ -782,7 +800,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); @@ -1477,16 +1494,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} /> (disableThreading ? emails.length : groupEmailsByThread(emails).length), [emails, disableThreading], ); + // More to load: the rows are conversations when the list groups them, so + // "100+ results" above them reads wrong as well (mail-16). + if (hasMore && !disableThreading) return <>{t("email_list.conversations_count_plus", { count: conversations })}; if (hasMore) return <>{t("advanced_search.results_found_more", { count: emails.length })}; if (conversations === emails.length) return <>{t("advanced_search.results_found", { count: emails.length })}; return <>{t("mail_list_extra.search_count", { conversations, messages: emails.length })}; diff --git a/components/email/send-warnings-dialog.tsx b/components/email/send-warnings-dialog.tsx index a29709903..0ea8a44eb 100644 --- a/components/email/send-warnings-dialog.tsx +++ b/components/email/send-warnings-dialog.tsx @@ -20,6 +20,8 @@ interface SendWarningsDialogProps { onBack: () => void; /** "Send anyway". `dontAskAgain`: the box under a single warning that has a setting. */ onSendAnyway: (dontAskAgain: boolean) => void; + /** Asked before Schedule send: the button says "Schedule anyway". */ + scheduling?: boolean; } /** The warnings that can be switched off in Settings (subject and placeholders). */ @@ -30,7 +32,7 @@ const HAS_SETTING = new Set(["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..c9b1687d8 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, @@ -204,7 +211,11 @@ export function ThreadConversationView({ }: ThreadConversationViewProps) { const t = useTranslations(); const externalContent = useConversationExternalContent(); - const emailIdList = emails.map((email) => email.id).join(','); + // Oldest first, the newest at the bottom, as on every other layout and in + // Gmail (mail-p2-6). The thread arrives newest first. + const ordered = useMemo(() => oldestFirst(emails), [emails]); + const newestId = newestReadable(ordered)?.id ?? null; + const emailIdList = ordered.map((email) => email.id).join(','); const { isMobile: summaryOnPhone, isCompact: summaryOnTablet } = useDeviceDetection(); const threadSummaryProps = useMemo( () => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [], deviceType: summaryOnPhone ? 'mobile' : summaryOnTablet ? 'tablet' : 'desktop' }), @@ -213,28 +224,41 @@ export function ThreadConversationView({ // Track which emails are expanded (most recent by default) const [expandedIds, setExpandedIds] = useState>(new Set()); + const cardsRef = useRef(null); + const scrolledThreadRef = useRef(null); - // Auto-expand most recent email AND all unread emails when thread opens + // Auto-expand the newest email (your own reply too) AND all unread emails + // when the thread opens. useEffect(() => { - if (emails.length > 0) { + if (ordered.length > 0) { const idsToExpand = new Set(); - // Always expand most recent - idsToExpand.add(emails[0].id); + if (newestId) idsToExpand.add(newestId); // Also expand all unread emails - emails.forEach(email => { - if (!email.keywords?.$seen) { + ordered.forEach(email => { + if (!email.keywords?.$seen && !email.keywords?.$draft) { idsToExpand.add(email.id); } }); setExpandedIds(idsToExpand); } - }, [emails]); + }, [ordered, newestId]); + + // Once per conversation, show the newest message: it is at the bottom. + useEffect(() => { + const threadId = ordered[0]?.threadId ?? null; + if (!newestId || !threadId || scrolledThreadRef.current === threadId) return; + scrolledThreadRef.current = threadId; + const frame = requestAnimationFrame(() => { + cardsRef.current?.querySelector(`[data-thread-email-id="${CSS.escape(newestId)}"]`)?.scrollIntoView?.({ block: 'start' }); + }); + return () => cancelAnimationFrame(frame); + }, [ordered, newestId]); const toggleExpanded = (emailId: string) => { - const target = emails.find((e) => e.id === emailId); + const target = ordered.find((e) => e.id === emailId); if (target) emitThreadExpand(target.threadId, [target], !expandedIds.has(emailId)); setExpandedIds(prev => { const next = new Set(prev); @@ -279,32 +303,53 @@ export function ThreadConversationView({ {/* Email Cards */}
    -
    - {emails.map((email, index) => ( +
    + {ordered.map((email) => { + 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} /> - ))} +
    + ); + })}
    ); } +/** A conversation's messages from the oldest to the newest. */ +export function oldestFirst(emails: Email[]): Email[] { + return [...emails].sort((a, b) => (Date.parse(a.receivedAt) || 0) - (Date.parse(b.receivedAt) || 0)); +} + +/** The newest message that isn't a draft or waiting to be sent. */ +export function newestReadable(ordered: Email[]): Email | undefined { + for (let i = ordered.length - 1; i >= 0; i--) { + const e = ordered[i]; + if (!e.keywords?.$draft && !e.isScheduled) return e; + } + return ordered[ordered.length - 1]; +} + // Individual email card component interface EmailCardProps { email: Email; @@ -317,6 +362,9 @@ interface EmailCardProps { onReply?: () => void; onReplyAll?: () => void; onForward?: () => void; + isDraft?: boolean; + onEditDraft?: () => void; + onDiscardDraft?: () => void; onDownloadAttachment?: ConversationAttachmentHandler; /** Routing captured when this conversation was opened. */ blobSource?: EmailBlobContext; @@ -355,6 +403,9 @@ function EmailCard({ onReply, onReplyAll, onForward, + isDraft = false, + onEditDraft, + onDiscardDraft, onDownloadAttachment, blobSource, onMarkAsRead, @@ -410,6 +461,11 @@ function EmailCard({ + {isDraft && ( + + {t('email_list.draft')} + + )} {email.isScheduled && ( @@ -475,6 +531,34 @@ function EmailCard({ in the single reader's More panel on phones. Long names (Arabic "Reply all") wrap to a second line instead of being cut. */}
    + {onEditDraft && ( + + )} + {onDiscardDraft && ( + + )} {onReply && (
-
+
{tagPlacement === 'subject' && tagIds.length > 0 && ( {tagIds.map((id) => ( @@ -986,11 +988,16 @@ const SingleEmailItem = React.forwardRef( : "font-normal text-foreground/90" )}> {subjectSnippet ? : (email.subject || tNoSubject)} + {previewOnSubjectLine && ( + + {' – '}{previewSnippet ? : (trimmedPreview || t('no_preview_available'))} + + )}
- {showPreview && density !== 'extra-compact' && density !== 'compact' && ( + {showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (

( onMarkAsSpam={onMarkAsSpam} onUndoSpam={onUndoSpam} isInJunk={currentMailboxRole === 'junk'} - spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} + spamApplicable={spamApplicableIn(currentMailboxRole)} mailboxRole={currentMailboxRole ?? null} pinToFirstLine={mvRow} /> @@ -1092,6 +1099,7 @@ export const ThreadListItem = React.forwardRef

-
+
{tagPlacement === 'subject' && tagIds.length > 0 && ( {tagIds.map((id) => ( @@ -1774,11 +1782,16 @@ export const ThreadListItem = React.forwardRef {subjectSnippet ? : (latestEmail.subject || tNoSubject)} + {previewOnSubjectLine && ( + + {' – '}{previewSnippet ? : (trimmedPreview || tEmailViewer('no_preview_available'))} + + )}
- {showPreview && density !== 'extra-compact' && density !== 'compact' && ( + {showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (

onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} - spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} + spamApplicable={spamApplicableIn(currentMailboxRole)} mailboxRole={currentMailboxRole ?? null} pinToFirstLine={mvRow} /> diff --git a/components/favicon-badge.tsx b/components/favicon-badge.tsx index 1bc199925..a961d0f29 100644 --- a/components/favicon-badge.tsx +++ b/components/favicon-badge.tsx @@ -1,13 +1,9 @@ "use client"; -import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useFaviconBadge } from "@/hooks/use-favicon-badge"; import { useAppBadge } from "@/hooks/use-app-badge"; -import { useShallow } from "zustand/react/shallow"; -import { useAccountStore } from "@/stores/account-store"; -import { useAuthStore } from "@/stores/auth-store"; -import { totalInboxUnread } from "@/lib/account-inbox-unread"; +import { useAllInboxesUnread } from "@/hooks/use-all-inboxes-unread"; /** * Badges the browser-tab favicon, and — where supported — the installed PWA's @@ -31,33 +27,14 @@ import { totalInboxUnread } from "@/lib/account-inbox-unread"; * Renders nothing. */ export function FaviconBadge() { - // The store's canonical inbox selector. `role === 'inbox'` alone is not - // enough: shared and group inboxes ship in the same `mailboxes` array, so on - // a delegated setup the first match can be somebody else's inbox. - const inboxUnread = useEmailStore( - (s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0, - ); const enabled = useSettingsStore((s) => s.faviconUnreadBadge); - const allAccounts = useSettingsStore((s) => s.unreadBadgeAllAccounts); // With several accounts the badge counts the inbox of each (on by default): // new mail in an account that is not on screen still shows on the icon and - // in the desktop tray. The other accounts' folder lists are what the - // account switcher reads (useProMultiAccountMailboxes); one not loaded yet - // adds nothing. - const othersUnread = useOtherAccountsUnread(allAccounts); - const count = inboxUnread + othersUnread; + // in the desktop tray. + const count = useAllInboxesUnread(); useFaviconBadge(count, enabled); useAppBadge(count, enabled); return null; } - -function useOtherAccountsUnread(enabled: boolean): number { - const activeAccountId = useAuthStore((s) => s.activeAccountId); - const otherIds = useAccountStore( - useShallow((s) => (enabled ? s.accounts.filter((a) => a.isConnected && a.id !== activeAccountId).map((a) => a.id) : [])), - ); - const accountMailboxes = useEmailStore((s) => s.accountMailboxes); - return totalInboxUnread(otherIds, accountMailboxes); -} diff --git a/components/layout/__tests__/mailbox-context-menu-order.test.tsx b/components/layout/__tests__/mailbox-context-menu-order.test.tsx index 947fb0062..54daf6a4b 100644 --- a/components/layout/__tests__/mailbox-context-menu-order.test.tsx +++ b/components/layout/__tests__/mailbox-context-menu-order.test.tsx @@ -12,7 +12,7 @@ const { MailboxContextMenu } = await import('../mailbox-context-menu'); const folder = { id: 'p', name: 'Projects', sortOrder: 1, totalEmails: 2, unreadEmails: 0, myRights: {} } as unknown as Mailbox; describe('MailboxContextMenu', () => { - it('ends with Empty folder and Delete folder', () => { + it('ends with Delete folder', () => { render( { />, ); const labels = screen.getAllByRole('menuitem').map((item) => item.textContent); - expect(labels.slice(-2)).toEqual(['empty_folder_generic', 'delete_folder']); + // A folder of your own has no Empty: it is offered on Trash and Spam only. + expect(labels.slice(-1)).toEqual(['delete_folder']); expect(labels).toContain('refresh'); }); }); diff --git a/components/layout/__tests__/mailbox-context-menu-states.test.tsx b/components/layout/__tests__/mailbox-context-menu-states.test.tsx index 5f6447986..63c89f3b9 100644 --- a/components/layout/__tests__/mailbox-context-menu-states.test.tsx +++ b/components/layout/__tests__/mailbox-context-menu-states.test.tsx @@ -63,7 +63,7 @@ describe('folder menu states', () => { const folder = box('f', 2); renderMenu(folder, [folder]); expect(screen.getByText('offline_header')).toBeInTheDocument(); - for (const name of ['new_subfolder', 'rename', 'menu_import', 'refresh', 'empty_folder_generic', 'delete_folder']) { + for (const name of ['new_subfolder', 'rename', 'menu_import', 'refresh', 'delete_folder']) { expect(item(name).disabled).toBe(true); } // No mail kept on this device: marking read would fail too. diff --git a/components/layout/__tests__/mailbox-context-menu-system-folders.test.tsx b/components/layout/__tests__/mailbox-context-menu-system-folders.test.tsx new file mode 100644 index 000000000..617e43223 --- /dev/null +++ b/components/layout/__tests__/mailbox-context-menu-system-folders.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; + +// Usability test 2026-11-04, mail-20 and pass 2 p2-14: Trash and Spam +// showed Rename and Delete folder greyed out, "Empty folder" instead of +// "Empty Trash", and Rules and Mute notifications; every folder, the Inbox +// included, offered "Empty folder". + +vi.mock('@/components/email/rules-menu', () => ({ FolderRulesSubMenu: () =>

rules
})); + +const { MailboxContextMenu } = await import('../mailbox-context-menu'); + +const box = (id: string, role: string | null) => + ({ id, name: id, role, parentId: null, sortOrder: 1, totalEmails: 5, unreadEmails: 0, myRights: {} }) as unknown as Mailbox; + +function labels(mailbox: Mailbox) { + render( + false, + isAccountMuted: () => false, + hasFolderSettings: () => false, + setMailboxMuted: vi.fn(), + setAccountMuted: vi.fn(), + resetFolderSettings: vi.fn(), + }} + />, + ); + return screen.getAllByRole('menuitem').map((item) => item.textContent); +} + +describe('folder menu of Trash and Spam', () => { + it('Trash: Empty Trash last, no rename, delete, rules or mute', () => { + const items = labels(box('trash', 'trash')); + expect(items[items.length - 1]).toBe('empty_trash'); + for (const gone of ['rename', 'delete_folder', 'rules', 'mute_notifications', 'empty_folder', 'empty_folder_generic']) { + expect(items).not.toContain(gone); + } + }); + + it('Spam: Empty Spam last, no rename, delete, rules or mute', () => { + const items = labels(box('junk', 'junk')); + expect(items[items.length - 1]).toBe('empty_spam'); + for (const gone of ['rename', 'delete_folder', 'rules', 'mute_notifications']) expect(items).not.toContain(gone); + }); + + it('Inbox: no Empty, no rename or delete, but rules and mute', () => { + const items = labels(box('inbox', 'inbox')); + for (const gone of ['empty_folder', 'empty_folder_generic', 'empty_trash', 'empty_spam', 'rename', 'delete_folder']) { + expect(items).not.toContain(gone); + } + expect(items).toContain('rules'); + expect(items).toContain('mute_notifications'); + }); + + it('a folder of your own: rename and delete, no Empty', () => { + const items = labels(box('family', null)); + expect(items).toContain('rename'); + expect(items[items.length - 1]).toBe('delete_folder'); + for (const gone of ['empty_folder', 'empty_folder_generic', 'empty_trash', 'empty_spam']) expect(items).not.toContain(gone); + }); +}); diff --git a/components/layout/__tests__/navigation-rail-badge-name.test.tsx b/components/layout/__tests__/navigation-rail-badge-name.test.tsx index c3baba3de..bf3d0aa5b 100644 --- a/components/layout/__tests__/navigation-rail-badge-name.test.tsx +++ b/components/layout/__tests__/navigation-rail-badge-name.test.tsx @@ -63,3 +63,29 @@ describe.each([ expect(link.textContent).not.toContain('2'); }); }); + +// Usability test 2026-11-04, mail-6: with two accounts the rail said 10 while +// 30 were unread in all; the favicon already counted every account. +describe('Mail entry with several accounts', () => { + const twoAccounts = async () => { + const { useAccountStore } = await import('@/stores/account-store'); + const { useAuthStore } = await import('@/stores/auth-store'); + useAccountStore.setState({ accounts: [{ id: 'acc-a', isConnected: true }, { id: 'acc-b', isConnected: true }] } as never); + useAuthStore.setState({ activeAccountId: 'acc-a' } as never); + useEmailStore.setState({ accountMailboxes: { 'acc-b': [{ id: 'b-in', role: 'inbox', unreadEmails: 5 }] } } as never); + }; + + it('counts the other accounts\' inboxes too', async () => { + await twoAccounts(); + useSettingsStore.setState({ unreadBadgeAllAccounts: true }); + render(); + expect(screen.getAllByRole('link', { name: 'mail, 7 unread' }).length).toBeGreaterThan(0); + }); + + it('counts the open account only when Notifications says so', async () => { + await twoAccounts(); + useSettingsStore.setState({ unreadBadgeAllAccounts: false }); + render(); + expect(screen.getAllByRole('link', { name: 'mail, 2 unread' }).length).toBeGreaterThan(0); + }); +}); diff --git a/components/layout/__tests__/sidebar-folded-column.test.tsx b/components/layout/__tests__/sidebar-folded-column.test.tsx new file mode 100644 index 000000000..02c0f30b2 --- /dev/null +++ b/components/layout/__tests__/sidebar-folded-column.test.tsx @@ -0,0 +1,124 @@ +import { act, render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; + +// Usability test 2026-11-04, pass 2 p2-9: folded to icons (600 to 1024 px), +// the folder column showed eight identical folder icons, no unread marks, +// and no way to reach subfolders without unfolding it. + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/contexts/drag-drop-context', () => ({ + useDragDropContext: () => ({ isDragging: false }), +})); +vi.mock('@/hooks/use-mailbox-drop', () => ({ + useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }), +})); +vi.mock('@/hooks/use-tag-drop', () => ({ + useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }), +})); +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); + +const { Sidebar } = await import('../sidebar'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { useUIStore } = await import('@/stores/ui-store'); + +const box = (id: string, name: string, role: string | null, extra: Partial = {}) => ({ + id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, ...extra, +}) as unknown as Mailbox; + +const mailboxes: Mailbox[] = [ + box('inbox', 'Inbox', 'inbox', { totalEmails: 120, unreadEmails: 4 }), + box('trash', 'Trash', 'trash'), + box('news', 'Newsletters', null, { totalEmails: 30, unreadEmails: 2 }), + box('receipts', 'Receipts', null, { totalEmails: 5 }), + box('y2026', '2026', null, { parentId: 'receipts', totalEmails: 3 }), +]; + +const peek = () => document.querySelector('[data-folder-peek]'); + +describe('folded folder column (default layout)', () => { + beforeEach(() => { + localStorage.clear(); + useAuthStore.setState({ activeAccountId: 'login-a', client: { getAccountId: () => 'me', getCapabilities: () => ({}) } } as never); + useSettingsStore.setState({ hideAccountSwitcher: true, interfaceLayout: 'default', openOnHover: true, unreadCountStyle: 'count' } as never); + useUIStore.setState({ sidebarCollapsed: true } as never); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + useUIStore.setState({ sidebarCollapsed: false } as never); + }); + + it('tells folders of one\'s own apart by their first letter', () => { + render(); + const initials = screen.getAllByTestId('folder-initial').map((el) => el.textContent); + expect(initials).toEqual(expect.arrayContaining(['N', 'R'])); + // System folders keep their own icons. + expect(initials).toHaveLength(2); + }); + + it('shows the unread count on the icon', () => { + render(); + const inbox = document.querySelector('[data-mailbox-id="inbox"]')!; + expect(inbox.querySelector('[data-testid="folded-unread"]')?.textContent).toBe('4'); + const trash = document.querySelector('[data-mailbox-id="trash"]')!; + expect(trash.querySelector('[data-testid="folded-unread"]')).toBeNull(); + }); + + it('shows a dot when counts are dots', () => { + useSettingsStore.setState({ unreadCountStyle: 'dot' } as never); + render(); + const mark = document.querySelector('[data-mailbox-id="news"] [data-testid="folded-unread"]'); + expect(mark).not.toBeNull(); + expect(mark!.textContent).toBe(''); + }); + + it('opens the whole tree from All folders, keyboard included, and Escape folds it back', () => { + render(); + expect(screen.queryByText('Receipts')).toBeNull(); + fireEvent.click(screen.getByTestId('folded-all-folders')); + expect(peek()).not.toBeNull(); + expect(screen.getByText('Receipts')).toBeTruthy(); + // The focus is in the tree, on the open folder. + expect(document.activeElement?.closest('[data-mailbox-id]')?.getAttribute('data-mailbox-id')).toBe('inbox'); + // Subfolders are reachable: Receipts > 2026. + expect(screen.getByText('2026')).toBeTruthy(); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(peek()).toBeNull(); + }); + + it('folds the tree away after a pick', () => { + const { rerender } = render(); + fireEvent.click(screen.getByTestId('folded-all-folders')); + expect(peek()).not.toBeNull(); + rerender(); + expect(peek()).toBeNull(); + }); + + it('folds it away on a press outside', () => { + render(<>); + fireEvent.click(screen.getByTestId('folded-all-folders')); + fireEvent.pointerDown(screen.getByText('elsewhere')); + expect(peek()).toBeNull(); + }); + + it('floats out after the pointer rests on it, not at once, and not with hover opening off', () => { + vi.useFakeTimers(); + const { container } = render(); + fireEvent.mouseEnter(container.firstElementChild!); + expect(peek()).toBeNull(); + act(() => { vi.advanceTimersByTime(400); }); + expect(peek()).not.toBeNull(); + fireEvent.mouseLeave(container.firstElementChild!); + expect(peek()).toBeNull(); + + useSettingsStore.setState({ openOnHover: false } as never); + fireEvent.mouseEnter(container.firstElementChild!); + act(() => { vi.advanceTimersByTime(400); }); + expect(peek()).toBeNull(); + }); +}); diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx index 8aee8b210..da1ff59da 100644 --- a/components/layout/mailbox-context-menu.tsx +++ b/components/layout/mailbox-context-menu.tsx @@ -159,6 +159,7 @@ export function MailboxContextMenu({ notifications, }: MailboxContextMenuProps) { const t = useTranslations("mailbox_context_menu"); + const tP2 = useTranslations("mail_p2_folders"); const tSidebar = useTranslations("sidebar"); const tImport = useTranslations("mail_import"); const tExport = useTranslations("mail_export"); @@ -251,6 +252,8 @@ export function MailboxContextMenu({ } const mailbox = target.mailbox; + // Emptying is offered where people expect it, as "Empty Trash" and "Empty + // Spam"; rules and notifications mean nothing there (mail-20, pass 2 p2-14). const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk"; const isSystem = !!mailbox.role && @@ -305,13 +308,16 @@ export function MailboxContextMenu({ disabled={!onCreateSubfolder || !canCreateChild || offline} testId="mailbox-new-subfolder" /> - handleAction(() => onRenameFolder?.(mailbox.id))} - disabled={!onRenameFolder || !canRename || offline} - testId="mailbox-rename" - /> + {/* The server's own folders can't be renamed or deleted: not shown greyed out. */} + {!isSystem && ( + handleAction(() => onRenameFolder?.(mailbox.id))} + disabled={!onRenameFolder || !canRename || offline} + testId="mailbox-rename" + /> + )} {onShareFolder && ( )} - - {notifications && (() => { + {!isTrashOrJunk && } + {notifications && !isTrashOrJunk && (() => { const muted = notifications.isMailboxMuted(mailbox); return ( {/* Destructive entries last (R6). */} - + {(isTrashOrJunk || !isSystem) && } - handleAction(() => onEmptyFolder?.(mailbox.id))} - disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline} - destructive - /> - handleAction(() => onDeleteFolder?.(mailbox.id))} - disabled={!onDeleteFolder || !canDelete || offline} - destructive - testId="mailbox-delete" - /> + {isTrashOrJunk && ( + handleAction(() => onEmptyFolder?.(mailbox.id))} + disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline} + destructive + testId="mailbox-empty" + /> + )} + {!isSystem && ( + handleAction(() => onDeleteFolder?.(mailbox.id))} + disabled={!onDeleteFolder || !canDelete || offline} + destructive + testId="mailbox-delete" + /> + )} ); } diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index c123da7f6..e36d84345 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -12,7 +12,6 @@ import { useShallow } from "zustand/react/shallow"; import { usePathname, Link, useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { useCalendarStore } from "@/stores/calendar-store"; -import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps"; @@ -34,6 +33,7 @@ import { toUnicodeEmail } from "@/lib/idn"; import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages"; import { NotificationBell } from "@/components/notifications/notification-center"; import { useBottomControls } from "@/hooks/use-bottom-controls"; +import { useAllInboxesUnread } from "@/hooks/use-all-inboxes-unread"; import { writeRailHints, type RailHint } from "@/lib/boot-skeleton/hints"; interface NavItem { @@ -260,7 +260,6 @@ export function NavigationRail({ const { appLogoLightUrl, appLogoDarkUrl } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar }))); - const { mailboxes } = useEmailStore(useShallow((state) => ({ mailboxes: state.mailboxes }))); const client = useAuthStore((s) => s.client); const supportsFiles = client?.supportsFiles() ?? false; const supportsContacts = client?.supportsContacts() ?? false; @@ -277,7 +276,9 @@ export function NavigationRail({ const visibleSidebarApps = useResolvedSidebarApps(); // Pages plugins added (api.ui.setPages); listed after the built-in apps. const pluginPages = usePluginPages(); - const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; + // Every signed-in account's inbox, as the favicon and the tab title count + // it (mail-6), unless Notifications says to count the open one only. + const inboxUnread = useAllInboxesUnread(); const isStalwartAdmin = useStalwartAdmin(); const hasUpdate = useUpdateStore(selectHasUpdate); const updateSeverity = useUpdateStore((s) => s.status?.severity); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 3b41225c1..b07adb2d4 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -22,6 +22,7 @@ import { ChevronUp, Folder, FolderOpen, + FolderSync, User, Users, Palmtree, @@ -172,6 +173,8 @@ interface SidebarProps { const ROW_PX_BASE = FOLDER_ROW_PX_BASE; const CHEVRON_SLOT = FOLDER_CHEVRON_SLOT; const INDENT_STEP = 12; +/** How long the pointer rests on the folded column before the tree floats out. */ +const RAIL_HOVER_DELAY_MS = 350; // Icons and colours follow the JMAP role only. const getIconForMailbox = (role?: string, hasChildren?: boolean, isExpanded?: boolean, id?: string) => { @@ -497,8 +500,22 @@ function SidebarRow({ )} title={isCollapsed ? label : undefined} > - + {icon} + {/* Folded to icons, the count rides on the icon (p2-9); the name says it already. */} + {isCollapsed && unreadForName > 0 && ( + unreadCountStyle === 'dot' ? ( + {!isCollapsed && ( <> @@ -548,6 +565,20 @@ function SidebarRow({ ); } +/** A folder of one's own on the folded strip: its first letter in a small box. */ +function FolderInitial({ name, className }: { name: string; className?: string }) { + const initial = Array.from(name.trim())[0]?.toLocaleUpperCase() ?? '?'; + return ( + + ); +} + /** A folder the server gave a role (inbox, sent, trash...), as opposed to one the user made. */ const isSystemFolder = (node: MailboxNode) => !!node.role; /** Key of the single-account "Your folders" section in the collapsed set. */ @@ -709,6 +740,9 @@ function MailboxTreeItem({ const isVirtualNode = node.id.startsWith('shared-'); const isSelected = selectedMailbox === node.id; const label = sharedAccountName ?? localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); + // Folded to icons, eight folders of one's own were eight identical folder + // icons (p2-9): each shows its first letter instead. + const letterIcon = isCollapsed && !node.role && !sharedAccountName && !isVirtualNode; // Only for the active login's folders: its client tells a missing // accountId (own mailbox) apart. const muted = useMutedMailbox(isVirtualNode || dragAccountId || pickOnly ? null : node); @@ -752,7 +786,9 @@ function MailboxTreeItem({ return ( <> } + icon={letterIcon + ? + : } label={label} testRole={node.role} testName={node.name} @@ -1177,8 +1213,23 @@ function SidebarImpl({ // floats rather than widening the column, or every hover would reflow the // list behind it. const [railHovered, setRailHovered] = useState(false); - const hoverExpanded = mvShell && sidebarCollapsed && railHovered; + // The default layout's folded column floats the whole tree out too, so + // subfolders and folder names are a hover or a click away (p2-9). A hover + // waits a moment there, so passing over the column on the way to the list + // doesn't open it; "All folders" opens it by click, tap or keyboard. + const [railOpen, setRailOpen] = useState(false); + const railHoverTimer = useRef | null>(null); + const rootRef = useRef(null); + const allFoldersRef = useRef(null); + const hoverExpanded = sidebarCollapsed && !isDrawer && (railHovered || railOpen); const isCollapsed = sidebarCollapsed && !hoverExpanded && !isDrawer; + const closeRailPeek = useCallback(() => { + if (railHoverTimer.current) clearTimeout(railHoverTimer.current); + railHoverTimer.current = null; + setRailHovered(false); + setRailOpen(false); + }, []); + useEffect(() => () => { if (railHoverTimer.current) clearTimeout(railHoverTimer.current); }, []); const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId }))); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [expandedTags, setExpandedTags] = useState>(new Set()); @@ -1276,6 +1327,31 @@ function SidebarImpl({ crossAccountActive, restoring: restoringAccounts, scope: unifiedScope, accounts, }), [crossAccountActive, restoringAccounts, accounts, unifiedScope]); const t = useTranslations('sidebar'); + const tP2 = useTranslations('mail_p2_folders'); + + // A pick in the floated tree folds it away again (the hover peek of the + // default layout, and the one "All folders" opened). + const pickedRef = useRef({ mailbox: selectedMailbox, keyword: selectedKeyword }); + useEffect(() => { + const before = pickedRef.current; + pickedRef.current = { mailbox: selectedMailbox, keyword: selectedKeyword }; + if (before.mailbox === selectedMailbox && before.keyword === selectedKeyword) return; + if (railOpen || !mvShell) closeRailPeek(); + }, [selectedMailbox, selectedKeyword, railOpen, mvShell, closeRailPeek]); + // Opened from "All folders": the focus goes to the open folder in the tree, + // and a press anywhere outside folds it away. + useEffect(() => { + if (!railOpen) return; + const root = rootRef.current; + const target = root?.querySelector('nav [aria-current="page"]') + ?? root?.querySelector('nav button:not([disabled])'); + target?.focus(); + const onPointerDown = (e: PointerEvent) => { + if (root && !root.contains(e.target as Node)) closeRailPeek(); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [railOpen, closeRailPeek]); useEffect(() => { const stored = localStorage.getItem('expandedMailboxes'); @@ -1902,10 +1978,32 @@ function SidebarImpl({ return (
{ if (hoverOpens()) setRailHovered(true); } : undefined} - onMouseLeave={mvShell && sidebarCollapsed ? () => setRailHovered(false) : undefined} - data-mv-rail-peek={hoverExpanded ? "" : undefined} + onMouseEnter={sidebarCollapsed && !isDrawer ? () => { + if (!hoverOpens()) return; + if (mvShell) { setRailHovered(true); return; } + if (railHoverTimer.current) clearTimeout(railHoverTimer.current); + railHoverTimer.current = setTimeout(() => setRailHovered(true), RAIL_HOVER_DELAY_MS); + } : undefined} + onMouseLeave={sidebarCollapsed && !isDrawer ? () => { + if (railHoverTimer.current) clearTimeout(railHoverTimer.current); + railHoverTimer.current = null; + setRailHovered(false); + } : undefined} + onKeyDown={railOpen || railHovered ? (e) => { + if (e.key !== 'Escape' || e.defaultPrevented) return; + e.preventDefault(); + const hadFocus = rootRef.current?.contains(document.activeElement); + closeRailPeek(); + if (hadFocus) requestAnimationFrame(() => allFoldersRef.current?.focus()); + } : undefined} + onBlur={railOpen ? (e) => { + const next = e.relatedTarget as Node | null; + if (next && !e.currentTarget.contains(next)) closeRailPeek(); + } : undefined} + data-mv-rail-peek={hoverExpanded && mvShell ? "" : undefined} + data-folder-peek={hoverExpanded && !mvShell ? "" : undefined} className={cn( "relative flex flex-col h-full border-e transition-all duration-300 overflow-hidden", // Mountain View's navigation sits on the canvas, with no rule between @@ -1914,7 +2012,7 @@ function SidebarImpl({ "max-desk:w-full", // Mountain View's collapsed column is 72px (--mv-rail-w), wide enough // for its round compose button; the default layout's rail is 48px. - isCollapsed && !mvShell ? "desk:w-12" : "desk:w-full", + isCollapsed && !mvShell ? "desk:w-12" : hoverExpanded && !mvShell ? "desk:w-64" : "desk:w-full", // Floats out of the rail's own column rather than widening it, so the // message list underneath never moves while the pointer passes over. // Width comes from --mv-rail-peek-w, keyed on data-mv-rail-peek. @@ -2288,6 +2386,20 @@ function SidebarImpl({ {!isCollapsed && } + {/* Folded: the whole tree, subfolders included, one click away (p2-9). */} + {isCollapsed && !mvShell && ( +
+ setRailOpen(true)} + > + + +
+ )} + ({ current: { isMobile: false, isTablet: false, isDesktop: true } })); +// What the stub composer's close does: 'saves' closes and lets the next one +// open, 'fails' keeps the composer (a save the server refused). +const stub = vi.hoisted(() => ({ + close: 'none' as 'none' | 'saves' | 'fails', + closes: [] as string[], + mounts: [] as string[], + restore: null as null | ((data: Record) => void), +})); + +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ onCompose }: { onCompose: () => void }) => , +})); +// 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__/mail-app-held-reply.test.tsx b/components/mail/__tests__/mail-app-held-reply.test.tsx new file mode 100644 index 000000000..fa1ec6fba --- /dev/null +++ b/components/mail/__tests__/mail-app-held-reply.test.tsx @@ -0,0 +1,217 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useToastStore } from '@/stores/toast-store'; +import { useQuickReplyRestoreStore } from '@/stores/quick-reply-restore-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { clearAllHooks } from '@/lib/plugin-hooks'; +import { forgetEmailPrefetches } from '@/lib/email-prefetch'; + +// mail-p2-1: a quick reply that went out shows in the open conversation at +// once. mail-p2-8: during its Undo window it isn't counted in Sent or +// Scheduled and the message isn't marked answered; Undo takes it out of the +// conversation and puts the text back into the quick reply box, leaving no +// draft behind. + +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +type ViewerProps = { mvConversation?: { id: string }[]; onQuickReply?: (body: string) => Promise; onReply?: () => void }; +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ mvConversation, onQuickReply, onReply }: ViewerProps) => ( +
+
{mvConversation ? mvConversation.map((e) => e.id).join(',') : 'no conversation'}
+ + +
+ ), +})); +type ComposerProps = { + mode: string; + initialData?: { draftId?: string | null; body?: string } | null; + onSend?: (data: Record, state?: Record) => Promise; +}; +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: ({ mode, initialData, onSend }: ComposerProps) => ( +
+ +
+ ), +})); +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ scheduledTotal, mailboxes }: { scheduledTotal?: number; mailboxes: Mailbox[] }) => ( +
{`scheduled ${scheduledTotal} sent ${mailboxes.find((m) => m.role === 'sent')?.totalEmails}`}
+ ), +})); +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-top-bar', () => ({ MvTopBar: () => 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: () => })); +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' }), getCachedConfig: () => null })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +function mailbox(id: string, role: string, totalEmails = 3): Mailbox { + return { + id, name: role, role, sortOrder: 0, totalEmails, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, isSubscribed: true, + } as Mailbox; +} +const row = (id: string, minute: number, extra: Partial = {}) => ({ + id, threadId: 't-m1', subject: 'Lunch tomorrow?', receivedAt: new Date(Date.UTC(2026, 9, 31, 10, minute)).toISOString(), + keywords: { $seen: true }, mailboxIds: { inbox: true }, from: [{ name: 'Sam', email: 'sam@example.org' }], + to: [{ name: 'Me', email: 'a@example.org' }], ...extra, +}) as unknown as Email; + +let client: Record | (() => unknown)>; +let sendEmail: ReturnType; +let markEmailKeyword: ReturnType; +let cancelUndoSend: ReturnType; +let folders: Mailbox[]; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + useToastStore.setState({ toasts: [] }); + useQuickReplyRestoreStore.setState({ pending: null }); + clearAllHooks(); + forgetEmailPrefetches(); + folders = [mailbox('inbox', 'inbox'), mailbox('sent', 'sent', 51), mailbox('drafts', 'drafts')]; + const thread = [row('m0', 0, { mailboxIds: { inbox: true } }), row('m1', 30)]; + client = { + getAccountId: () => 'acc', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => true, getSession: () => null, + getMailboxes: vi.fn().mockResolvedValue(folders), + getThread: vi.fn(async (threadId: string) => ({ id: threadId, emailIds: ['m0', 'm1'] })), + getSomeEmails: vi.fn(async (ids: string[]) => thread.filter((e) => ids.includes(e.id))), + getEmail: vi.fn(async (id: string) => ({ ...thread.find((e) => e.id === id)!, bodyValues: {} })), + deleteEmail: vi.fn().mockResolvedValue(undefined), + markAsRead: vi.fn().mockResolvedValue(undefined), + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ + enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', disableThreading: false, + sendDelaySeconds: 5, sendConfirmation: false, placeholderWarningEnabled: false, attachmentReminderEnabled: false, + } as never); + sendEmail = vi.fn(async () => { + const sendAt = new Date(Date.now() + 5000).toISOString(); + useEmailStore.setState({ + pendingUndoSend: { + submissionId: 'sub-1', emailId: 'sent-1', sendAt, isSmime: false, + countsBefore: { sent: 51, scheduled: 0 }, + }, + } as never); + // The server files it in Sent and lists its submission at once. + useEmailStore.setState({ scheduledTotal: 1, mailboxes: folders.map((m) => m.role === 'sent' ? { ...m, totalEmails: 52 } : m) } as never); + return { scheduled: true, emailId: 'sent-1', emailSubmissionId: 'sub-1', sendAt }; + }); + markEmailKeyword = vi.fn().mockResolvedValue(undefined); + cancelUndoSend = vi.fn(async () => { + useEmailStore.setState({ pendingUndoSend: null } as never); + return { ...row('sent-1', 40), id: 'sent-1', mailboxIds: { drafts: true }, keywords: { $draft: true } }; + }); + const emails = [row('m1', 30)]; + useEmailStore.setState({ + emails, selectedEmail: emails[0], selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: folders, + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', scheduledTotal: 0, pendingUndoSend: null, + selectedEmailKeys: new Set(), unreadSection: null, listRows: null, + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + fetchEmailContent: vi.fn().mockResolvedValue(undefined), + sendEmail, markEmailKeyword, cancelUndoSend, + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('a quick reply in its Undo window', () => { + it('shows in the conversation at once, counts nowhere and answers nothing yet; Undo puts it back in the box', async () => { + render(); + const reader = await screen.findByTestId('reader', {}, { timeout: 5000 }); + await waitFor(() => expect(reader).toHaveTextContent('m0,m1')); + + fireEvent.click(screen.getByRole('button', { name: 'quick send' })); + await waitFor(() => expect(sendEmail).toHaveBeenCalled()); + await waitFor(() => expect(reader).toHaveTextContent('m0,m1,sent-1')); + + expect(markEmailKeyword).not.toHaveBeenCalled(); + expect(screen.getByTestId('sidebar-counts')).toHaveTextContent('scheduled 0 sent 51'); + + const undo = await waitFor(() => { + const toast = useToastStore.getState().toasts.find((t) => t.action); + expect(toast).toBeTruthy(); + return toast!; + }); + await act(async () => { undo.action!.onClick(); }); + await waitFor(() => expect(cancelUndoSend).toHaveBeenCalled()); + await waitFor(() => expect(reader).toHaveTextContent(/^m0,m1$/)); + await waitFor(() => expect(useQuickReplyRestoreStore.getState().pending).toEqual({ emailId: 'm1', text: 'Thanks, see you there' })); + expect(client.deleteEmail).toHaveBeenCalledWith('sent-1', undefined); + expect(markEmailKeyword).not.toHaveBeenCalled(); + }); + + it('brings an undone reply back into the dock, as it was', async () => { + render(); + await screen.findByTestId('reader', {}, { timeout: 5000 }); + fireEvent.click(screen.getByRole('button', { name: 'reply' })); + const composer = await screen.findByTestId('composer'); + expect(composer.closest('[data-reply-dock]')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'composer send' })); + await waitFor(() => expect(sendEmail).toHaveBeenCalled()); + await waitFor(() => expect(screen.queryByTestId('composer')).toBeNull()); + expect(markEmailKeyword).not.toHaveBeenCalled(); + + const undo = await waitFor(() => { + const toast = useToastStore.getState().toasts.find((t) => t.action); + expect(toast).toBeTruthy(); + return toast!; + }); + await act(async () => { undo.action!.onClick(); }); + const back = await screen.findByTestId('composer'); + expect(back).toHaveAttribute('data-mode', 'reply'); + expect(back).toHaveAttribute('data-draft', 'sent-1'); + expect(back).toHaveAttribute('data-body', '

Works for me

'); + expect(back.closest('[data-reply-dock]')).not.toBeNull(); + }); +}); diff --git a/components/mail/__tests__/mail-app-list-ux.test.tsx b/components/mail/__tests__/mail-app-list-ux.test.tsx index 7f11c1bf9..54354744c 100644 --- a/components/mail/__tests__/mail-app-list-ux.test.tsx +++ b/components/mail/__tests__/mail-app-list-ux.test.tsx @@ -37,8 +37,8 @@ vi.mock('@/components/ui/welcome-banner', () => ({ useWelcomeBannerDue: () => false, })); vi.mock('@/components/email/email-viewer', () => ({ - EmailViewer: ({ emptyPaneContent, emptyPaneFooter }: { emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => ( -
{emptyPaneContent ??

no conversation selected

}{emptyPaneFooter}
+ EmailViewer: ({ email, emptyPaneContent, emptyPaneFooter }: { email?: { id: string } | null; emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => ( +
{email ?

reading {email.id}

: <>{emptyPaneContent ??

no conversation selected

}{emptyPaneFooter}}
), })); vi.mock('@/components/email/email-list', () => ({ @@ -94,7 +94,7 @@ beforeEach(() => { 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, + selectedEmailKeys: new Set(), unreadSection: null, listRows: 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), @@ -138,6 +138,35 @@ describe('the reading pane for a selection', () => { expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true); }); + it('replaces the open message once other rows are ticked too (mail-p2-3)', () => { + useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm2']) } as never); + render(); + expect(screen.queryByText('reading m1')).toBeNull(); + expect(listProps.host).toBeInstanceOf(HTMLElement); + expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true); + }); + + it('replaces the open message when another single row is ticked', () => { + useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m2']) } as never); + render(); + expect(screen.queryByText('reading m1')).toBeNull(); + }); + + it('keeps the open message when only its own row is ticked', () => { + useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1']) } as never); + render(); + expect(screen.getByText('reading m1')).toBeTruthy(); + }); + + it('keeps the open message when its conversation row is ticked', () => { + useEmailStore.setState({ + selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm1-reply']), + listRows: [{ key: 'm1', members: ['m1', 'm1-reply'] }, { key: 'm2', members: ['m2'] }], + } as never); + render(); + expect(screen.getByText('reading m1')).toBeTruthy(); + }); + it('says nothing is selected without a selection', () => { render(); expect(screen.getByText('no conversation selected')).toBeTruthy(); @@ -145,6 +174,18 @@ describe('the reading pane for a selection', () => { }); }); +describe('the list header checkbox (mail-p2-11)', () => { + it('ticks every loaded row on the first click and clears them on the second', () => { + const second = { ...message, id: 'm2', threadId: 't-m2', subject: 'm2' } as Email; + useEmailStore.setState({ emails: [message, second] } as never); + render(); + fireEvent.click(screen.getByRole('button', { name: /^email_list\.batch_actions\.select/ })); + expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['m1', 'm2']); + fireEvent.click(screen.getByRole('button', { name: 'email_list.batch_actions.clear_selection' })); + expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0); + }); +}); + describe('the new folder dialogs', () => { it.each([ ['stub new folder', 'mail_list_extra.new_folder_title'], diff --git a/components/mail/__tests__/mail-app-open-offline.test.tsx b/components/mail/__tests__/mail-app-open-offline.test.tsx new file mode 100644 index 000000000..34699ce66 --- /dev/null +++ b/components/mail/__tests__/mail-app-open-offline.test.tsx @@ -0,0 +1,179 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useToastStore } from '@/stores/toast-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { clearAllHooks } from '@/lib/plugin-hooks'; +import { dispatchApiCall } from '@/lib/plugin-sandbox/host-api'; +import { resetOpenEmail } from '@/lib/plugin-sandbox/open-email'; +import type { InstalledPlugin } from '@/lib/plugin-types'; + +// pass-2 mail-p2-7: in a browser tab that keeps no mail on the device, a click +// on a message while offline left "No conversation selected" and the keys did +// nothing. The reader now says the message needs a connection, with Retry, and +// a key that changes mail says you're offline. The message is opened the way a +// plugin opens one, which goes through the same open as a click. + +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: (props: { email: unknown; emptyPaneContent?: React.ReactNode }) => (props.email ?
reader
:
{props.emptyPaneContent ?? 'empty pane'}
), +})); +vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => 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: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +function mailbox(id: string, role: string | undefined, accountId: string, shared = false): Mailbox { + return { + id: shared ? `${accountId}:${id}` : id, originalId: id, name: role ?? id, role, accountId, isShared: shared, + sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, + } as Mailbox; +} + +// The active login's folders: its own account `cf` and the group `ce`. +const folders = [mailbox('a', 'inbox', 'cf'), mailbox('a', 'inbox', 'ce', true)]; + +// The group's Inbox message, as Email/get gives it. +const groupMessage = { + id: 'eaaaaab', threadId: 'c', subject: 'GRP one', receivedAt: '2026-10-26T10:00:00Z', + keywords: { $seen: true }, mailboxIds: { a: true }, bodyValues: {}, +} as unknown as Email; + +let client: Record; +const plugin = { + id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook', + permissions: ['email:read'], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true, +} as InstalledPlugin; + +/** The group account has `eaaaaab`, the own account nothing. */ +let failing = true; +const getEmail = vi.fn(async (id: string, accountId?: string) => { + // Offline the real client logs the failure and answers with no message. + if (failing && navigator.onLine === false) return null; + if (failing) throw new TypeError('Failed to fetch'); + return id === 'eaaaaab' && accountId === 'ce' ? { ...groupMessage } : null; +}); + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + useToastStore.setState({ toasts: [] }); + clearAllHooks(); + resetOpenEmail(); + getEmail.mockClear(); + client = { + getAccountId: () => 'cf', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getMailboxes: vi.fn().mockResolvedValue(folders), + getEmail, + getSharedAccounts: () => ['cf', 'ce'].map((id) => ({ id, name: id, isPrimary: id === 'cf', capabilities: { mail: true } })), + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', archiveMode: 'single', deleteAction: 'trash' } as never); + useEmailStore.setState({ + emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null, mailboxes: folders, + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', + selectedEmailKeys: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const setOnline = (online: boolean) => { + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online }); + window.dispatchEvent(new Event(online ? 'online' : 'offline')); +}; + +describe('a message that cannot be fetched', () => { + afterEach(() => setOnline(true)); + + it('says offline that it needs a connection, and Retry opens it once back online', async () => { + failing = true; + render(); + await screen.findByText('message list', {}, { timeout: 5000 }); + act(() => setOnline(false)); + await act(async () => { + await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {}); + }); + expect(await screen.findByText('reader_offline')).toBeTruthy(); + + failing = false; + act(() => setOnline(true)); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'retry' })); + await new Promise((r) => setTimeout(r, 20)); + }); + expect(await screen.findByText('reader')).toBeTruthy(); + expect(screen.queryByText('reader_offline')).toBeNull(); + }); + + it('says it could not be loaded when online', async () => { + failing = true; + render(); + await screen.findByText('message list', {}, { timeout: 5000 }); + await act(async () => { + await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {}); + }); + expect(await screen.findByText('reader_failed')).toBeTruthy(); + }); + + it('answers a key that changes mail with a note that it is offline', async () => { + render(); + await screen.findByText('message list', {}, { timeout: 5000 }); + act(() => setOnline(false)); + failing = true; + await act(async () => { + await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {}); + }); + await screen.findByText('reader_offline'); + // No Archive folder here: Delete stands in for every key that changes mail. + fireEvent.keyDown(document.body, { key: '#', code: 'Digit3', shiftKey: true }); + expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('action_offline'); + }); +}); diff --git a/components/mail/__tests__/reply-dock.test.tsx b/components/mail/__tests__/reply-dock.test.tsx new file mode 100644 index 000000000..eebdd83a7 --- /dev/null +++ b/components/mail/__tests__/reply-dock.test.tsx @@ -0,0 +1,58 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// mail-p2-2 / compose-p2-4: the docked reply took 62 % of the reading pane +// (at least 18 rem), so the message it answered was out of sight, and on a +// short window the reply itself had two lines to type in. It now grows with +// its text up to half of a tall pane; on a short pane it takes the pane under +// the reader's toolbar, with "Show the message" to read and come back. + +const { ReplyDock, dockLayout } = await import('../reply-dock'); + +function renderInPane(height: number, readerTop = 110) { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.hasAttribute('data-pane')) return { top: 0, height, width: 800, left: 0, right: 800, bottom: height, x: 0, y: 0, toJSON() {} } as DOMRect; + if (this.hasAttribute('data-reader-scroll')) return { top: readerTop, height: 0, width: 800, left: 0, right: 800, bottom: readerTop, x: 0, y: readerTop, toJSON() {} } as DOMRect; + return { top: 0, height: 0, width: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} } as DOMRect; + }); + return render( +
+
+