Merge branch 'feat/do-ci-fix' into feat/desktop-offline

This commit is contained in:
Linus Rath
2026-11-04 16:36:02 +01:00
65 changed files with 457 additions and 168 deletions
+3
View File
@@ -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"] *));
+7 -5
View File
@@ -131,7 +131,7 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) {
{/* A compact desktop folds the sidebar to its icons (sidebarFolded). */}
<ChevronsRight className="w-4 h-4 hidden desk:max-lg:block" />
</span>
<span className="flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 desk:max-lg:hidden">
<span className="flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 desk:max-lg:hidden desk:boot-sidebar-compose:hidden">
<Circle className="w-9 h-9" />
<span className="min-w-0 flex-1">
<TextLine className="text-sm" w="70%" />
@@ -139,9 +139,11 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) {
</span>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
</span>
{/* Compose in place of the account switcher (sidebarAccountSwitcher off). */}
<Block className="hidden desk:boot-sidebar-compose:block desk:max-lg:hidden flex-1 min-w-0 h-9 rounded-md" />
</div>
{/* The folded sidebar's Compose button. */}
<div className="hidden desk:max-lg:flex justify-center pb-2">
{/* The folded sidebar's Compose button (sidebarAccountSwitcher off). */}
<div className="hidden desk:max-lg:boot-sidebar-compose:flex justify-center pb-2">
<Block className="w-10 h-10 rounded-md" />
</div>
</div>
@@ -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",
)}
/>
</div>
@@ -114,12 +114,13 @@ describe('global commands', () => {
} as never);
render(<CommandPaletteHost />);
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();
});
});
@@ -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(<ContactList {...defaultProps} contacts={[]} isLoading />);
const scroller = screen.getByTestId('contact-list-skeleton').parentElement!;
const loadingList = screen.queryByRole('listbox');
rerender(<ContactList {...defaultProps} banner={<button type="button">Possible duplicates (1)</button>} />);
// 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(<ContactList {...defaultProps} isLoading />);
expect(screen.queryByTestId('contact-list-skeleton')).toBeNull();
+12 -8
View File
@@ -663,21 +663,23 @@ export function ContactList({
</div>
)}
{/* 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. */}
<div ref={listRef} className="flex-1 overflow-y-auto max-md:pb-20">
{sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? (
// The rows the page skeleton drew, at the same density.
<ContactRowsSkeleton density={density} letters={groupByLetter} />
) : (<>
{!hasSelection && banner}
{/* Contact list */}
<div
ref={listRef}
role="listbox"
aria-label={t("title")}
aria-multiselectable
onKeyDown={handleListKeyDown}
className="flex-1 overflow-y-auto max-md:pb-20"
>
{sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? (
// The rows the page skeleton drew, at the same density.
<ContactRowsSkeleton density={density} letters={groupByLetter} />
) : sorted.length === 0 ? (
{sorted.length === 0 ? (
searchQuery || activeFilters > 0 ? (
<EmptyState
compact
@@ -781,6 +783,8 @@ export function ContactList({
})()
)}
</div>
</>)}
</div>
{printTitle != null && (
<ContactListPrint
@@ -264,6 +264,11 @@ describe('recipient fields (SR-06, CV-02)', () => {
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(<EmailComposer initialData={DRAFT} />);
expect(screen.getByRole('combobox', { name: 'from' })).toBe(screen.getByTestId('composer-from'));
});
it('names the remove button after the person', () => {
render(<EmailComposer initialData={{ ...DRAFT, to: 'Anna Berg <anna@example.com>' }} />);
expect(screen.getByRole('button', { name: 'recipient_remove_named:Anna Berg' })).toBeInTheDocument();
@@ -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(<EmailComposer initialData={GROUP_DRAFT} onDiscardDraft={onDiscardDraft} requestCloseRef={requestCloseRef} onClose={vi.fn()} />);
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());
});
});
@@ -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<typeof vi.fn>) => {
// 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' });
});
@@ -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();
});
@@ -54,38 +54,24 @@ beforeEach(() => {
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), 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);
});
});
@@ -360,7 +360,8 @@ describe('thread-summary', () => {
render(<ThreadConversationView thread={thread} emails={[conversation[1], conversation[0]]} onBack={() => {}} />);
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);
});
@@ -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', () => {
@@ -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);
});
+1
View File
@@ -5268,6 +5268,7 @@ export function EmailComposer({
) : identities.length > 0 ? (
<select
data-testid="composer-from"
aria-label={t('from')}
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => {
// The sender's rarer options sit at the end of the list.
+9 -11
View File
@@ -39,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, showsPreviewOnSubjectLine } from "@/hooks/use-list-row-shape";
import { useListRowShape } 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";
@@ -216,6 +216,7 @@ export function EmailList({
const mountainViewList = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
const isDesktopPane = useIsDesktop();
const isEmbeddedList = useIsEmbedded();
const sidebarAccountSwitcher = useSettingsStore((state) => state.sidebarAccountSwitcher);
const undo = undoLabels(tNotifications);
const tRemove = useTranslations('email_list.confirm_remove');
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
@@ -492,11 +493,7 @@ 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 (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') {
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).
@@ -507,7 +504,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, isMobile, mountainViewList]);
}, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments]);
// Stable per list, so the virtualizer does not rebuild every row's
// measurement on each scroll render.
@@ -1165,10 +1162,11 @@ export function EmailList({
// Room under the last row for the round Compose button, which would otherwise
// sit on that row's actions (Mountain View has no such button, and a desktop
// has Compose in the folder sidebar instead). A spacer and
// not padding on the list: padding is a minimum height the list can't go
// under, and on a short screen it pushed the rows over the tab bar.
const composeButtonRoom = !mountainViewList && (!isDesktopPane || isEmbeddedList) ? <div aria-hidden="true" className="h-20" /> : null;
// can have Compose in the folder sidebar instead, sidebarAccountSwitcher
// off). A spacer and not padding on the list: padding is a minimum height
// the list can't go under, and on a short screen it pushed the rows over
// the tab bar.
const composeButtonRoom = !mountainViewList && (!isDesktopPane || isEmbeddedList || sidebarAccountSwitcher) ? <div aria-hidden="true" className="h-20" /> : null;
return (
<TagDisplayContext.Provider value={tagDisplay}>
+5 -17
View File
@@ -17,7 +17,7 @@ import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, Messa
import type { AppIcon } from "@/components/icons";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useListRowShape, LIST_COLUMN_CLASS, showsPreviewOnSubjectLine } from "@/hooks/use-list-row-shape";
import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
import { SizeCell } from "./list-columns";
import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
import { useUIStore } from "@/stores/ui-store";
@@ -499,7 +499,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
// together.
const mvRow = interfaceLayout === 'mountain-view' && oneLineRow;
const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile;
const previewOnSubjectLine = showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: interfaceLayout === 'mountain-view' });
const trimmedPreview = cleanPreview(email.preview);
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// Search hits carry server snippets with the matched terms marked; they
@@ -973,7 +972,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className={cn("flex min-w-0 items-center gap-1.5", !previewOnSubjectLine && "mb-1")}>
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
@@ -988,16 +987,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
: "font-normal text-foreground/90"
)}>
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || tNoSubject)}</bdi>
{previewOnSubjectLine && (
<span data-row-preview="" className="font-normal text-muted-foreground">
{' – '}<span dir="auto">{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || t('no_preview_available'))}</span>
</span>
)}
</span>
<PluginRowBadges email={email} />
</div>
{showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<div className={cn("flex min-w-0 items-center gap-1", !mvMobileRow && "contents")}>
<p dir="auto" className={cn(
"text-sm leading-relaxed",
@@ -1099,7 +1093,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
// together.
const mvRow = interfaceLayout === 'mountain-view' && oneLineRow;
const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile;
const previewOnSubjectLine = showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: interfaceLayout === 'mountain-view' });
const trimmedPreview = cleanPreview(latestEmail.preview);
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// In a search the matched mail need not be the thread's latest one: show
@@ -1767,7 +1760,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className={cn("flex min-w-0 items-center gap-1.5", !previewOnSubjectLine && "mb-1")}>
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
@@ -1782,16 +1775,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
: "font-normal text-foreground/90"
)}>
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || tNoSubject)}</bdi>
{previewOnSubjectLine && (
<span data-row-preview="" className="font-normal text-muted-foreground">
{' – '}<span dir="auto">{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || tEmailViewer('no_preview_available'))}</span>
</span>
)}
</span>
<PluginRowBadges email={latestEmail} />
</div>
{showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<div className={cn("flex min-w-0 items-center gap-1", !mvMobileRow && "contents")}>
<p dir="auto" className={cn(
"text-sm leading-relaxed",
@@ -2,9 +2,11 @@ import { render, screen, fireEvent } 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: on a desktop the round Compose button covered the
// list's rows (mail-13), and the folder sidebar's header held a second account
// switcher beside the rail's avatar (mail-27). The header now holds Compose.
// The folder sidebar's header holds the account switcher by default, with the
// round Compose button over the list. With sidebarAccountSwitcher off it holds
// Compose instead (usability test 2026-11-04: the round button covered the
// list's rows, mail-13, and the switcher was a second one beside the rail's
// avatar, mail-27).
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({
@@ -42,9 +44,36 @@ function narrowWindow(narrow: boolean) {
})) as typeof window.matchMedia;
}
describe('folder sidebar header', () => {
describe('folder sidebar header, by default', () => {
beforeEach(() => {
useSettingsStore.setState({ interfaceLayout: 'default', enableUnifiedMailbox: false } as never);
useSettingsStore.setState({
interfaceLayout: 'default',
enableUnifiedMailbox: false,
sidebarAccountSwitcher: useSettingsStore.getInitialState().sidebarAccountSwitcher,
} as never);
useUIStore.setState({ sidebarCollapsed: false } as never);
narrowWindow(false);
});
afterEach(() => {
window.matchMedia = realMatchMedia;
});
it('starts with the account switcher and leaves Compose to the round button', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onCompose={vi.fn()} />);
expect(screen.getByTestId('account-switcher')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'compose' })).toBeNull();
});
it('has no Compose icon when the sidebar is collapsed', () => {
useUIStore.setState({ sidebarCollapsed: true } as never);
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onCompose={vi.fn()} />);
expect(screen.queryByRole('button', { name: 'compose' })).toBeNull();
});
});
describe('folder sidebar header, with Compose at the top', () => {
beforeEach(() => {
useSettingsStore.setState({ interfaceLayout: 'default', enableUnifiedMailbox: false, sidebarAccountSwitcher: false } as never);
useUIStore.setState({ sidebarCollapsed: false } as never);
narrowWindow(false);
});
+13 -7
View File
@@ -85,6 +85,7 @@ import { trashAndJunkIds } from "@/lib/unified-mailbox";
import { toast } from "@/stores/toast-store";
import { toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { debug } from "@/lib/debug";
import { AccountSwitcher } from "./account-switcher";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { buildSettingsPath, SCHEDULED_MAILBOX_ID, scopedScheduledMailboxId } from "@/lib/deep-links";
import { useTour } from "@/components/tour/tour-provider";
@@ -1374,6 +1375,7 @@ function SidebarImpl({
const selectedKeywordAccountId = useEmailStore(s => s.selectedKeywordAccountId);
const personalSelectedKeyword = selectedKeywordAccountId ? null : selectedKeyword;
const isEmbedded = useIsEmbedded();
const sidebarAccountSwitcher = useSettingsStore(s => s.sidebarAccountSwitcher);
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
const showSharedMailAccounts = useSettingsStore(s => s.showSharedMailAccounts);
@@ -2145,12 +2147,16 @@ function SidebarImpl({
{isCollapsed ? <ChevronsRight className="w-4 h-4 rtl:-scale-x-100" /> : <ChevronsLeft className="w-4 h-4 rtl:-scale-x-100" />}
</Button>
{/* Compose at the head of the folders on a desktop, as in Gmail
and Outlook; the round button over the list stays for phones
and tablets, where this pane is a drawer. The account switcher
that sat here was a second one beside the rail's avatar, which
every app shows (usability test 2026-11-04, mail-13, mail-27). */}
{!isCollapsed && !isDrawer && onCompose && (
{/* The account switcher heads the folders by default, with the
round Compose button over the list. With the setting off a
desktop puts Compose here instead, as in Gmail and Outlook,
and the rail's avatar is the one account switcher (usability
test 2026-11-04, mail-13, mail-27); phones and tablets keep
the round button, since this pane is a drawer there. */}
{!isCollapsed && sidebarAccountSwitcher && (
<AccountSwitcher variant="expanded" className="flex-1" />
)}
{!isCollapsed && !sidebarAccountSwitcher && !isDrawer && onCompose && (
<Button
onClick={onCompose}
className="flex-1 min-w-0 h-9 justify-start gap-2 px-3"
@@ -2163,7 +2169,7 @@ function SidebarImpl({
</Button>
)}
</div>
{isCollapsed && !isDrawer && onCompose && (
{isCollapsed && !sidebarAccountSwitcher && !isDrawer && onCompose && (
<div className="flex justify-center pb-2">
<IconButton
onClick={onCompose}
@@ -32,10 +32,15 @@ vi.mock('@/components/email/email-composer', () => ({
</button>
),
}));
// On a desktop Compose sits at the head of the folder sidebar (mail-13).
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ onCompose }: { onCompose?: () => void }) => <button type="button" onClick={onCompose}>sidebar.compose</button>,
}));
// With sidebarAccountSwitcher off, Compose sits at the head of the folder
// sidebar on a desktop (mail-13); by default it is the round button.
vi.mock('@/components/layout/sidebar', async () => {
const { useSettingsStore: settings } = await import('@/stores/settings-store');
return {
Sidebar: ({ onCompose }: { onCompose?: () => void }) =>
settings((s) => s.sidebarAccountSwitcher) ? null : <button type="button" onClick={onCompose}>sidebar.compose</button>,
};
});
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 }));
@@ -21,13 +21,19 @@ vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentTy
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
type ViewerProps = { mvConversation?: { id: string }[]; conversationPane?: boolean };
type ViewerProps = { email?: { id: string } | null; mvConversation?: { id: string }[]; mvConversationPending?: boolean; conversationPane?: boolean };
// What the reader was drawn with, render by render: message, conversation, waiting.
const readerDraws = vi.hoisted(() => [] as string[]);
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ mvConversation, conversationPane }: ViewerProps) => (
<div data-testid="reader" data-pane={conversationPane ? 'conversation' : 'message'}>
{mvConversation ? mvConversation.map((e) => e.id).join(',') : 'no conversation'}
</div>
),
EmailViewer: ({ email, mvConversation, mvConversationPending, conversationPane }: ViewerProps) => {
const conversation = mvConversation ? mvConversation.map((e) => e.id).join(',') : 'no conversation';
readerDraws.push(`${email?.id ?? '-'} ${conversation}${mvConversationPending ? ' waiting' : ''}`);
return (
<div data-testid="reader" data-pane={conversationPane ? 'conversation' : 'message'}>
{conversation}
</div>
);
},
}));
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
@@ -124,6 +130,7 @@ beforeEach(() => {
fetchEmailContent,
} as never);
opened = [];
readerDraws.length = 0;
unsubscribe = useEmailStore.subscribe((state, prev) => {
if (state.selectedEmail !== prev.selectedEmail) opened.push(state.selectedEmail?.id ?? null);
});
@@ -155,6 +162,35 @@ describe('The reading pane in the default layout', () => {
await waitFor(() => expect(reader).toHaveTextContent('m0,m1'));
});
it('opens a message with its conversation at once, in one draw', async () => {
useEmailStore.setState({ selectedEmail: inbox()[1], listRows: null } as never);
render(<MailApp />);
const reader = await screen.findByTestId('reader', {}, { timeout: 5000 });
await waitFor(() => expect(reader).toHaveTextContent('no conversation'));
readerDraws.length = 0;
await press('k');
await waitFor(() => expect(selectedId()).toBe('m1'));
await waitFor(() => expect(reader).toHaveTextContent('m0,m1'));
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
// The open brought the conversation with the message; fetching it again
// after the open drew the whole mail view once more.
expect(readerDraws.filter((draw) => draw.startsWith('m1 '))).toEqual(['m1 m0,m1']);
});
it('shows a linked message with its conversation, not alone first', async () => {
useEmailStore.setState({ selectedEmail: null } as never);
fetchEmailContent.mockImplementation(async (_client: unknown, id: string, _owner?: string, options?: { select?: boolean }) => {
const email = { ...inbox().find((e) => e.id === id)!, bodyValues: {} };
if (options?.select !== false) useEmailStore.setState({ selectedEmail: email } as never);
return email;
});
render(<MailApp linkSegments={['message', 'm1']} />);
const reader = await screen.findByTestId('reader', {}, { timeout: 5000 });
await waitFor(() => expect(reader).toHaveTextContent('m0,m1'), { timeout: 5000 });
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
expect(readerDraws.filter((draw) => draw.startsWith('m1 ') && draw !== 'm1 m0,m1')).toEqual([]);
});
it('shows the message alone with "Show emails as individual messages"', async () => {
useSettingsStore.setState({ disableThreading: true } as never);
render(<MailApp />);
@@ -272,7 +272,8 @@ describe('shared message deep link from the personal Inbox (#1491)', () => {
});
const mounted = render(<MailApp linkSegments={['message', 'eaaaaab']} />);
try {
await waitFor(() => expect(fetchContent).toHaveBeenCalledWith(client, 'eaaaaab', 'ce'));
// Fetched without opening: it opens with its conversation, in the reading pane.
await waitFor(() => expect(fetchContent).toHaveBeenCalledWith(client, 'eaaaaab', 'ce', { select: false }));
await waitFor(() => expect(useEmailStore.getState().selectedMailbox).toBe('ce:a'));
expect(fetchEmails).toHaveBeenCalledWith(client, 'ce:a', { background: true });
expect(useEmailStore.getState().selectedEmail?.subject).toBe('GRP one');
@@ -29,10 +29,17 @@ vi.mock('@/components/email/email-composer', () => ({
return <p>stub composer</p>;
},
}));
// On a desktop Compose sits at the head of the folder sidebar (mail-13).
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ onCompose }: { onCompose?: () => void }) => (device.isMobile ? null : <button type="button" onClick={onCompose}>sidebar.compose</button>),
}));
// With sidebarAccountSwitcher off, Compose sits at the head of the folder
// sidebar on a desktop (mail-13); by default it is the round button.
vi.mock('@/components/layout/sidebar', async () => {
const { useSettingsStore: settings } = await import('@/stores/settings-store');
return {
Sidebar: ({ onCompose }: { onCompose?: () => void }) => {
const switcherOnTop = settings((s) => s.sidebarAccountSwitcher);
return device.isMobile || switcherOnTop ? null : <button type="button" onClick={onCompose}>sidebar.compose</button>;
},
};
});
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 }));
@@ -95,7 +102,7 @@ beforeEach(() => {
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0 } as never);
useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0, sidebarAccountSwitcher: true } as never);
sendEmail.mockReset().mockResolvedValue({});
useUIStore.setState({ activeView: 'list', sidebarOpen: false });
useEmailStore.setState({
@@ -128,14 +135,19 @@ describe('Mail after the tour on a phone', () => {
});
// Usability test 2026-11-04, mail-13: on a desktop the round button sat on
// the list's rows; Compose is at the head of the folder sidebar there.
it('draws the round Compose button on a phone, not on a desktop', () => {
// the list's rows. It stays by default, with the account switcher at the
// head of the folder sidebar; turned off, Compose is there instead.
it('draws the round Compose button on a phone, and on a desktop unless Compose is in the sidebar', () => {
const view = render(<MailApp />);
expect(document.querySelectorAll('[data-tour="compose-button"]')).toHaveLength(1);
device.isMobile = false;
view.rerender(<MailApp />);
expect(document.querySelectorAll('[data-tour="compose-button"]')).toHaveLength(1);
act(() => { useSettingsStore.setState({ sidebarAccountSwitcher: false } as never); });
expect(document.querySelector('[data-tour="compose-button"]')).toBeNull();
expect(screen.getByRole('button', { name: 'sidebar.compose' })).toBeTruthy();
});
// mail-27 kept the tablet from drawing the header's ⋮ and the list's ⋮ one
+48 -8
View File
@@ -397,6 +397,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
threadId: string; emailId: string; startedAt: number; promise: Promise<Email[] | null>;
} | null>(null);
const mvConversationOpenSeqRef = useRef(0);
// The open that put a message on screen together with its conversation (or
// with none, a message alone in its thread). The effect below then has
// nothing to fetch, and resetting and setting it again would draw the whole
// mail view twice more for each message opened.
const mvConversationSettledRef = useRef<{
emailId: string; threadId: string; client: EmailBlobContext['client']; accountId?: string;
} | null>(null);
// compose-p2-6: a reply to the open message saved as a draft. The reader
// shows it under the message, and Reply opens it again. The conversation
// fetch notes the thread's drafts; a scan reads them fresh after a composer
@@ -494,6 +501,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Desktop lifts search into a global header. Mobile keeps its native
// one-pane flow, with Mountain View's own app chrome.
const mvShell = interfaceLayout === 'mountain-view' && !isMobile && !isEmbedded;
const sidebarAccountSwitcher = useSettingsStore((state) => state.sidebarAccountSwitcher);
const mvMobile = interfaceLayout === 'mountain-view' && isMobile && !isEmbedded;
// Mountain View reads a thread as one conversation in the viewer, on the
// phone as on the desktop, so both show the same order and details. (#1224)
@@ -2299,11 +2307,30 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
setLoadingEmail(true);
try {
const email = await fetchEmailContent(activeClient, link.id, link.ownerAccountId);
// With the conversation in the reader, the message waits for it, as on
// a click: shown alone first, it would move down once it came.
const withConversation = readerConversationView && !cursorOnly;
const email = withConversation
? await fetchEmailContent(activeClient, link.id, link.ownerAccountId, { select: false })
: await fetchEmailContent(activeClient, link.id, link.ownerAccountId);
if (!email) {
toast.error(t('deep_link.message_not_found'));
return;
}
if (withConversation) {
const source = resolveEmailBlobContext(email, activeClient);
const conversation = await Promise.race([
loadMvConversation(email, activeClient),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), MV_CONVERSATION_WAIT_MS)),
]);
if (conversation !== undefined) {
setMvConversationResult(conversation ? { emails: conversation, source, threadId: email.threadId } : null);
mvConversationSettledRef.current = {
emailId: email.id, threadId: email.threadId, client: source.client, accountId: source.accountId,
};
}
useEmailStore.setState({ selectedEmail: email });
}
await showInOwningFolder(activeClient, email);
if (link.fullscreen && !isMobile) {
// The owning-folder branch above may have skipped selecting (the
@@ -4997,11 +5024,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
useEffect(() => {
++mvConversationOpenSeqRef.current;
const settled = mvConversationSettledRef.current;
mvConversationSettledRef.current = null;
if (!mvConversationThreadId || !client || !mvConversationRef) {
setMvConversationResult(null);
setMvConversationLoading(null);
return;
}
if (settled && settled.emailId === mvConversationRef.id && settled.threadId === mvConversationThreadId
&& settled.client === mvBlobSource.client && settled.accountId === mvBlobSource.accountId) {
// The open already has the conversation on screen with the message.
setMvConversationLoading(null);
return () => { ++mvConversationOpenSeqRef.current; };
}
let cancelled = false;
setMvConversationResult(null);
setMvConversationLoading(mvConversationThreadId);
@@ -5449,9 +5484,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), MV_CONVERSATION_WAIT_MS)),
]);
if (openSeq !== emailOpenSeqRef.current) return 'opened';
if (conversation !== undefined) setMvConversationResult(conversation ? {
emails: conversation, source, threadId: fullEmail.threadId,
} : null);
if (conversation !== undefined) {
setMvConversationResult(conversation ? {
emails: conversation, source, threadId: fullEmail.threadId,
} : null);
mvConversationSettledRef.current = {
emailId: fullEmail.id, threadId: fullEmail.threadId, client: source.client, accountId: source.accountId,
};
}
}
// One write: the message, and the end of its loading state.
finishOpenEmail(fullEmail);
@@ -6349,10 +6389,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</div>
</div>
{/* Floating Compose Button - on desktop the folder sidebar has
Compose at its top instead (and Mountain View its rail), so the
button no longer sits on the list's rows. */}
{!mvShell && (isMobile || isEmbedded) && <Button
{/* Floating Compose Button - Mountain View has Compose in its rail,
and a desktop with sidebarAccountSwitcher off at the top of the
folder sidebar, so there the button doesn't sit on the rows. */}
{!mvShell && (isMobile || isEmbedded || sidebarAccountSwitcher) && <Button
onClick={() => {
void handleCompose();
}}
+9 -4
View File
@@ -189,12 +189,19 @@ export function LayoutSection() {
/** The Appearance page's Sidebar section: what the folder sidebar and rail show. */
export function SidebarSection() {
const t = useTranslations('settings.appearance');
const { showRailAccountList, colorfulSidebarIcons, showFolderTotalCount, updateSetting } = useSettingsStore();
const { sidebarAccountSwitcher, showRailAccountList, colorfulSidebarIcons, showFolderTotalCount, updateSetting } = useSettingsStore();
const accounts = useAccountStore(s => s.accounts);
const multiAccount = accounts.length > 1;
return (
<SettingsSection id="sidebar" title={t('sections.sidebar')}>
<SettingItem label={t('sidebar_account_switcher.label')} description={t('sidebar_account_switcher.description')}>
<ToggleSwitch
checked={sidebarAccountSwitcher}
onChange={(checked) => updateSetting('sidebarAccountSwitcher', checked)}
/>
</SettingItem>
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
<ToggleSwitch
checked={colorfulSidebarIcons}
@@ -209,9 +216,7 @@ export function SidebarSection() {
/>
</SettingItem>
{/* The rail avatars only mean something with a second account. The
sidebar's own account switcher is gone (the rail's avatar is the
one), and with it the switch that hid it. */}
{/* The rail avatars only mean something with a second account. */}
{multiAccount && (
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
-16
View File
@@ -36,19 +36,3 @@ export const LIST_COLUMN_CLASS = {
date: 'w-28 shrink-0 text-end',
size: 'w-16 shrink-0 text-end',
} as const;
/**
* Whether a stacked row puts its preview after the subject, on the subject's
* line, instead of on a line of its own (mail-15). The regular density does
* on a computer, which brings a row from about 96 px to about 68 px, so a
* 1366×768 screen lists about ten. Comfortable keeps two preview lines, and
* phones keep the preview under the subject, where the line is too short.
*/
export function showsPreviewOnSubjectLine(options: {
showPreview: boolean;
density: string;
isMobile: boolean;
mountainView: boolean;
}): boolean {
return options.showPreview && options.density === 'regular' && !options.isMobile && !options.mountainView;
}
@@ -143,6 +143,17 @@ describe('boot script: layout settings', () => {
expect(attr('preview')).toBeNull();
});
it('marks Compose at the head of the sidebar only when the account switcher is turned off', () => {
boot('/en/mail');
expect(attr('sidebar-compose')).toBeNull();
settings({ sidebarAccountSwitcher: false });
boot('/en/mail');
expect(attr('sidebar-compose')).toBe('on');
settings({ sidebarAccountSwitcher: true });
boot('/en/mail');
expect(attr('sidebar-compose')).toBeNull();
});
it('falls back to regular for an unknown density', () => {
settings({ density: 'cosy' });
boot('/en/mail');
+3
View File
@@ -174,6 +174,9 @@ export function applyBootLayout(root: HTMLElement, c: BootLayoutConfig): void {
var mailLayout = s.mailLayout === 'focus' || s.mailLayout === 'horizontal' ? s.mailLayout : 'split';
if (layout === 'mountain-view' || s.simpleMode === true) mailLayout = 'focus';
mark('mail-layout', mailLayout);
// Compose at the head of the folder sidebar instead of the account
// switcher, with no round button over the list (sidebarAccountSwitcher).
mark('sidebar-compose', s.sidebarAccountSwitcher === false ? 'on' : null);
var desktop = false;
// The desktop layout, compact included (PHONE_BELOW in hooks/use-media-query).
@@ -116,6 +116,12 @@ beforeEach(async () => {
await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 });
}, 60_000);
// A wipe that goes on behind a start's budget must not finish in the next test: it would find
// that test's shell, fetch stub, storage and windows, and restart, report or reload there.
afterEach(async () => {
await wipe?.wipesIdleForTests();
}, 60_000);
/** The wipe goes on behind the start's budget, and takes its time under load. */
async function waitForNoAccounts(): Promise<void> {
await vi.waitFor(() => expect(useAccountStore.getState().accounts).toEqual([]), { timeout: 20_000 });
@@ -410,6 +416,8 @@ describe('a start', () => {
expect(waited).toBeLessThan(wipe.BOOT_CHECK_IN_BUDGET_MS + 1500);
expectUntouched();
// The rest of the wipe's requests (the sign-out's) are answered.
respond = (url) => (url.endsWith('/api/devices/wiped') ? json(200, { ok: true }) : noWipe());
release(wipeNow());
await waitForNoAccounts();
expect(purge.files).toBeGreaterThan(0);
+10
View File
@@ -500,6 +500,16 @@ export function setWipeReloadForTests(run: (() => void) | null): void {
reloadSeam = run;
}
/**
* Test helper: resolves once no wipe and no check-in is running. A wipe that goes on behind the
* start's budget would otherwise finish in the next test, with its shell, fetch and windows.
*/
export async function wipesIdleForTests(): Promise<void> {
while (running.size > 0 || checkInFlight) {
await Promise.allSettled([...running.values(), checkInFlight]);
}
}
/** Everything on this device, for everyone. The app's device preferences stay: they hold no mail. */
export function wipeWholeDevice(reason: WipeReason): Promise<void> {
const under = running.get(WHOLE_DEVICE);
+1 -1
View File
@@ -138,7 +138,7 @@ export const SHARED_SETTINGS = [
'enableUnifiedMailbox', 'sessionTimeout',
// Everything else app.setSettings may change (lib/settings-schema.ts), so
// a plugin reads the current value before it proposes a new one.
'colorfulSidebarIcons', 'showFolderTotalCount', 'hideAccountSwitcher', 'showRailAccountList',
'colorfulSidebarIcons', 'showFolderTotalCount', 'hideAccountSwitcher', 'sidebarAccountSwitcher', 'showRailAccountList',
'unifiedCrossAccount', 'includeGroupInUnified', 'enableCrossUnreadView', 'enableCrossStarredView', 'enableCrossAllView',
'tintListRowsByAccount', 'showAccountDot', 'showSharedMailAccounts', 'compactSharedMailboxes', 'showAdvancedSettings',
'dateLocale', 'protocolOpenMode', 'showOnboardingOnNewDevices',
+1
View File
@@ -171,6 +171,7 @@ const ENTRIES: SettingSchemaEntry[] = [
bool('showToolbarLabels', 'appearance', `${AP}toolbar_labels.label`),
bool('colorfulSidebarIcons', 'appearance', `${AP}colorful_sidebar_icons.label`),
bool('showFolderTotalCount', 'appearance', `${AP}show_folder_total_count.label`),
bool('sidebarAccountSwitcher', 'appearance', `${AP}sidebar_account_switcher.label`),
bool('showRailAccountList', 'appearance', `${AP}show_rail_account_list.label`),
bool('proInterface', 'appearance', `${AP}pro_interface.label`),
bool('enableUnifiedMailbox', 'appearance', `${AP}unified_mailbox.label`),
+1
View File
@@ -151,6 +151,7 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.appearance.show_folder_total_count.label' },
{ label: 'settings.appearance.sections.experimental', advanced: true },
{ label: 'settings.appearance.pro_interface.label', advanced: true },
{ label: 'settings.appearance.sidebar_account_switcher.label' },
{ label: 'settings.appearance.show_rail_account_list.label', when: 'multiAccount' },
],
accessibility: [
+4
View File
@@ -1661,6 +1661,10 @@
"label": "إخفاء مبدّل الحسابات في الشريط الجانبي",
"description": "إخفاء محدد الحساب أعلى الشريط الجانبي للمجلدات. يمكنك مع ذلك تبديل الحسابات من شريط التنقل السفلي."
},
"sidebar_account_switcher": {
"label": "مبدّل الحسابات أعلى الشريط الجانبي",
"description": "يعرض مبدّل الحسابات فوق المجلدات وزر «إنشاء رسالة» الدائري فوق قائمة الرسائل. أوقف هذا الخيار لوضع «إنشاء رسالة» فوق المجلدات بدلاً من ذلك."
},
"show_rail_account_list": {
"label": "إظهار صور الحسابات على شريط التنقل",
"description": "عرض دوائر الحسابات الفردية أسفل شريط التنقل للتبديل السريع، مع زر تسجيل خروج أسفلها."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "Amaga el selector de comptes de la barra lateral",
"description": "Amaga el selector de comptes a la part superior de la barra lateral de carpetes. Encara podreu canviar de compte des de la barra de navegació inferior."
},
"sidebar_account_switcher": {
"label": "Selector de comptes a la part superior de la barra lateral",
"description": "Mostra el selector de comptes damunt de les carpetes i el botó rodó Redacta damunt de la llista de missatges. Desactiveu-ho per posar Redacta damunt de les carpetes."
},
"show_rail_account_list": {
"label": "Mostra els avatars dels comptes a la barra de navegació",
"description": "Mostra cercles individuals per a cada compte a la part inferior de la barra de navegació per canviar-hi ràpidament, amb un botó de tancament de sessió a sota."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Skrýt přepínač účtů v postranním panelu",
"description": "Skryje výběr účtu v horní části postranního panelu. Stále můžete přepínat účty pomocí spodního navigačního panelu."
},
"sidebar_account_switcher": {
"label": "Přepínač účtů nahoře v postranním panelu",
"description": "Zobrazí přepínač účtů nad složkami a kulaté tlačítko Napsat nad seznamem zpráv. Po vypnutí bude tlačítko Napsat nad složkami."
},
"show_rail_account_list": {
"label": "Zobrazovat avatary účtů v navigačním panelu",
"description": "Zobrazovat samostatné ikony účtů ve spodní části navigačního panelu pro rychlé přepínání, s tlačítkem odhlášení níže."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "Skjul kontoskifter i sidepanel",
"description": "Skjul kontovælgeren øverst i mappesidepanelet. Du kan stadig skifte konto fra navigationslinjen nederst."
},
"sidebar_account_switcher": {
"label": "Kontoskifter øverst i sidepanelet",
"description": "Vis kontoskifteren over mapperne og den runde Ny besked-knap over beskedlisten. Slå det fra for at placere Ny besked over mapperne i stedet."
},
"show_rail_account_list": {
"label": "Vis kontoavatarer på navigationslinje",
"description": "Vis individuelle kontocirkler nederst på navigationslinjen for hurtig skift, med en log-ud-knap nedenunder."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Kontoumschalter in der Seitenleiste ausblenden",
"description": "Den Kontowähler oben in der Ordner-Seitenleiste ausblenden. Sie können weiterhin über die untere Navigationsleiste zwischen Konten wechseln."
},
"sidebar_account_switcher": {
"label": "Kontoumschalter oben in der Seitenleiste",
"description": "Zeigt den Kontoumschalter über den Ordnern und die runde Schaltfläche „Verfassen“ über der Nachrichtenliste. Ausgeschaltet steht „Verfassen“ stattdessen über den Ordnern."
},
"show_rail_account_list": {
"label": "Konto-Avatare in der Navigationsleiste anzeigen",
"description": "Individuelle Kontokreise am unteren Rand der Navigationsleiste für schnelles Umschalten anzeigen, mit einer Abmelde-Schaltfläche darunter."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "Hide sidebar account switcher",
"description": "Hide the account selector at the top of the folder sidebar. You can still switch accounts from the bottom navigation rail."
},
"sidebar_account_switcher": {
"label": "Account switcher at the top of the sidebar",
"description": "Show the account switcher above the folders and the round Compose button over the message list. Turn this off to put Compose above the folders instead."
},
"show_rail_account_list": {
"label": "Show account avatars on the navigation rail",
"description": "Display individual account circles at the bottom of the navigation rail for quick switching, with a sign-out button below."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Ocultar selector de cuentas de la barra lateral",
"description": "Ocultar el selector de cuentas en la parte superior de la barra lateral de carpetas. Aún puede cambiar de cuenta desde la barra de navegación inferior."
},
"sidebar_account_switcher": {
"label": "Selector de cuentas en la parte superior de la barra lateral",
"description": "Muestra el selector de cuentas encima de las carpetas y el botón redondo Redactar sobre la lista de mensajes. Desactívelo para poner Redactar encima de las carpetas."
},
"show_rail_account_list": {
"label": "Mostrar avatares de cuentas en la barra de navegación",
"description": "Mostrar círculos de cuentas individuales en la parte inferior de la barra de navegación para un cambio rápido, con un botón de cerrar sesión debajo."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "Peida külgriba kontovahetaja",
"description": "Peida kontovaliku menüü kaustade külgriba ülaosast. Kontosid saab endiselt vahetada alumiselt navigeerimisribalt."
},
"sidebar_account_switcher": {
"label": "Kontovahetaja külgriba ülaosas",
"description": "Näita kontovahetajat kaustade kohal ja ümmargust nuppu Koosta kirjade loendi kohal. Lülita see välja, et nupp Koosta oleks hoopis kaustade kohal."
},
"show_rail_account_list": {
"label": "Näita kontode avatare navigeerimisribal",
"description": "Kuva navigeerimisriba allosas eraldi kontoringid kiireks vahetamiseks ning nende all väljalogimise nupp."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "مخفی کردن تعویض حساب نوار کناری",
"description": "انتخاب‌گر حساب در بالای نوار کناری پوشه‌ها را مخفی کنید"
},
"sidebar_account_switcher": {
"label": "تعویض حساب در بالای نوار کناری",
"description": "تعویض حساب را بالای پوشه‌ها و دکمه گرد «ایمیل جدید» را روی فهرست پیام‌ها نشان می‌دهد. با خاموش کردن آن، «ایمیل جدید» به‌جای آن بالای پوشه‌ها قرار می‌گیرد."
},
"show_rail_account_list": {
"label": "نمایش آواتار حساب‌ها در نوار ناوبری",
"description": "دایره‌های حساب جداگانه در پایین نوار ناوبری برای تعویض سریع"
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Masquer le sélecteur de compte de la barre latérale",
"description": "Masquer le sélecteur de compte en haut de la barre latérale des dossiers. Vous pouvez toujours changer de compte depuis la barre de navigation inférieure."
},
"sidebar_account_switcher": {
"label": "Sélecteur de compte en haut de la barre latérale",
"description": "Affiche le sélecteur de compte au-dessus des dossiers et le bouton rond Composer sur la liste des messages. Désactivez cette option pour placer Composer au-dessus des dossiers."
},
"show_rail_account_list": {
"label": "Afficher les avatars de compte sur la barre de navigation",
"description": "Afficher les cercles de comptes individuels en bas de la barre de navigation pour un changement rapide, avec un bouton de déconnexion en dessous."
+4
View File
@@ -1602,6 +1602,10 @@
"label": "הסתר את מחליף החשבונות בסרגל הצד",
"description": "הסתר את בורר החשבונות בחלק העליון של סרגל הצד של התיקיות. אתה עדיין יכול להחליף חשבונות ממסילת הניווט התחתונה."
},
"sidebar_account_switcher": {
"label": "מחליף החשבונות בראש סרגל הצד",
"description": "מציג את מחליף החשבונות מעל התיקיות ואת כפתור הכתיבה העגול מעל רשימת ההודעות. כבו את האפשרות כדי להציב את כפתור הכתיבה מעל התיקיות במקום זאת."
},
"show_rail_account_list": {
"label": "הצג אווטרים של חשבון על רכבת ניווט",
"description": "הצג עיגולי חשבון בודדים בתחתית מסילת הניווט למעבר מהיר, עם לחצן יציאה למטה."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "Oldalsáv fiókváltó elrejtése",
"description": "Fiókválasztó elrejtése a mappa oldalsáv tetején. A fiókokat továbbra is válthatod az alsó navigációs sávból."
},
"sidebar_account_switcher": {
"label": "Fiókváltó az oldalsáv tetején",
"description": "A fiókváltó a mappák fölött, a kerek Új levél gomb pedig az üzenetlista fölött jelenik meg. Kikapcsolva az Új levél gomb kerül a mappák fölé."
},
"show_rail_account_list": {
"label": "Fiók avatárok mutatása a navigációs sávon",
"description": "Egyedi fiókkörök megjelenítése a navigációs sáv alján a gyors váltáshoz, kijelentkezés gombbal alatta."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Nascondi selettore account dalla barra laterale",
"description": "Nascondi il selettore account nella parte superiore della barra laterale delle cartelle. Puoi comunque cambiare account dalla barra di navigazione inferiore."
},
"sidebar_account_switcher": {
"label": "Selettore account in cima alla barra laterale",
"description": "Mostra il selettore account sopra le cartelle e il pulsante rotondo Scrivi sull'elenco dei messaggi. Disattivalo per mettere invece Scrivi sopra le cartelle."
},
"show_rail_account_list": {
"label": "Mostra avatar account nella barra di navigazione",
"description": "Visualizza i cerchi degli account individuali nella parte inferiore della barra di navigazione per un cambio rapido, con un pulsante di disconnessione sotto."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "サイドバーのアカウント切替を非表示",
"description": "フォルダサイドバー上部のアカウント選択を非表示にします。下部のナビゲーションレールからアカウントを切り替えることは引き続き可能です。"
},
"sidebar_account_switcher": {
"label": "サイドバー上部のアカウント切替",
"description": "フォルダの上にアカウント切替を、メッセージ一覧の上に丸い「作成」ボタンを表示します。オフにすると、代わりに「作成」がフォルダの上に表示されます。"
},
"show_rail_account_list": {
"label": "ナビゲーションレールにアカウントアバターを表示",
"description": "ナビゲーションレールの下部に個々のアカウントの丸を表示して素早く切り替えできるようにし、その下にサインアウトボタンを配置します。"
+4
View File
@@ -1658,6 +1658,10 @@
"label": "사이드바 계정 전환기 숨기기",
"description": "폴더 사이드바 상단의 계정 선택기를 숨겨요. 하단 내비게이션 바에서 여전히 계정을 전환할 수 있어요."
},
"sidebar_account_switcher": {
"label": "사이드바 상단의 계정 전환기",
"description": "폴더 위에 계정 전환기를, 메일 목록 위에 둥근 메일 쓰기 버튼을 보여줘요. 끄면 메일 쓰기가 대신 폴더 위에 표시돼요."
},
"show_rail_account_list": {
"label": "내비게이션 바에 계정 아바타 표시",
"description": "내비게이션 바 아래에 계정 프로필을 표시해서 빠르게 전환할 수 있어요."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Slēpt kontu pārslēdzēju sānu joslā",
"description": "Slēpt kontu izvēli sānu joslas augšpusē. Kontus joprojām varēsiet pārslēgt navigācijas joslā apakšā."
},
"sidebar_account_switcher": {
"label": "Kontu pārslēdzējs sānu joslas augšpusē",
"description": "Rāda kontu pārslēdzēju virs mapēm un apaļo pogu Rakstīt virs ziņojumu saraksta. Izslēdziet, lai poga Rakstīt būtu virs mapēm."
},
"show_rail_account_list": {
"label": "Rādīt kontu avatarus navigācijas joslā",
"description": "Rādīt kontu apļus navigācijas joslas apakšā ātrai pārslēgšanai."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "Sidebar Account Switcher-ийг нуух",
"description": "Хавтрын хажуугийн дээд хэсэгт байрлах бүртгэл сонгогчийг нуу. Та доод навигацийн төмөр замаас бүртгэлээ сольж болно."
},
"sidebar_account_switcher": {
"label": "Хажуугийн самбарын дээд талд бүртгэл солигч",
"description": "Хавтаснуудын дээр бүртгэл солигчийг, захидлын жагсаалтын дээр дугуй Бичилэх товчийг харуулна. Унтраавал Бичилэх товч хавтаснуудын дээр гарна."
},
"show_rail_account_list": {
"label": "Навигацийн төмөр зам дээр дансны аватаруудыг харуулах",
"description": "Хурдан сэлгэхийн тулд навигацийн төмөр замын доод хэсэгт бүртгэлээс гарах товчийг ашиглан хувийн дансны хүрээг харуул."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "Skjul kontobytter i sidefeltet",
"description": "Skjul kontovelgeren øverst i mappesidefeltet. Du kan fortsatt bytte konto fra navigasjonsfeltet nederst."
},
"sidebar_account_switcher": {
"label": "Kontobytter øverst i sidefeltet",
"description": "Vis kontobytteren over mappene og den runde Skriv-knappen over meldingslisten. Slå av for å plassere Skriv over mappene i stedet."
},
"show_rail_account_list": {
"label": "Vis kontobilder i navigasjonsfeltet",
"description": "Vis et kontobilde for hver konto nederst i navigasjonsfeltet, slik at du raskt kan bytte konto. En utloggingsknapp vises nedenfor."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Accountwisselaar in zijbalk verbergen",
"description": "Verberg de accountselector bovenaan de mappenzijbalk. U kunt nog steeds van account wisselen via de onderste navigatiebalk."
},
"sidebar_account_switcher": {
"label": "Accountwisselaar boven in de zijbalk",
"description": "Toont de accountwisselaar boven de mappen en de ronde knop Nieuw bericht boven de berichtenlijst. Zet dit uit om Nieuw bericht in plaats daarvan boven de mappen te zetten."
},
"show_rail_account_list": {
"label": "Accountavatars tonen op navigatiebalk",
"description": "Toon individuele accountcirkels onderaan de navigatiebalk voor snel wisselen, met een afmeldknop eronder."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Ukryj przełącznik kont na pasku bocznym",
"description": "Ukryj wybór konta u góry paska folderów. Nadal możesz przełączać konta z dolnego paska nawigacyjnego."
},
"sidebar_account_switcher": {
"label": "Przełącznik kont u góry paska bocznego",
"description": "Pokazuje przełącznik kont nad folderami i okrągły przycisk Napisz nad listą wiadomości. Po wyłączeniu przycisk Napisz będzie nad folderami."
},
"show_rail_account_list": {
"label": "Pokazuj awatary kont na pasku nawigacyjnym",
"description": "Wyświetlaj osobne ikony kont na dole paska nawigacyjnego, aby szybko się przełączać, z przyciskiem wylogowania poniżej."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Ocultar seletor de conta da barra lateral",
"description": "Ocultar o seletor de conta no topo da barra lateral de pastas. Você ainda pode trocar de conta pela barra de navegação inferior."
},
"sidebar_account_switcher": {
"label": "Seletor de conta no topo da barra lateral",
"description": "Mostra o seletor de conta acima das pastas e o botão redondo Escrever sobre a lista de mensagens. Desative para colocar Escrever acima das pastas."
},
"show_rail_account_list": {
"label": "Mostrar avatares de conta na barra de navegação",
"description": "Exibir círculos de contas individuais na parte inferior da barra de navegação para troca rápida, com um botão de sair abaixo."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "Ascunde comutatorul de conturi din bara laterală",
"description": "Ascundeți selectorul de conturi din partea de sus a barei laterale a folderelor. Puteți comuta între conturi în continuare din bara de navigare din partea de jos."
},
"sidebar_account_switcher": {
"label": "Comutator de conturi în partea de sus a barei laterale",
"description": "Afișează comutatorul de conturi deasupra folderelor și butonul rotund Compune peste lista de mesaje. Dezactivați pentru a pune Compune deasupra folderelor."
},
"show_rail_account_list": {
"label": "Afișați avatarurile conturilor în bara de navigare",
"description": "Afișați cercurile conturilor individuale în partea de jos a barei de navigare pentru comutarea rapidă, cu un buton de deconectare sub acestea."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Скрыть переключатель аккаунтов в боковой панели",
"description": "Скрыть выбор аккаунта в верхней части боковой панели папок. Вы по-прежнему можете переключать аккаунты через нижнюю панель навигации."
},
"sidebar_account_switcher": {
"label": "Переключатель аккаунтов вверху боковой панели",
"description": "Показывает переключатель аккаунтов над папками и круглую кнопку «Создать» над списком писем. Выключите, чтобы кнопка «Создать» была над папками."
},
"show_rail_account_list": {
"label": "Показать аватары аккаунтов на панели навигации",
"description": "Отображать отдельные круги аккаунтов в нижней части панели навигации для быстрого переключения, с кнопкой выхода ниже."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "Skryť prepínač účtov v postrannom paneli",
"description": "Skryje výber účtu v hornej časti postranného panela."
},
"sidebar_account_switcher": {
"label": "Prepínač účtov hore v postrannom paneli",
"description": "Zobrazí prepínač účtov nad priečinkami a okrúhle tlačidlo Napísať nad zoznamom správ. Po vypnutí bude tlačidlo Napísať nad priečinkami."
},
"show_rail_account_list": {
"label": "Zobraziť avatáry účtov na navigačnej lište",
"description": "Zobrazovať jednotlivé ikony účtov v dolnej časti navigačnej lišty pre rýchle prepínanie."
+4
View File
@@ -4857,6 +4857,10 @@
"label": "ซ่อนตัวสลับบัญชีในแถบด้านข้าง",
"description": "ซ่อนตัวเลือกบัญชีที่ด้านบนของแถบโฟลเดอร์ คุณยังสามารถสลับบัญชีจากแถบนำทางหลักด้านล่างได้"
},
"sidebar_account_switcher": {
"label": "ตัวสลับบัญชีที่ด้านบนของแถบด้านข้าง",
"description": "แสดงตัวสลับบัญชีเหนือโฟลเดอร์ และปุ่มเขียนจดหมายทรงกลมบนรายการข้อความ ปิดตัวเลือกนี้เพื่อย้ายปุ่มเขียนจดหมายไปไว้เหนือโฟลเดอร์แทน"
},
"show_rail_account_list": {
"label": "แสดงรูปประจำตัวบัญชีบนแถบนำทางหลัก",
"description": "แสดงวงกลมบัญชีแต่ละบัญชีที่ด้านล่างของแถบนำทางหลักเพื่อการสลับบัญชีอย่างรวดเร็ว พร้อมปุ่มลงชื่อออกด้านล่าง"
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Kenar Çubuğu Hesap Değiştiriciyi Gizle",
"description": "Klasör kenar çubuğunun üstündeki hesap seçiciyi gizleyin. Yine de alt gezinme çubuğundan hesap değiştirebilirsiniz."
},
"sidebar_account_switcher": {
"label": "Kenar çubuğunun üstünde hesap değiştirici",
"description": "Hesap değiştiriciyi klasörlerin üstünde, yuvarlak Yeni E-posta düğmesini ise ileti listesinin üzerinde gösterir. Kapatırsanız Yeni E-posta bunun yerine klasörlerin üstünde yer alır."
},
"show_rail_account_list": {
"label": "Gezinme Çubuğunda Hesap Avatarlarını Göster",
"description": "Hızlı geçiş için gezinme çubuğunun altında ayrı hesap dairelerini ve altında oturumu kapat düğmesini görüntüleyin."
+4
View File
@@ -1658,6 +1658,10 @@
"label": "Приховати перемикач облікових записів на бічній панелі",
"description": "Сховайте селектор облікових записів у верхній частині бічної панелі папок. Ви все ще можете перемикати облікові записи з нижньої панелі навігації."
},
"sidebar_account_switcher": {
"label": "Перемикач облікових записів угорі бічної панелі",
"description": "Показує перемикач облікових записів над папками й круглу кнопку «Скласти» над списком листів. Вимкніть, щоб кнопка «Скласти» була над папками."
},
"show_rail_account_list": {
"label": "Показувати аватари облікових записів на панелі навігації",
"description": "Відображати кола окремих облікових записів у нижній частині панелі навігації для швидкого перемикання з кнопкою виходу внизу."
+4
View File
@@ -1673,6 +1673,10 @@
"label": "Ẩn bộ chuyển tài khoản ở thanh bên",
"description": "Ẩn bộ chọn tài khoản ở đầu thanh bên thư mục. Bạn vẫn có thể chuyển tài khoản từ thanh điều hướng phía dưới."
},
"sidebar_account_switcher": {
"label": "Bộ chuyển tài khoản ở đầu thanh bên",
"description": "Hiện bộ chuyển tài khoản phía trên các thư mục và nút Soạn thư hình tròn trên danh sách thư. Tắt tùy chọn này để đặt Soạn thư phía trên các thư mục."
},
"show_rail_account_list": {
"label": "Hiển thị ảnh đại diện tài khoản trên thanh điều hướng",
"description": "Hiển thị ảnh đại diện từng tài khoản ở cuối thanh điều hướng để chuyển nhanh, cùng nút đăng xuất bên dưới."
+4
View File
@@ -1661,6 +1661,10 @@
"label": "隱藏側邊欄帳號切換器",
"description": "隱藏信件匣側邊欄頂端的帳號選取器。您仍可從底部導覽列切換帳號。"
},
"sidebar_account_switcher": {
"label": "側邊欄頂端的帳號切換器",
"description": "在信件匣上方顯示帳號切換器,並在郵件列表上顯示圓形的「撰寫郵件」按鈕。關閉後,「撰寫郵件」會改為顯示在信件匣上方。"
},
"show_rail_account_list": {
"label": "在導覽列顯示帳號頭像",
"description": "在導覽列底部顯示各帳號的圓形頭像,方便快速切換,並在下方顯示登出按鈕。"
+4
View File
@@ -1658,6 +1658,10 @@
"label": "隐藏侧边栏账户切换器",
"description": "隐藏文件夹侧边栏顶部的账户选择器。您仍然可以从底部导航栏切换账户。"
},
"sidebar_account_switcher": {
"label": "侧边栏顶部的账户切换器",
"description": "在文件夹上方显示账户切换器,并在邮件列表上显示圆形的“撰写邮件”按钮。关闭后,“撰写邮件”会改为显示在文件夹上方。"
},
"show_rail_account_list": {
"label": "在导航导轨上显示账户头像",
"description": "在导航栏底部显示账户头像,方便快速切换;下方会保留退出按钮。"
+7 -7
View File
@@ -1,22 +1,22 @@
{
"open": {
"commits": 8,
"renders": 475,
"requests": 1
"commits": 6,
"renders": 630,
"requests": 2
},
"search": {
"commits": 9,
"renders": 454,
"renders": 513,
"requests": 2
},
"folder (first visit)": {
"commits": 8,
"renders": 488,
"commits": 7,
"renders": 591,
"requests": 2
},
"folder (revisit)": {
"commits": 3,
"renders": 275,
"renders": 337,
"requests": 1
}
}
+7 -4
View File
@@ -467,8 +467,11 @@ interface EmailStore {
// times; later calls are no-ops while a prior one is in flight.
prefetchInitialData: (client: IJMAPClient) => Promise<void>;
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
/** `ownerAccountId`: the JMAP account a link names for the message (#1351). */
fetchEmailContent: (client: IJMAPClient, emailId: string, ownerAccountId?: string) => Promise<Email | null>;
/**
* `ownerAccountId`: the JMAP account a link names for the message (#1351).
* `select: false`: hand the message back without opening it.
*/
fetchEmailContent: (client: IJMAPClient, emailId: string, ownerAccountId?: string, options?: { select?: boolean }) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>;
/**
* `options.outbox`: a send from the composer or quick reply, which offline
@@ -4349,7 +4352,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
fetchEmailContent: async (client, emailId, ownerAccountId) => {
fetchEmailContent: async (client, emailId, ownerAccountId, options) => {
try {
// Route to the owning account. In aggregate views (All Mail, unified,
// cross-account) the selected mailbox is virtual, so derive the client +
@@ -4382,7 +4385,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
annotatedEmail.accountId = listEmail?.accountId;
annotatedEmail.accountLabel = listEmail?.accountLabel;
annotatedEmail.sourceFolder = listEmail?.sourceFolder;
set({ selectedEmail: annotatedEmail });
if (options?.select !== false) set({ selectedEmail: annotatedEmail });
return annotatedEmail;
}
return email;
+5
View File
@@ -732,6 +732,9 @@ interface SettingsState extends AccessibilitySettings {
toolbarPosition: ToolbarPosition;
showToolbarLabels: boolean;
hideAccountSwitcher: boolean;
// The account switcher at the head of the folder sidebar, with the round
// Compose button over the list. Off puts Compose at the sidebar's head.
sidebarAccountSwitcher: boolean;
showRailAccountList: boolean;
proInterface: boolean;
// Settings marked `advanced` (power-user and integrator rows) stay hidden
@@ -1062,6 +1065,7 @@ const DEFAULT_SETTINGS = {
toolbarPosition: 'top' as ToolbarPosition,
showToolbarLabels: true,
hideAccountSwitcher: false,
sidebarAccountSwitcher: true,
showRailAccountList: false,
proInterface: false,
showAdvancedSettings: false,
@@ -1485,6 +1489,7 @@ export const useSettingsStore = create<SettingsState>()(
hiddenCompanyCalendars: state.hiddenCompanyCalendars,
toolbarPosition: state.toolbarPosition,
hideAccountSwitcher: state.hideAccountSwitcher,
sidebarAccountSwitcher: state.sidebarAccountSwitcher,
showRailAccountList: state.showRailAccountList,
showAdvancedSettings: state.showAdvancedSettings,
// proInterface is intentionally omitted - it's a per-device choice