diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index dbf7543cf..dc27f65a3 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -30,6 +30,9 @@ } } +/* Compose heads the folder sidebar, in place of the account switcher. */ +@custom-variant boot-sidebar-compose (&:where([data-boot-sidebar-compose="on"] *)); + /* A message, thread, contact, settings page or admin page is open. */ @custom-variant boot-open (&:where([data-boot-pane] *)); @custom-variant boot-thread (&:where([data-boot-pane="thread"] *)); diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx index 07c634645..2a3b4a87d 100644 --- a/components/boot-skeleton/mail-skeleton.tsx +++ b/components/boot-skeleton/mail-skeleton.tsx @@ -131,7 +131,7 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) { {/* A compact desktop folds the sidebar to its icons (sidebarFolded). */} - + @@ -139,9 +139,11 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) { + {/* Compose in place of the account switcher (sidebarAccountSwitcher off). */} + - {/* The folded sidebar's Compose button. */} -
+ {/* The folded sidebar's Compose button (sidebarAccountSwitcher off). */} +
@@ -665,8 +667,8 @@ export function MailSkeletonBody({ embedded = false }: { embedded?: boolean }) { className={cn( "absolute z-40 shadow-lg bottom-4 end-4 h-12 w-12 max-desk:h-14 max-desk:w-14", "max-desk:mv:w-36 max-desk:mv:rounded-2xl", - // A desktop has Compose in the folder sidebar; a Pro pane keeps the round one. - !embedded && "desk:hidden", + // A desktop can have Compose in the folder sidebar instead; a Pro pane keeps the round one. + !embedded && "desk:mv:hidden desk:boot-sidebar-compose:hidden", )} /> diff --git a/components/command-palette/__tests__/command-registry.test.tsx b/components/command-palette/__tests__/command-registry.test.tsx index 0d6ed7a2b..4122fea84 100644 --- a/components/command-palette/__tests__/command-registry.test.tsx +++ b/components/command-palette/__tests__/command-registry.test.tsx @@ -114,12 +114,13 @@ describe('global commands', () => { } as never); render(); pressCtrlK(); - expect(screen.queryByRole('option', { name: /me@example\.com/ })).toBeNull(); + // The open account isn't offered as a switch target; Sign out names it. + expect(screen.queryByRole('option', { name: /^switch_to\(me@example\.com/ })).toBeNull(); act(() => { fireEvent.click(screen.getByRole('option', { name: 'switch_to(Work)' })); }); expect(switchAccount).toHaveBeenCalledWith('b'); pressCtrlK(); - act(() => { fireEvent.click(screen.getByRole('option', { name: 'sign_out' })); }); + act(() => { fireEvent.click(screen.getByRole('option', { name: 'sign_out_of(me@example.com)' })); }); expect(logout).toHaveBeenCalled(); }); }); diff --git a/components/contacts/__tests__/contact-list.test.tsx b/components/contacts/__tests__/contact-list.test.tsx index a0864a598..73105e918 100644 --- a/components/contacts/__tests__/contact-list.test.tsx +++ b/components/contacts/__tests__/contact-list.test.tsx @@ -90,6 +90,18 @@ describe('ContactList', () => { expect(avatars().some((el) => el.classList.contains('hidden'))).toBe(false); }); + it('puts a banner at the top of the scrolling list, so its arrival moves nothing on screen', () => { + const { rerender } = render(); + const scroller = screen.getByTestId('contact-list-skeleton').parentElement!; + const loadingList = screen.queryByRole('listbox'); + rerender(Possible duplicates (1)} />); + // The same scroll area as the loading rows, with the banner inside it, and + // a list of its own, not the loading one moved down. + expect(screen.getByRole('listbox').parentElement).toBe(scroller); + expect(scroller.firstElementChild).toBe(screen.getByRole('button', { name: 'Possible duplicates (1)' })); + expect(loadingList === null || loadingList !== screen.getByRole('listbox')).toBe(true); + }); + it('shows the contacts as soon as they are there, even while loading', () => { render(); expect(screen.queryByTestId('contact-list-skeleton')).toBeNull(); diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 1f563aeba..82280bddd 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -663,21 +663,23 @@ export function ContactList({ )} + {/* Contact list. The banner scrolls with it: it comes with the loaded + contacts, and above the scroll area it pushed the whole list down. + The loading rows stand in for the list, not inside it, so the list + that replaces them is new and nothing on screen moves. */} +
+ {sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? ( + // The rows the page skeleton drew, at the same density. + + ) : (<> {!hasSelection && banner} - - {/* Contact list */}
- {sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? ( - // The rows the page skeleton drew, at the same density. - - ) : sorted.length === 0 ? ( + {sorted.length === 0 ? ( searchQuery || activeFilters > 0 ? ( + )} +
{printTitle != null && ( { expect(screen.getByLabelText('subject_label')).toBe(screen.getByTestId('composer-subject')); }); + it('names the From list by its visible label, also with one address to send from', () => { + render(); + expect(screen.getByRole('combobox', { name: 'from' })).toBe(screen.getByTestId('composer-from')); + }); + it('names the remove button after the person', () => { render(' }} />); expect(screen.getByRole('button', { name: 'recipient_remove_named:Anna Berg' })).toBeInTheDocument(); diff --git a/components/email/__tests__/composer-draft-attachments.test.tsx b/components/email/__tests__/composer-draft-attachments.test.tsx index b3667e961..d4e3be641 100644 --- a/components/email/__tests__/composer-draft-attachments.test.tsx +++ b/components/email/__tests__/composer-draft-attachments.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; import { useAuthStore } from '@/stores/auth-store'; +import { UNDO_TOAST_DURATION } from '@/lib/email-action-toast'; // ─── Heavy component mocks (mirrors composer-close-guard.test.tsx) ──────────── @@ -129,6 +130,7 @@ vi.mock('@/stores/template-store', () => { vi.mock('@/stores/toast-store', () => ({ toast: { info: () => {}, error: () => {}, success: () => {} }, + useToastStore: { getState: () => ({ toasts: [] }) }, })); vi.mock('@/lib/plugin-hooks', () => ({ @@ -352,7 +354,7 @@ describe('a re-opened draft that lives in a group account', () => { fireEvent.click(screen.getAllByTestId('composer-send')[0]); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); - expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'eaaaaab', draftAccountId: 'c' })); + expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'eaaaaab', draftAccountId: 'c' }), expect.anything()); }); it('sends a version saved on the way without the group account', async () => { @@ -366,7 +368,7 @@ describe('a re-opened draft that lives in a group account', () => { await act(async () => { await vi.advanceTimersByTimeAsync(0); }); expect(createDraft.mock.calls[0][13]?.previousDraftAccountId).toBe('c'); - expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'draft-v2', draftAccountId: undefined })); + expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'draft-v2', draftAccountId: undefined }), expect.anything()); }); it('tells the host where the draft lives when it is discarded', async () => { @@ -376,9 +378,11 @@ describe('a re-opened draft that lives in a group account', () => { render(); fireEvent.change(screen.getByDisplayValue('Quarterly numbers'), { target: { value: 'Quarterly numbers v2' } }); - // Closing keeps the draft now; Discard is the way to delete it. + // Closing keeps the draft now; Discard is the way to delete it, once its Undo is over. fireEvent.click(screen.getAllByTestId('composer-discard')[0]); - fireEvent.click(within(screen.getByRole('alertdialog')).getByText('discard')); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(onDiscardDraft).not.toHaveBeenCalled(); + await act(async () => { await vi.advanceTimersByTimeAsync(UNDO_TOAST_DURATION); }); expect(onDiscardDraft).toHaveBeenCalledWith('eaaaaab', { accountId: 'c', localAccountId: undefined }); }); @@ -394,9 +398,10 @@ describe('a re-opened draft that lives in a group account', () => { expect(createDraft.mock.calls[0][13]?.previousDraftAccountId).toBeUndefined(); fireEvent.change(screen.getByDisplayValue('Quarterly numbers v2'), { target: { value: 'Quarterly numbers v3' } }); - // Closing keeps the draft now; Discard is the way to delete it. + // Closing keeps the draft now; Discard is the way to delete it, once its Undo is over. fireEvent.click(screen.getAllByTestId('composer-discard')[0]); - fireEvent.click(within(screen.getByRole('alertdialog')).getByText('discard')); + expect(onDiscardDraft).not.toHaveBeenCalled(); + await act(async () => { await vi.advanceTimersByTimeAsync(UNDO_TOAST_DURATION); }); expect(onDiscardDraft).toHaveBeenCalledWith('draft-v2', { accountId: undefined, localAccountId: undefined }); }); }); @@ -450,6 +455,6 @@ describe('send while a draft save is in flight', () => { expect(createDraft).toHaveBeenCalledTimes(2); expect(createDraft.mock.calls[1][7]).toBe('draft-v2'); expect(onSend).toHaveBeenCalledTimes(1); - expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'draft-v3', subject: 'Quarterly numbers v3' })); + expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'draft-v3', subject: 'Quarterly numbers v3' }), expect.anything()); }); }); diff --git a/components/email/__tests__/composer-draft-login.test.tsx b/components/email/__tests__/composer-draft-login.test.tsx index 7f6e1a4b3..6d2695dc3 100644 --- a/components/email/__tests__/composer-draft-login.test.tsx +++ b/components/email/__tests__/composer-draft-login.test.tsx @@ -1,8 +1,9 @@ -import { render, screen, fireEvent, act, within } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { EmailComposer, type ComposerDraftData } from '../email-composer'; import { useAuthStore } from '@/stores/auth-store'; +import { UNDO_TOAST_DURATION } from '@/lib/email-action-toast'; const offlineQueue = vi.hoisted(() => ({ noteDraftReplacedElsewhere: vi.fn(async () => {}) })); vi.mock('@/lib/offline/queue', async (importOriginal) => ({ @@ -143,6 +144,7 @@ vi.mock('@/stores/template-store', () => { vi.mock('@/stores/toast-store', () => ({ toast: { info: () => {}, error: () => {}, success: () => {} }, + useToastStore: { getState: () => ({ toasts: [] }) }, })); vi.mock('@/lib/plugin-hooks', () => ({ @@ -254,10 +256,12 @@ describe('a draft and the login it was saved with', () => { await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); }, switchFrom: (identityId: string) => fireEvent.change(screen.getByTestId('composer-from'), { target: { value: identityId } }), - discard: () => { - // The Discard button asks even when the draft is saved. + discard: async (onDiscardDraft: ReturnType) => { + // The bin asks nothing; the draft goes once its Undo is over. fireEvent.click(screen.getByText('discard')); - fireEvent.click(within(screen.getByRole('alertdialog')).getByText('discard')); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(onDiscardDraft).not.toHaveBeenCalled(); + await act(async () => { await vi.advanceTimersByTimeAsync(UNDO_TOAST_DURATION); }); }, send: async () => { fireEvent.click(screen.getAllByTestId('composer-send')[0]); @@ -274,7 +278,7 @@ describe('a draft and the login it was saved with', () => { expect(clientA.createDraft).toHaveBeenCalledTimes(1); composer.switchFrom('login-b::id-b'); await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); - composer.discard(); + await composer.discard(onDiscardDraft); expect(onDiscardDraft).toHaveBeenCalledWith('draft-a2', { accountId: undefined, localAccountId: 'login-a' }); }); @@ -295,7 +299,7 @@ describe('a draft and the login it was saved with', () => { expect(clientA.deleteEmail).toHaveBeenCalledWith('draft-a2', undefined); expect(clientB.deleteEmail).not.toHaveBeenCalled(); - composer.discard(); + await composer.discard(onDiscardDraft); expect(onDiscardDraft).toHaveBeenCalledWith('draft-b2', { accountId: undefined, localAccountId: 'login-b' }); }); @@ -330,7 +334,7 @@ describe('a draft and the login it was saved with', () => { composer.switchFrom('login-b::id-b'); await composer.send(); - expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ localAccountId: 'login-b', draftId: undefined, draftAccountId: undefined })); + expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ localAccountId: 'login-b', draftId: undefined, draftAccountId: undefined }), expect.anything()); expect(clientA.deleteEmail).toHaveBeenCalledWith('draft-a2', undefined); expect(clientB.deleteEmail).not.toHaveBeenCalled(); }); @@ -373,16 +377,16 @@ describe('a draft and the login it was saved with', () => { expect(clientB.deleteEmail).toHaveBeenCalledWith('draft-b1', 'c'); expect(clientA.deleteEmail).not.toHaveBeenCalled(); - composer.discard(); + await composer.discard(onDiscardDraft); expect(onDiscardDraft).toHaveBeenCalledWith('draft-a2', { accountId: undefined, localAccountId: 'login-a' }); }); - it('discards an unsaved draft opened from another login through that login and account', () => { + it('discards an unsaved draft opened from another login through that login and account', async () => { const onDiscardDraft = vi.fn(); const composer = open({ ...DRAFT, draftId: 'draft-b1', draftAccountId: 'c', draftLocalAccountId: 'login-b' }, { onDiscardDraft }); fireEvent.change(screen.getByDisplayValue('Hello'), { target: { value: 'Hello 2' } }); - composer.discard(); + await composer.discard(onDiscardDraft); expect(onDiscardDraft).toHaveBeenCalledWith('draft-b1', { accountId: 'c', localAccountId: 'login-b' }); }); diff --git a/components/email/__tests__/composer-save-pending-attachments.test.tsx b/components/email/__tests__/composer-save-pending-attachments.test.tsx index 4bb4a0d5b..3fef2ec9a 100644 --- a/components/email/__tests__/composer-save-pending-attachments.test.tsx +++ b/components/email/__tests__/composer-save-pending-attachments.test.tsx @@ -230,8 +230,9 @@ describe('Save and close with attachment uploads', () => { }); it('can discard during the wait without a later save or close follow-up', async () => { const c = await openComposer(); await c.add(file()); await c.chooseSave(); + // The bin asks nothing: the composer goes at once, with Undo on a toast. fireEvent.click(screen.getAllByTestId('composer-discard')[0]); - fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: /^Discard$/ })); + expect(screen.queryByRole('alertdialog')).toBeNull(); await complete(0); await new Promise(resolve => setTimeout(resolve, 200)); expect(creations).toEqual([]); expect(c.closed).toHaveBeenCalledOnce(); }); diff --git a/components/email/__tests__/list-row-preview-lines.test.tsx b/components/email/__tests__/list-row-preview-lines.test.tsx index 366ab7709..a32f33131 100644 --- a/components/email/__tests__/list-row-preview-lines.test.tsx +++ b/components/email/__tests__/list-row-preview-lines.test.tsx @@ -54,38 +54,24 @@ beforeEach(() => { useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); }); -// 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. +// mail-15: the default density shows one preview line, so more rows fit; +// comfortable keeps two. describe('list row preview lines', () => { - 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 row clamps its preview with %s', (density, clamp) => { + useSettingsStore.setState({ density }); + renderRow([makeEmail()]); + expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp); + }); it.each([ - ['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'); + ['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); }); }); diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx index 28d5f4745..47ce9152e 100644 --- a/components/email/__tests__/reader-plugin-surfaces.test.tsx +++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx @@ -360,7 +360,8 @@ describe('thread-summary', () => { render( {}} />); const frame = screen.getByTestId('slot-thread-summary'); - expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m2', 'm1'], deviceType: 'mobile' }); + // Oldest first, as the cards below it. + expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m1', 'm2'], deviceType: 'mobile' }); expect(follows(frame, screen.getAllByText('Alice')[0])).toBe(true); }); diff --git a/components/email/__tests__/thread-list-item-columns.test.tsx b/components/email/__tests__/thread-list-item-columns.test.tsx index ed27738ce..98c275ee0 100644 --- a/components/email/__tests__/thread-list-item-columns.test.tsx +++ b/components/email/__tests__/thread-list-item-columns.test.tsx @@ -93,9 +93,7 @@ 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()]); - // 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(); + expect(container.querySelector('p')).not.toBeNull(); }); describe('as columns', () => { diff --git a/components/email/__tests__/viewer-read-hooks.test.tsx b/components/email/__tests__/viewer-read-hooks.test.tsx index c59018986..2e1bb8120 100644 --- a/components/email/__tests__/viewer-read-hooks.test.tsx +++ b/components/email/__tests__/viewer-read-hooks.test.tsx @@ -148,11 +148,11 @@ describe('onThreadExpand in the conversation view', () => { onBack={() => {}} />, ); - // The newest card starts unfolded, the older read one folded. - const olderHeader = screen.getAllByText('Alice').at(-1)!; + // Oldest first: the older read card on top, folded; the newest below it, unfolded. + const olderHeader = screen.getAllByText('Alice')[0]; fireEvent.click(olderHeader); expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: true }); - fireEvent.click(screen.getAllByText('Alice').at(-1)!); + fireEvent.click(screen.getAllByText('Alice')[0]); expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: false }); expect(handler).toHaveBeenCalledTimes(2); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index dd5845507..a1384e37c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5268,6 +5268,7 @@ export function EmailComposer({ ) : identities.length > 0 ? (