mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: add Mountain View, an optional alternative layout
Add an "Interface layout" setting at the top of Settings > Layout with two options, Default and Mountain View. Default is the existing layout and stays the default, so nothing changes unless a user opts in. Mountain View rearranges the chrome while keeping the theme's colours, icons and type: - desktop: a top bar with a wide search field (filters drop from the field), an apps grid and the account avatar instead of the left app rail; flat navigation with Starred under Inbox and the rest behind "More"; a Compose pill; one list toolbar with select-all, refresh, overflow menu, a 1-N of total range and the batch actions; rows led by a checkbox and a star; an icon-only message toolbar with Reply / Reply all / Forward under the message; compose in a docked window; an account popover with per-account unread counts; j/k cursor, x to select and g-sequences - phone: rounded search header, compact rows with the star on the preview line, and Reply / Reply all / Forward at the end of a message instead of the bottom action bar It is one setting (`interfaceLayout`), written to a data attribute on <html> that keys a block of CSS tokens in globals.css, plus a few components gated on the flag. The mail layout in force is derived (effectiveMailLayout), so the user's saved mail layout is never rewritten. The setting is listed in the admin policy tab, and the new strings are translated in all locales. Includes unit tests and a Playwright smoke test against a running instance (scripts/mountain-view-smoke/).
This commit is contained in:
@@ -42,6 +42,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
|
||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||
{ key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'] },
|
||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
|
||||
|
||||
+107
@@ -910,3 +910,110 @@ optgroup {
|
||||
[dir="rtl"] .tabler-icon-layout-sidebar-right {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Interface layouts
|
||||
--------------------------------------------------------------------------
|
||||
`data-interface-layout` is set on <html> by the settings store
|
||||
(`applyInterfaceLayout`). The default layout declares no overrides, so
|
||||
everything below is additive and switched off by a single attribute.
|
||||
Mountain View sets its own geometry - bar heights, rail widths, pill shapes,
|
||||
row rhythm - while colour, iconography and type keep coming from the active
|
||||
theme.
|
||||
========================================================================== */
|
||||
|
||||
[data-interface-layout="mountain-view"] {
|
||||
--mv-topbar-h: 64px;
|
||||
--mv-rail-w: 72px;
|
||||
--mv-rail-peek-w: 256px;
|
||||
--mv-nav-row-h: 32px;
|
||||
--mv-nav-radius: 9999px;
|
||||
--mv-compose-h: 56px;
|
||||
--mv-search-h: 48px;
|
||||
--mv-search-radius: 8px;
|
||||
--mv-popover-w: 352px;
|
||||
--mv-popover-radius: 28px;
|
||||
}
|
||||
|
||||
/* Search: a filled 48px field that lifts onto a card while focused, rather
|
||||
than the outlined input the default layout uses. */
|
||||
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input] {
|
||||
height: var(--mv-search-h);
|
||||
border-radius: var(--mv-search-radius);
|
||||
border-color: transparent;
|
||||
background-color: var(--color-muted);
|
||||
padding-inline-start: 3rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input]:hover {
|
||||
border-color: transparent;
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input]:focus-visible {
|
||||
background-color: var(--color-background);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.16), 0 4px 8px 3px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* The filter button sits inside the field's trailing end: keep typed text and
|
||||
the clear button clear of it. */
|
||||
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input] {
|
||||
padding-inline-end: 5.5rem;
|
||||
}
|
||||
|
||||
[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > button {
|
||||
inset-inline-end: 3.25rem;
|
||||
}
|
||||
|
||||
/* The magnifier sits on the field's baseline grid, one 48px hit area in. */
|
||||
[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > svg {
|
||||
inset-inline-start: 1rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Folder rows: 32px pills flush with the rail's leading edge and rounded only
|
||||
on the trailing side, so the selected label reads as one continuous shape
|
||||
with the rail. The default layout's leading accent stripe goes away with it. */
|
||||
[data-interface-layout="mountain-view"] [data-mv-nav-row] {
|
||||
min-height: var(--mv-nav-row-h);
|
||||
margin-inline-end: 12px;
|
||||
padding-inline-end: 16px;
|
||||
border-inline-start-width: 0;
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
border-start-end-radius: var(--mv-nav-radius);
|
||||
border-end-end-radius: var(--mv-nav-radius);
|
||||
}
|
||||
|
||||
/* Compose: a 56px pill, which shrinks to a circle with the rail. */
|
||||
[data-interface-layout="mountain-view"] [data-mv-compose] {
|
||||
height: var(--mv-compose-h);
|
||||
border-radius: var(--mv-nav-radius);
|
||||
}
|
||||
|
||||
/* The account popover: a wide card with a very round corner, rather
|
||||
than the narrow menu the rail and the sidebar switchers drop. */
|
||||
[data-interface-layout="mountain-view"] [data-mv-account-popover] {
|
||||
width: var(--mv-popover-w);
|
||||
border-radius: var(--mv-popover-radius);
|
||||
}
|
||||
|
||||
/* The advanced filters drop from the search field with a small gap and take
|
||||
its corner, so the two read as one control. Position and width come from
|
||||
the field's own box, measured by FilterPanelHost. */
|
||||
[data-interface-layout="mountain-view"] [data-mv-filter-panel] {
|
||||
border-radius: var(--mv-search-radius);
|
||||
}
|
||||
|
||||
/* The collapsed rail's hover peek. Only on desktop: below that breakpoint the
|
||||
sidebar is already a drawer, opened by a tap rather than by a pointer that
|
||||
does not exist. The width beats Tailwind's own `lg:w-full` on specificity,
|
||||
which is what keeps the floating panel from filling the 72px column. */
|
||||
@media (min-width: 1024px) {
|
||||
[data-interface-layout="mountain-view"] [data-mv-rail-peek] {
|
||||
width: var(--mv-rail-peek-w);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { KEYWORD_PREFIX } from '@/lib/thread-utils';
|
||||
|
||||
// "Move to" and "Labels" were reachable only from a row's context menu, so a
|
||||
// selection could be archived or deleted from the toolbar but not filed. Both
|
||||
// now sit in the batch half of the Mountain View list toolbar.
|
||||
|
||||
const { MvListToolbar } = await import('../mv-list-toolbar');
|
||||
const { useEmailStore } = await import('@/stores/email-store');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
const rights = {
|
||||
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
|
||||
maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
|
||||
};
|
||||
|
||||
const mailbox = (id: string, name: string, role?: string): Mailbox =>
|
||||
({ id, name, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0,
|
||||
unreadThreads: 0, isSubscribed: true, myRights: rights }) as Mailbox;
|
||||
|
||||
const message = (id: string, keywords: Record<string, boolean> = {}): Email =>
|
||||
({ id, threadId: id, keywords, mailboxIds: { inbox: true } }) as unknown as Email;
|
||||
|
||||
const mailboxes = [mailbox('inbox', 'Inbox', 'inbox'), mailbox('receipts', 'Receipts')];
|
||||
|
||||
describe('MvListToolbar filing actions', () => {
|
||||
const batchMoveToMailbox = vi.fn().mockResolvedValue(undefined);
|
||||
const batchSetTag = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
batchMoveToMailbox.mockClear();
|
||||
batchSetTag.mockClear();
|
||||
const emails = [message('a', { [KEYWORD_PREFIX + 'work']: true }), message('b')];
|
||||
useAuthStore.setState({ client: {} as never } as never);
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
|
||||
nestedTags: false,
|
||||
} as never);
|
||||
useEmailStore.setState({
|
||||
emails,
|
||||
mailboxes,
|
||||
selectedMailbox: 'inbox',
|
||||
selectedEmailIds: new Set(emails.map((email) => email.id)),
|
||||
isUnifiedView: false,
|
||||
unifiedRole: null,
|
||||
batchMoveToMailbox,
|
||||
batchSetTag,
|
||||
} as never);
|
||||
});
|
||||
|
||||
const toolbar = () =>
|
||||
render(<MvListToolbar loadedCount={2} onRefresh={() => {}} />);
|
||||
|
||||
it('files the selection into a folder that will take it', async () => {
|
||||
toolbar();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('move_to'));
|
||||
fireEvent.click(screen.getByText('Receipts'));
|
||||
|
||||
await waitFor(() => expect(batchMoveToMailbox).toHaveBeenCalledWith({}, 'receipts'));
|
||||
});
|
||||
|
||||
it('does not offer the folder you are already in as a destination', () => {
|
||||
toolbar();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('move_to'));
|
||||
expect(screen.queryByText('mailboxes.inbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts a tag on the whole selection when only some of it carries the tag', async () => {
|
||||
toolbar();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('tag'));
|
||||
fireEvent.click(screen.getByText('Work'));
|
||||
|
||||
await waitFor(() => expect(batchSetTag).toHaveBeenCalledTimes(1));
|
||||
expect(batchSetTag).toHaveBeenCalledWith({}, 'work', true);
|
||||
});
|
||||
|
||||
it('takes a tag off once the whole selection carries it', async () => {
|
||||
const emails = [
|
||||
message('a', { [KEYWORD_PREFIX + 'work']: true }),
|
||||
message('b', { [KEYWORD_PREFIX + 'work']: true }),
|
||||
];
|
||||
useEmailStore.setState({
|
||||
emails,
|
||||
selectedEmailIds: new Set(emails.map((email) => email.id)),
|
||||
} as never);
|
||||
toolbar();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('tag'));
|
||||
fireEvent.click(screen.getByText('Work'));
|
||||
|
||||
await waitFor(() => expect(batchSetTag).toHaveBeenCalledTimes(1));
|
||||
expect(batchSetTag).toHaveBeenCalledWith({}, 'work', false);
|
||||
});
|
||||
|
||||
it('shows neither control until something is selected', () => {
|
||||
useEmailStore.setState({ selectedEmailIds: new Set() } as never);
|
||||
toolbar();
|
||||
|
||||
expect(screen.queryByLabelText('move_to')).toBeNull();
|
||||
expect(screen.queryByLabelText('tag')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MvListToolbar overflow menu', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ client: {} as never } as never);
|
||||
useEmailStore.setState({
|
||||
emails: [message('a')],
|
||||
mailboxes,
|
||||
selectedMailbox: 'inbox',
|
||||
selectedEmailIds: new Set(),
|
||||
isUnifiedView: false,
|
||||
unifiedRole: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
const open = () => fireEvent.click(screen.getAllByLabelText('mark_folder_read')[0]);
|
||||
|
||||
it('offers marking every folder read, not just this one', () => {
|
||||
render(
|
||||
<MvListToolbar
|
||||
loadedCount={1}
|
||||
onRefresh={() => {}}
|
||||
onMarkFolderRead={() => {}}
|
||||
onMarkAllFoldersRead={() => {}}
|
||||
/>
|
||||
);
|
||||
open();
|
||||
|
||||
expect(screen.getByText('mark_all_folders_read')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('withholds emptying the folder outside spam and the bin', () => {
|
||||
render(<MvListToolbar loadedCount={1} onRefresh={() => {}} onEmptyFolder={() => {}} />);
|
||||
open();
|
||||
|
||||
expect(screen.queryByText('empty_folder')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers it in the bin', () => {
|
||||
useEmailStore.setState({
|
||||
mailboxes: [mailbox('trash', 'Trash', 'trash')],
|
||||
selectedMailbox: 'trash',
|
||||
} as never);
|
||||
const onEmptyFolder = vi.fn();
|
||||
render(<MvListToolbar loadedCount={1} onRefresh={() => {}} onEmptyFolder={onEmptyFolder} />);
|
||||
open();
|
||||
|
||||
fireEvent.click(screen.getByText('empty_folder'));
|
||||
expect(onEmptyFolder).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { render, screen, act, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ThreadListItem } from '../thread-list-item';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { groupEmailsByThread } from '@/lib/thread-utils';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
@@ -60,12 +61,17 @@ function renderRow(email: Email) {
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
|
||||
});
|
||||
|
||||
describe('ThreadListItem tag badge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
interfaceLayout: 'default',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
@@ -139,6 +145,7 @@ describe('ThreadListItem multi-message thread', () => {
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
interfaceLayout: 'default',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
@@ -200,6 +207,7 @@ describe('ThreadListItem row content', () => {
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
interfaceLayout: 'default',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
@@ -223,6 +231,51 @@ describe('ThreadListItem row content', () => {
|
||||
// rather than getting a paragraph of its own.
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts the star at the trailing edge in the Mountain View phone row', () => {
|
||||
useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split' });
|
||||
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
|
||||
const onToggleStar = vi.fn();
|
||||
const [thread] = groupEmailsByThread([makeEmail()]);
|
||||
const { container } = render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
onToggleStar={onToggleStar}
|
||||
/>,
|
||||
);
|
||||
|
||||
const star = container.querySelector<HTMLButtonElement>('[data-mv-mobile-row] button[aria-pressed]');
|
||||
expect(star).not.toBeNull();
|
||||
fireEvent.click(star!);
|
||||
|
||||
expect(onToggleStar).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector('[data-mv-mobile-row]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem Mountain View phone row layout', () => {
|
||||
it('sets the star at the end of the preview line, not under the time', () => {
|
||||
useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split', showPreview: true, density: 'regular' } as never);
|
||||
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
|
||||
const [thread] = groupEmailsByThread([makeEmail()]);
|
||||
const { container } = render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
onToggleStar={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stars = container.querySelectorAll('[data-mv-mobile-row] button[aria-pressed]');
|
||||
expect(stars).toHaveLength(1);
|
||||
// Same line as the preview text: snippet ... star.
|
||||
expect(stars[0].parentElement?.querySelector('p')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem shift-range avatar selection', () => {
|
||||
@@ -231,6 +284,7 @@ describe('ThreadListItem shift-range avatar selection', () => {
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
interfaceLayout: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,6 +322,7 @@ describe('ThreadListItem row tint', () => {
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
interfaceLayout: 'default',
|
||||
tintListRowsByTag: true,
|
||||
});
|
||||
useEmailStore.setState({
|
||||
@@ -311,6 +366,7 @@ describe('ThreadListItem account row tint', () => {
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
interfaceLayout: 'default',
|
||||
tintListRowsByTag: true,
|
||||
tintListRowsByAccount: false,
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useEmailStore, ArchiveMailboxNotFoundError } from "@/stores/email-store
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
|
||||
import { groupEmailsByThread, sortThreadGroups, threadKeyFor, getEmailTagIds } from "@/lib/thread-utils";
|
||||
@@ -62,6 +63,11 @@ interface EmailListProps {
|
||||
onLoadMoreScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
|
||||
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
|
||||
/**
|
||||
* Mountain View folds the batch verbs into the single toolbar above the
|
||||
* list, so the separate bar that would otherwise slide in here is redundant.
|
||||
*/
|
||||
hideBatchToolbar?: boolean;
|
||||
}
|
||||
|
||||
export function EmailList({
|
||||
@@ -91,6 +97,7 @@ export function EmailList({
|
||||
onMoveToMailbox,
|
||||
onEditDraft,
|
||||
isScheduledView = false,
|
||||
hideBatchToolbar = false,
|
||||
onLoadMoreScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
@@ -259,7 +266,7 @@ export function EmailList({
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const mailLayout = useEffectiveMailLayout();
|
||||
const footerHasMore = hasMore ?? hasMoreEmails;
|
||||
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
@@ -529,7 +536,7 @@ export function EmailList({
|
||||
ref={batchToolbarRef}
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
|
||||
import { useCopyLink } from "@/hooks/use-copy-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { emailDisplayDate } from "@/lib/email-date";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
@@ -89,6 +89,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -705,8 +706,9 @@ export function EmailViewer({
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const showToolbarLabelsSetting = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
|
||||
const mailLayout = useEffectiveMailLayout();
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||
const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse);
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
@@ -758,6 +760,15 @@ export function EmailViewer({
|
||||
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
// Mountain View's conversation view: an icon-only toolbar at the top, and Reply /
|
||||
// Reply all / Forward as buttons under the message rather than in the bar.
|
||||
const mvViewer = interfaceLayout === 'mountain-view' && !isMobile;
|
||||
// On the phone too Mountain View ends the message with Reply / Reply all / Forward
|
||||
// and has no bar fixed at the bottom; previous / next move to the top bar.
|
||||
const mvMobileViewer = interfaceLayout === 'mountain-view' && isMobile;
|
||||
const mvReplyRow = mvViewer || mvMobileViewer;
|
||||
// Mountain View's toolbars are icon-only, on the phone as on the desktop.
|
||||
const showToolbarLabels = showToolbarLabelsSetting && interfaceLayout !== 'mountain-view';
|
||||
// Inside a Pro pane, `isMobile` above is pane-width based: overlays that
|
||||
// would go viewport-fixed must instead cover just the pane (via
|
||||
// PaneOverlay + absolute positioning), and viewport CSS breakpoints like
|
||||
@@ -3040,6 +3051,32 @@ export function EmailViewer({
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
{showBackButton && mvMobileViewer && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onNavigatePrev}
|
||||
disabled={!onNavigatePrev}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
aria-label={t('tooltips.previous')}
|
||||
title={t('tooltips.previous')}
|
||||
>
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onNavigateNext}
|
||||
disabled={!onNavigateNext}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
aria-label={t('tooltips.next')}
|
||||
title={t('tooltips.next')}
|
||||
>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isScheduled && canCancelScheduled && (
|
||||
<>
|
||||
<Button
|
||||
@@ -3084,7 +3121,7 @@ export function EmailViewer({
|
||||
<span className="text-sm">{t('edit_draft')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isScheduled && !isDraft && (<>
|
||||
{!isScheduled && !isDraft && !mvViewer && (<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3362,7 +3399,7 @@ export function EmailViewer({
|
||||
onClick={() => { setMoreMenuOpen(!moreMenuOpen); setMoreMenuSlideEnabled(true); setMoreMenuSub(null); setTagMenuOpen(false); setMoveMenuOpen(false); }}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
|
||||
{interfaceLayout !== 'mountain-view' && <span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>}
|
||||
</Button>
|
||||
{moreMenuOpen && !isMobile && (
|
||||
<div
|
||||
@@ -3985,8 +4022,9 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
||||
<div className="hidden sm:block flex-shrink-0 text-end">
|
||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender.
|
||||
Mountain View puts it on the sender's row instead, and shows no size. */}
|
||||
<div className={cn("hidden flex-shrink-0 text-end", !mvViewer && "sm:block")}>
|
||||
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
@@ -4012,7 +4050,7 @@ export function EmailViewer({
|
||||
)}
|
||||
|
||||
{/* Email Content Area */}
|
||||
<div className={cn("flex-1 overflow-auto overscroll-contain bg-muted/30", isMobile && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
|
||||
<div className={cn("flex-1 overflow-auto overscroll-contain bg-muted/30", isMobile && !mvMobileViewer && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
|
||||
<div className="min-h-full flex flex-col">
|
||||
|
||||
{/* === SENDER INFO (Desktop) === */}
|
||||
@@ -4047,6 +4085,9 @@ export function EmailViewer({
|
||||
) : (
|
||||
<span className="font-semibold text-foreground">{t('unknown_sender')}</span>
|
||||
)}
|
||||
{mvViewer && sender?.email && sender?.name && (
|
||||
<span className="text-xs text-muted-foreground truncate"><{sender.email}></span>
|
||||
)}
|
||||
<EmailIdentityBadge email={email} identities={identities} />
|
||||
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
||||
<UnsubscribeBanner
|
||||
@@ -4061,9 +4102,14 @@ export function EmailViewer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mvViewer && (
|
||||
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap" data-mv-sender-date="">
|
||||
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Email address under name */}
|
||||
{sender?.email && sender?.name && (
|
||||
{/* Email address under name (on the name's line under Mountain View) */}
|
||||
{!mvViewer && sender?.email && sender?.name && (
|
||||
<div className="text-sm text-muted-foreground mt-0.5 truncate">{sender.email}</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -4336,8 +4382,8 @@ export function EmailViewer({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Email address under name */}
|
||||
{sender?.email && sender?.name && (
|
||||
{/* Email address under name - Mountain View keeps it in the details */}
|
||||
{!mvMobileViewer && sender?.email && sender?.name && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 truncate">{sender.email}</div>
|
||||
)}
|
||||
{/* Row 2: Recipients */}
|
||||
@@ -4379,9 +4425,11 @@ export function EmailViewer({
|
||||
{/* Date/time + size on the right (mobile) */}
|
||||
<div className="sm:hidden flex-shrink-0 text-end ms-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
{mvMobileViewer
|
||||
? formatDate(emailDisplayDate(email))
|
||||
: formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
{email.size > 0 && (
|
||||
{email.size > 0 && !mvMobileViewer && (
|
||||
<div className="text-xs text-muted-foreground/60">
|
||||
{formatFileSize(email.size)}
|
||||
</div>
|
||||
@@ -5322,8 +5370,29 @@ export function EmailViewer({
|
||||
|
||||
<PluginSlot name="email-footer" />
|
||||
|
||||
{/* Mountain View: Reply / Reply all / Forward as buttons under the message. */}
|
||||
{mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
|
||||
<div
|
||||
className={cn("pb-6 pt-2", isMobile ? "grid grid-cols-3 gap-2 px-4" : "flex flex-wrap items-center gap-2 px-6")}
|
||||
data-mv-reply-row=""
|
||||
>
|
||||
<Button variant="outline" className={cn("h-9 rounded-full gap-2", isMobile ? "min-w-0 px-2 text-xs" : "px-5")} onClick={() => onReply?.()}>
|
||||
<Reply className="w-4 h-4" />
|
||||
{t('reply')}
|
||||
</Button>
|
||||
<Button variant="outline" className={cn("h-9 rounded-full gap-2", isMobile ? "min-w-0 px-2 text-xs" : "px-5")} onClick={onReplyAll}>
|
||||
<ReplyAll className="w-4 h-4" />
|
||||
{t('reply_all')}
|
||||
</Button>
|
||||
<Button variant="outline" className={cn("h-9 rounded-full gap-2", isMobile ? "min-w-0 px-2 text-xs" : "px-5")} onClick={onForward}>
|
||||
<Forward className="w-4 h-4" />
|
||||
{t('forward')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||
{!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
{!mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar
|
||||
@@ -5470,7 +5539,7 @@ export function EmailViewer({
|
||||
{/* Mobile bottom action bar - pane-scoped inside a Pro pane (the
|
||||
viewport `sm:hidden` guard would otherwise hide it there, and
|
||||
`fixed` would span the whole app instead of the pane). */}
|
||||
{isMobile && (
|
||||
{isMobile && !mvMobileViewer && (
|
||||
<nav className={cn(
|
||||
"z-50 bg-background border-t border-border overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
|
||||
isPaneScoped ? "absolute bottom-0 left-0 right-0" : "fixed bottom-0 left-0 right-0 sm:hidden",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Maximize2, Minimize2, Minus } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WindowState = "normal" | "minimized" | "maximized";
|
||||
|
||||
/**
|
||||
* Mountain View's compose window: a panel docked at the bottom-right corner
|
||||
* over the mail list, which can be folded down to its title bar or opened up
|
||||
* to a large centred dialog. The composer inside stays mounted throughout, so folding it
|
||||
* keeps the draft exactly as it was.
|
||||
*/
|
||||
export function MvComposeWindow({ children }: { children: ReactNode }) {
|
||||
const t = useTranslations("email_composer");
|
||||
const [state, setState] = useState<WindowState>("normal");
|
||||
const minimized = state === "minimized";
|
||||
const maximized = state === "maximized";
|
||||
|
||||
return (
|
||||
<>
|
||||
{maximized && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40"
|
||||
aria-hidden
|
||||
onClick={() => setState("normal")}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
data-mv-compose-window=""
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"fixed z-40 flex flex-col overflow-hidden border border-border bg-background shadow-2xl",
|
||||
maximized
|
||||
? "inset-x-[8vw] inset-y-[6vh] rounded-2xl"
|
||||
: "bottom-0 end-6 w-[min(560px,calc(100vw-3rem))] rounded-t-2xl",
|
||||
minimized && "h-14",
|
||||
state === "normal" && "h-[min(640px,calc(100dvh-5rem))]",
|
||||
)}
|
||||
>
|
||||
<div className="absolute top-2.5 end-3 z-10 flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setState(minimized ? "normal" : "minimized")}
|
||||
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label={minimized ? t("restore") : t("minimize")}
|
||||
title={minimized ? t("restore") : t("minimize")}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setState(maximized ? "normal" : "maximized")}
|
||||
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label={maximized ? t("restore") : t("maximize")}
|
||||
title={maximized ? t("restore") : t("maximize")}
|
||||
>
|
||||
{maximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Folded down, the title bar is the whole window and opens it again. */}
|
||||
{minimized && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 end-24 z-[5]"
|
||||
aria-label={t("restore")}
|
||||
onClick={() => setState("normal")}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Archive,
|
||||
CheckSquare,
|
||||
Folder,
|
||||
FolderInput,
|
||||
Loader2,
|
||||
Mail,
|
||||
MailOpen,
|
||||
MoreVertical,
|
||||
RotateCcw,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Tag,
|
||||
Trash2,
|
||||
} from "@/components/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { TagPicker } from "@/components/email/tag-picker";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { cn, type MailboxNode } from "@/lib/utils";
|
||||
|
||||
interface MvListToolbarProps {
|
||||
/** Loaded conversation count, for the right-hand range readout. */
|
||||
loadedCount: number;
|
||||
/** Total in the folder, when the server has told us. */
|
||||
totalCount?: number;
|
||||
onRefresh: () => void;
|
||||
isRefreshing?: boolean;
|
||||
onMarkFolderRead?: () => void;
|
||||
onMarkAllFoldersRead?: () => void;
|
||||
/** Only offered where emptying is the usual thing to do: spam and the bin. */
|
||||
onEmptyFolder?: () => void;
|
||||
/** Scheduled view: only the per-message scheduling actions make sense. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
busy,
|
||||
className,
|
||||
}: {
|
||||
icon: typeof Archive;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"grid place-items-center w-10 h-10 rounded-full transition-colors shrink-0",
|
||||
"text-muted-foreground hover:bg-foreground/10 hover:text-foreground",
|
||||
disabled && "opacity-50 cursor-not-allowed hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{busy ? <Loader2 className="w-[18px] h-[18px] animate-spin" /> : <Icon className="w-[18px] h-[18px]" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mountain View's single list toolbar: one row above the list that morphs.
|
||||
* With nothing selected it offers select-all, refresh and an overflow menu;
|
||||
* as soon as a conversation is ticked the batch verbs appear in the same row,
|
||||
* rather than in a second bar that pushes the list down.
|
||||
*/
|
||||
export function MvListToolbar({
|
||||
loadedCount,
|
||||
totalCount,
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
onMarkFolderRead,
|
||||
onMarkAllFoldersRead,
|
||||
onEmptyFolder,
|
||||
disabled = false,
|
||||
}: MvListToolbarProps) {
|
||||
const t = useTranslations("email_list");
|
||||
const tBatch = useTranslations("email_list.batch_actions");
|
||||
const tActions = useTranslations("settings.email_behavior.hover_actions");
|
||||
const tFolder = useTranslations("mailbox_context_menu");
|
||||
const tMenu = useTranslations("context_menu");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const {
|
||||
emails,
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
selectedEmailIds,
|
||||
selectAllEmails,
|
||||
clearSelection,
|
||||
batchArchive,
|
||||
batchDelete,
|
||||
batchMarkAsRead,
|
||||
batchMarkAsSpam,
|
||||
batchUndoSpam,
|
||||
batchMoveToMailbox,
|
||||
batchSetTag,
|
||||
isUnifiedView,
|
||||
unifiedRole,
|
||||
} = useEmailStore();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||
const { menuRef, onKeyDown: onMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
|
||||
open: menuOpen,
|
||||
onClose: closeMenu,
|
||||
triggerRef: menuButtonRef,
|
||||
});
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
const moveButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeMove = useCallback(() => setMoveOpen(false), []);
|
||||
const { menuRef: moveRef, onKeyDown: onMoveKeyDown } = useMenuNavigation<HTMLDivElement>({
|
||||
open: moveOpen,
|
||||
onClose: closeMove,
|
||||
triggerRef: moveButtonRef,
|
||||
});
|
||||
|
||||
const [tagOpen, setTagOpen] = useState(false);
|
||||
const tagButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeTag = useCallback(() => setTagOpen(false), []);
|
||||
const { menuRef: tagRef, onKeyDown: onTagKeyDown } = useMenuNavigation<HTMLDivElement>({
|
||||
open: tagOpen,
|
||||
onClose: closeTag,
|
||||
triggerRef: tagButtonRef,
|
||||
});
|
||||
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (
|
||||
menuButtonRef.current?.contains(e.target as Node) ||
|
||||
menuRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [menuOpen, menuRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!moveOpen && !tagOpen) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
const inside = (button: HTMLElement | null, menu: HTMLElement | null) =>
|
||||
button?.contains(e.target as Node) || menu?.contains(e.target as Node);
|
||||
if (!inside(moveButtonRef.current, moveRef.current)) setMoveOpen(false);
|
||||
if (!inside(tagButtonRef.current, tagRef.current)) setTagOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [moveOpen, tagOpen, moveRef, tagRef]);
|
||||
|
||||
const selectionCount = selectedEmailIds.size;
|
||||
const hasSelection = selectionCount > 0;
|
||||
const allSelected = hasSelection && selectionCount === emails.length;
|
||||
// Optimistic updates and demo fixtures can briefly leave the server total
|
||||
// behind the rows already loaded. Never render an impossible "23 of 22".
|
||||
const displayedTotal = Math.max(totalCount ?? loadedCount, loadedCount);
|
||||
|
||||
const currentRole = mailboxes.find((mb) => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const isInJunk = currentRole === "junk";
|
||||
const isInTrash = currentRole === "trash";
|
||||
// Marking your own drafts or sent mail as spam is meaningless.
|
||||
const spamApplicable = !["sent", "drafts", "scheduled"].includes(currentRole ?? "");
|
||||
|
||||
const run = async (action: () => Promise<void>) => {
|
||||
if (!client || isProcessing) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
setTimeout(() => setIsProcessing(false), 400);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllToggle = () => {
|
||||
if (disabled) return;
|
||||
if (hasSelection) {
|
||||
if (allSelected) clearSelection();
|
||||
else selectAllEmails();
|
||||
return;
|
||||
}
|
||||
if (emails.length > 0) selectAllEmails();
|
||||
};
|
||||
|
||||
const handleDelete = () =>
|
||||
run(async () => {
|
||||
if (!client) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: isInTrash ? t("permanent_delete_confirm_title") : tBatch("delete_confirm_title"),
|
||||
message: isInTrash
|
||||
? t("permanent_delete_confirm_batch_message", { count: selectionCount })
|
||||
: tBatch("delete_confirm_message", { count: selectionCount }),
|
||||
confirmText: isInTrash ? t("permanent_delete") : tBatch("delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await batchDelete(client, isInTrash);
|
||||
});
|
||||
|
||||
const selectedEmails = emails.filter((email) => selectedEmailIds.has(email.id));
|
||||
|
||||
// The same targets the row's own context menu offers, with the account of
|
||||
// the selection leading (#1149).
|
||||
const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, {
|
||||
currentMailboxId: selectedMailbox,
|
||||
ownerAccountId: resolveMoveOwnerAccountId(selectedEmails[0], mailboxes, selectedMailbox),
|
||||
});
|
||||
|
||||
const renderMoveNodes = (nodes: MailboxNode[], depth = 0): React.ReactNode =>
|
||||
nodes.map((node) => {
|
||||
const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`));
|
||||
const isTarget = moveTargetIds.has(node.id);
|
||||
return (
|
||||
<div key={node.id}>
|
||||
{isTarget ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleMove(node.id)}
|
||||
style={{ paddingInlineStart: `${12 + depth * 16}px` }}
|
||||
className="w-full flex items-center gap-2 pe-3 py-2 text-start text-sm hover:bg-muted"
|
||||
>
|
||||
<Folder className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
style={{ paddingInlineStart: `${12 + depth * 16}px` }}
|
||||
className="flex items-center gap-2 pe-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Folder className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
{node.children.length > 0 && renderMoveNodes(node.children, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// What the tag picker shows: a tag every selected message carries is
|
||||
// checked, one only some of them carry is drawn as partial.
|
||||
const tagCounts = new Map<string, number>();
|
||||
for (const email of selectedEmails) {
|
||||
for (const tagId of getEmailTagIds(email.keywords)) tagCounts.set(tagId, (tagCounts.get(tagId) ?? 0) + 1);
|
||||
}
|
||||
const appliedTags: string[] = [];
|
||||
const partialTags: string[] = [];
|
||||
for (const [tagId, count] of tagCounts) (count === selectedEmails.length ? appliedTags : partialTags).push(tagId);
|
||||
|
||||
const handleMove = (mailboxId: string) => {
|
||||
setMoveOpen(false);
|
||||
void run(async () => {
|
||||
if (client) await batchMoveToMailbox(client, mailboxId);
|
||||
});
|
||||
};
|
||||
|
||||
// A tag the whole selection carries comes off; any other goes onto all of
|
||||
// it, as in the default toolbar.
|
||||
const handleToggleTag = (tagId: string) => {
|
||||
const add = !appliedTags.includes(tagId);
|
||||
void run(async () => {
|
||||
if (client) await batchSetTag(client, tagId, add);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSpam = () =>
|
||||
run(async () => {
|
||||
if (!client) return;
|
||||
const ids = Array.from(selectedEmailIds);
|
||||
if (isInJunk) await batchUndoSpam(client, ids);
|
||||
else await batchMarkAsSpam(client, ids);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 h-12 px-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={allSelected ? true : hasSelection ? "mixed" : false}
|
||||
onClick={handleSelectAllToggle}
|
||||
disabled={disabled}
|
||||
title={
|
||||
hasSelection
|
||||
? allSelected
|
||||
? tBatch("clear_selection")
|
||||
: tBatch("select_all")
|
||||
: tBatch("select")
|
||||
}
|
||||
className={cn(
|
||||
"grid place-items-center w-10 h-10 rounded-full transition-colors shrink-0",
|
||||
"text-muted-foreground hover:bg-foreground/10 hover:text-foreground",
|
||||
hasSelection && "text-primary",
|
||||
disabled && "opacity-50 cursor-not-allowed hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
{hasSelection ? (
|
||||
<CheckSquare className="w-[18px] h-[18px]" />
|
||||
) : (
|
||||
<Square className="w-[18px] h-[18px]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<ToolbarButton
|
||||
icon={RotateCcw}
|
||||
label={t("loading")}
|
||||
onClick={onRefresh}
|
||||
disabled={!client || isRefreshing}
|
||||
busy={isRefreshing}
|
||||
/>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
title={tFolder("mark_folder_read")}
|
||||
aria-label={tFolder("mark_folder_read")}
|
||||
className={cn(
|
||||
"grid place-items-center w-10 h-10 rounded-full transition-colors",
|
||||
menuOpen
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MoreVertical className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
role="menu"
|
||||
className="absolute start-0 top-full mt-1 z-50 min-w-56 rounded-lg border border-border bg-popover py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!onMarkFolderRead}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onMarkFolderRead?.();
|
||||
}}
|
||||
className="w-full px-3 py-2 text-start text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{tFolder("mark_folder_read")}
|
||||
</button>
|
||||
{onMarkAllFoldersRead && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onMarkAllFoldersRead();
|
||||
}}
|
||||
className="w-full px-3 py-2 text-start text-sm hover:bg-muted"
|
||||
>
|
||||
{tFolder("mark_all_folders_read")}
|
||||
</button>
|
||||
)}
|
||||
{/* Offered only where emptying a folder is the usual thing to
|
||||
do: spam and the bin. */}
|
||||
{onEmptyFolder && (isInJunk || isInTrash) && (
|
||||
<>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onEmptyFolder();
|
||||
}}
|
||||
className="w-full px-3 py-2 text-start text-sm text-destructive hover:bg-muted"
|
||||
>
|
||||
{tFolder("empty_folder")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSelection && !disabled && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-border mx-1 shrink-0" />
|
||||
<ToolbarButton
|
||||
icon={Archive}
|
||||
label={tActions("archive")}
|
||||
onClick={() => run(async () => { if (client) await batchArchive(client); })}
|
||||
disabled={isProcessing}
|
||||
busy={isProcessing}
|
||||
/>
|
||||
{spamApplicable && (
|
||||
<ToolbarButton
|
||||
icon={isInJunk ? ShieldCheck : ShieldAlert}
|
||||
label={isInJunk ? tActions("not_spam") : tActions("spam")}
|
||||
onClick={handleSpam}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon={Trash2}
|
||||
label={tBatch("delete")}
|
||||
onClick={handleDelete}
|
||||
disabled={isProcessing}
|
||||
className="hover:text-red-600 dark:hover:text-red-400"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 shrink-0" />
|
||||
{moveTree.length > 0 && (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
ref={moveButtonRef}
|
||||
type="button"
|
||||
onClick={() => { setTagOpen(false); setMoveOpen((v) => !v); }}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={moveOpen}
|
||||
title={tMenu("move_to")}
|
||||
aria-label={tMenu("move_to")}
|
||||
className={cn(
|
||||
"grid place-items-center w-10 h-10 rounded-full transition-colors",
|
||||
moveOpen
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<FolderInput className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
{moveOpen && (
|
||||
<div
|
||||
ref={moveRef}
|
||||
onKeyDown={onMoveKeyDown}
|
||||
role="menu"
|
||||
aria-label={tMenu("move_to")}
|
||||
className="absolute start-0 top-full mt-1 z-50 min-w-64 max-h-80 overflow-y-auto rounded-lg border border-border bg-popover py-1 shadow-xl"
|
||||
>
|
||||
{renderMoveNodes(moveTree)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
ref={tagButtonRef}
|
||||
type="button"
|
||||
onClick={() => { setMoveOpen(false); setTagOpen((v) => !v); }}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={tagOpen}
|
||||
title={tMenu("tag")}
|
||||
aria-label={tMenu("tag")}
|
||||
className={cn(
|
||||
"grid place-items-center w-10 h-10 rounded-full transition-colors",
|
||||
tagOpen
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Tag className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
{tagOpen && (
|
||||
<div
|
||||
ref={tagRef}
|
||||
onKeyDown={onTagKeyDown}
|
||||
role="menu"
|
||||
aria-label={tMenu("tag")}
|
||||
className="absolute start-0 top-full mt-1 z-50 min-w-64 rounded-lg border border-border bg-popover py-1 shadow-xl"
|
||||
>
|
||||
<TagPicker selectedIds={appliedTags} partialIds={partialTags} onToggle={handleToggleTag} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-px h-6 bg-border mx-1 shrink-0" />
|
||||
<ToolbarButton
|
||||
icon={MailOpen}
|
||||
label={tBatch("mark_read")}
|
||||
onClick={() => run(async () => { if (client) await batchMarkAsRead(client, true); })}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Mail}
|
||||
label={tBatch("mark_unread")}
|
||||
onClick={() => run(async () => { if (client) await batchMarkAsRead(client, false); })}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<span className="ms-2 text-sm text-muted-foreground truncate">
|
||||
{tBatch("selected_messages", { count: selectionCount })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
{loadedCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums pe-2 shrink-0">
|
||||
{/* The range readout. The list grows as it scrolls rather than
|
||||
paging, so there are no previous/next arrows to go with it. */}
|
||||
{t("conversations_range", { from: 1, to: loadedCount, total: displayedTotal })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "@/components/icons";
|
||||
import type { AppIcon } from "@/components/icons";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
|
||||
import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -66,6 +67,101 @@ function RowChips({ email, loadAttachments, onOpenAttachment }: {
|
||||
return leading ? <div className="mt-1.5 flex">{leading}</div> : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mountain View's leading controls: a checkbox and a star, both always
|
||||
* visible and both acting on the row without opening it. They take the slot
|
||||
* the default layout gives to the sender avatar.
|
||||
*/
|
||||
function MvRowLead({
|
||||
checked,
|
||||
onToggleChecked,
|
||||
starred,
|
||||
onToggleStar,
|
||||
selectLabel,
|
||||
starLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onToggleChecked: (e: React.MouseEvent) => void;
|
||||
starred: boolean;
|
||||
onToggleStar?: () => void;
|
||||
selectLabel: string;
|
||||
starLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0 self-center">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-label={selectLabel}
|
||||
onClick={onToggleChecked}
|
||||
className={cn(
|
||||
"p-1 rounded-full transition-colors hover:bg-foreground/10",
|
||||
checked && "text-primary"
|
||||
)}
|
||||
>
|
||||
{checked ? (
|
||||
<CheckSquare className="w-[18px] h-[18px]" />
|
||||
) : (
|
||||
<Square className="w-[18px] h-[18px] text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{onToggleStar && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={starLabel}
|
||||
aria-pressed={starred}
|
||||
title={starLabel}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleStar();
|
||||
}}
|
||||
className="p-1 rounded-full transition-colors hover:bg-foreground/10"
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"w-[18px] h-[18px]",
|
||||
starred ? "fill-amber-400 text-amber-400" : "text-muted-foreground/60"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MvMobileStar({
|
||||
starred,
|
||||
onToggle,
|
||||
label,
|
||||
}: {
|
||||
starred: boolean;
|
||||
onToggle?: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={starred}
|
||||
title={label}
|
||||
disabled={!onToggle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle?.();
|
||||
}}
|
||||
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 disabled:pointer-events-none"
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-[18px] w-[18px]",
|
||||
starred ? "fill-amber-400 text-amber-400" : "text-muted-foreground/70"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ icon: Icon, label, className }: { icon: AppIcon; label: string; className: string }) {
|
||||
return (
|
||||
<>
|
||||
@@ -178,6 +274,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const tHover = useTranslations('settings.email_behavior.hover_actions');
|
||||
const tStatus = useTranslations('email_list');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
@@ -198,9 +295,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const mailLayout = useEffectiveMailLayout();
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
// Show the originating folder in the aggregate "All …" views.
|
||||
const showSourceFolder = isUnifiedView && !!email.sourceFolder;
|
||||
@@ -212,6 +310,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
// Mountain View leads its rows with a checkbox and a clickable star
|
||||
// instead of an avatar, and marks unread with weight alone. Only the
|
||||
// one-line layout is shaped that way, so the two conditions travel
|
||||
// together.
|
||||
const mvRow = interfaceLayout === 'mountain-view' && isFocusedMailLayout;
|
||||
const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile;
|
||||
const trimmedPreview = cleanPreview(email.preview);
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
// Search hits carry server snippets with the matched terms marked; they
|
||||
@@ -337,19 +441,23 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
data-subject={email.subject || ''}
|
||||
data-unread={isUnread ? 'true' : 'false'}
|
||||
data-starred={email.keywords?.$flagged ? 'true' : 'false'}
|
||||
data-mv-mobile-row={mvMobileRow ? "" : undefined}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
mvMobileRow && "border-b-transparent",
|
||||
resolvedRowTint ? resolvedRowTint : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
: mvRow && !isUnread
|
||||
? "bg-muted/60"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !resolvedRowTint && "shadow-sm",
|
||||
!resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedRowTint && "bg-accent/30",
|
||||
isUnread && !resolvedRowTint && !mvRow && !mvMobileRow && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20",
|
||||
isChecked && !resolvedRowTint && "bg-accent/40",
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||
@@ -384,7 +492,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }}
|
||||
>
|
||||
{/* Checkbox - only for extra-compact density (no avatar) while in selection mode */}
|
||||
{density === 'extra-compact' && selectedEmailIds.size > 0 && (
|
||||
{!mvRow && density === 'extra-compact' && selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
role="checkbox"
|
||||
@@ -407,7 +515,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</button>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
{mvRow && (
|
||||
<MvRowLead
|
||||
checked={isChecked}
|
||||
onToggleChecked={handleCheckboxClick}
|
||||
starred={!!isStarred}
|
||||
onToggleStar={onToggleStar}
|
||||
selectLabel={tBatch('select')}
|
||||
starLabel={tHover('star')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!mvRow && density !== 'extra-compact' && (
|
||||
<div className="relative flex flex-shrink-0 self-center">
|
||||
<SelectableAvatar
|
||||
name={sender?.name}
|
||||
@@ -423,12 +542,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUnread && density === 'extra-compact' && (
|
||||
{isUnread && !mvRow && density === 'extra-compact' && (
|
||||
<UnreadDot besideAvatar={false} />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFocusedMailLayout ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{isUnifiedView && email.accountId && accountColor && !tintListRowsByAccount && (
|
||||
@@ -469,7 +589,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{isPinned && <StatusIcon icon={Pin} label={tStatus('pinned')} className="w-3.5 h-3.5 text-primary" />}
|
||||
{isStarred && <StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{isStarred && !mvRow && <StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{isAnswered && !isForwarded && <StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isForwarded && !isAnswered && <StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isAnswered && isForwarded && (
|
||||
@@ -498,6 +618,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{mvRow && (
|
||||
<RowChips email={email} loadAttachments={loadAttachments} onOpenAttachment={onOpenAttachment} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
@@ -530,7 +654,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{isPinned && (
|
||||
<StatusIcon icon={Pin} label={tStatus('pinned')} className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{isStarred && (
|
||||
{isStarred && !mvMobileRow && (
|
||||
<StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{isAnswered && !isForwarded && (
|
||||
@@ -570,6 +694,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
)}
|
||||
{mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && (
|
||||
<MvMobileStar
|
||||
starred={!!isStarred}
|
||||
onToggle={onToggleStar}
|
||||
label={tHover('star')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -592,14 +723,24 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || t('no_preview_available'))}
|
||||
</p>
|
||||
<div className={cn("flex min-w-0 items-center gap-1", !mvMobileRow && "contents")}>
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed",
|
||||
mvMobileRow ? "line-clamp-1 min-w-0 flex-1" : "line-clamp-2",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || t('no_preview_available'))}
|
||||
</p>
|
||||
{mvMobileRow && (
|
||||
<MvMobileStar
|
||||
starred={!!isStarred}
|
||||
onToggle={onToggleStar}
|
||||
label={tHover('star')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<RowChips email={email} loadAttachments={loadAttachments} onOpenAttachment={onOpenAttachment} />
|
||||
</>
|
||||
@@ -654,16 +795,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const t = useTranslations('threads');
|
||||
const tEmailViewer = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const tHover = useTranslations('settings.email_behavior.hover_actions');
|
||||
const tStatus = useTranslations('email_list');
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const mailLayout = useEffectiveMailLayout();
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
// Mountain View leads its rows with a checkbox and a clickable star
|
||||
// instead of an avatar, and marks unread with weight alone. Only the
|
||||
// one-line layout is shaped that way, so the two conditions travel
|
||||
// together.
|
||||
const mvRow = interfaceLayout === 'mountain-view' && isFocusedMailLayout;
|
||||
const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile;
|
||||
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
|
||||
@@ -817,23 +966,31 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("border-b border-border", isThreadDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30")}>
|
||||
<div ref={ref} className={cn(
|
||||
"border-b border-border",
|
||||
mvMobileRow && "border-b-transparent",
|
||||
isThreadDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
|
||||
)}>
|
||||
<div
|
||||
{...dragHandlers}
|
||||
{...threadLongPressHandlers}
|
||||
aria-current={isSelected ? 'true' : undefined}
|
||||
data-unread={hasUnread ? 'true' : 'false'}
|
||||
data-mv-mobile-row={mvMobileRow ? "" : undefined}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||
rowTint ? rowTint : (
|
||||
isSelected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
: mvRow && !hasUnread
|
||||
? "bg-muted/60"
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !rowTint && "shadow-sm",
|
||||
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
rowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !rowTint && !isSelected && "bg-accent/30",
|
||||
hasUnread && !rowTint && !isSelected && !mvRow && !mvMobileRow && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50",
|
||||
isChecked && "ring-2 ring-primary/20",
|
||||
isChecked && !rowTint && "bg-accent/40",
|
||||
@@ -855,7 +1012,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox for thread selection - only for extra-compact density (no avatar) while in selection mode */}
|
||||
{density === 'extra-compact' && selectedEmailIds.size > 0 && (
|
||||
{!mvRow && density === 'extra-compact' && selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleThreadCheckboxClick}
|
||||
role="checkbox"
|
||||
@@ -878,7 +1035,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</button>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
{mvRow && (
|
||||
<MvRowLead
|
||||
checked={isChecked}
|
||||
onToggleChecked={handleThreadCheckboxClick}
|
||||
starred={!!hasStarred}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
selectLabel={tBatch('select')}
|
||||
starLabel={tHover('star')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!mvRow && density !== 'extra-compact' && (
|
||||
<div className="relative flex-shrink-0 self-center">
|
||||
<SelectableAvatar
|
||||
name={avatarPerson?.name}
|
||||
@@ -921,12 +1089,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasUnread && density === 'extra-compact' && (
|
||||
{hasUnread && !mvRow && density === 'extra-compact' && (
|
||||
<UnreadDot besideAvatar={false} />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFocusedMailLayout ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{isUnifiedView && latestEmail.accountId && threadAccountColor && !tintListRowsByAccount && (
|
||||
@@ -973,7 +1142,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{hasPinned && <StatusIcon icon={Pin} label={tStatus('pinned')} className="w-3.5 h-3.5 text-primary" />}
|
||||
{hasStarred && <StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{hasStarred && !mvRow && <StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{hasAnswered && !hasForwarded && <StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{hasForwarded && !hasAnswered && <StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{hasAnswered && hasForwarded && (
|
||||
@@ -1002,6 +1171,14 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{mvRow && (
|
||||
<RowChips
|
||||
email={latestEmail}
|
||||
loadAttachments={loadAttachments}
|
||||
onOpenAttachment={onOpenAttachment ? (a) => onOpenAttachment(latestEmail, a) : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
@@ -1037,7 +1214,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{hasPinned && (
|
||||
<StatusIcon icon={Pin} label={tStatus('pinned')} className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{hasStarred && (
|
||||
{hasStarred && !mvMobileRow && (
|
||||
<StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{hasAnswered && !hasForwarded && (
|
||||
@@ -1077,6 +1254,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
)}
|
||||
{mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && (
|
||||
<MvMobileStar
|
||||
starred={!!hasStarred}
|
||||
onToggle={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
label={tHover('star')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1099,14 +1283,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
hasUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || tEmailViewer('no_preview_available'))}
|
||||
</p>
|
||||
<div className={cn("flex min-w-0 items-center gap-1", !mvMobileRow && "contents")}>
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed",
|
||||
mvMobileRow ? "line-clamp-1 min-w-0 flex-1" : "line-clamp-2",
|
||||
hasUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || tEmailViewer('no_preview_available'))}
|
||||
</p>
|
||||
{mvMobileRow && (
|
||||
<MvMobileStar
|
||||
starred={!!hasStarred}
|
||||
onToggle={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
label={tHover('star')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<RowChips
|
||||
email={latestEmail}
|
||||
|
||||
@@ -103,6 +103,7 @@ export {
|
||||
IconFolderX as FolderX,
|
||||
IconArrowForwardUp as Forward,
|
||||
IconWorld as Globe,
|
||||
IconGridDots as Grid3x3,
|
||||
IconGripVertical as GripVertical,
|
||||
IconServer2 as HardDrive,
|
||||
IconH1 as Heading1,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Keyboard } from "@/components/icons";
|
||||
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { KEYBOARD_SHORTCUTS, type ShortcutEntry } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
@@ -15,6 +16,10 @@ interface KeyboardShortcutsModalProps {
|
||||
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
|
||||
const t = useTranslations();
|
||||
const { startTour } = useTour();
|
||||
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
|
||||
// Rows that belong to one interface layout are listed only under it.
|
||||
const shown = (entries: readonly ShortcutEntry[]) =>
|
||||
entries.filter((entry) => !entry.layout || entry.layout === interfaceLayout);
|
||||
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
@@ -66,7 +71,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
||||
{t("shortcuts.sections.navigation")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{KEYBOARD_SHORTCUTS.navigation.map((shortcut) => (
|
||||
{shown(KEYBOARD_SHORTCUTS.navigation).map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
@@ -82,7 +87,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
||||
{t("shortcuts.sections.actions")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{KEYBOARD_SHORTCUTS.actions.map((shortcut) => (
|
||||
{shown(KEYBOARD_SHORTCUTS.actions).map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
@@ -98,7 +103,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
||||
{t("shortcuts.sections.global")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{KEYBOARD_SHORTCUTS.global.map((shortcut) => (
|
||||
{shown(KEYBOARD_SHORTCUTS.global).map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
@@ -114,7 +119,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
||||
{t("shortcuts.sections.threads")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{KEYBOARD_SHORTCUTS.threads.map((shortcut) => (
|
||||
{shown(KEYBOARD_SHORTCUTS.threads).map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
@@ -130,7 +135,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
||||
{t("shortcuts.sections.composer")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{KEYBOARD_SHORTCUTS.composer.map((shortcut) => (
|
||||
{shown(KEYBOARD_SHORTCUTS.composer).map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// Mountain View rearranges the account popover: the account you are in
|
||||
// becomes a card at the top, the others become a list with the unread waiting
|
||||
// in each, and the merged inbox gets a row of its own. Outside Mountain View the
|
||||
// popover is the one the rail and the sidebar have always shown.
|
||||
|
||||
const { AccountSwitcher } = await import('../account-switcher');
|
||||
const { useAccountStore } = await import('@/stores/account-store');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { useEmailStore } = await import('@/stores/email-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
const inbox = (unread: number): Mailbox[] =>
|
||||
[{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 10, unreadEmails: unread }] as unknown as Mailbox[];
|
||||
|
||||
function account(id: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
label: `${id}@example.com`,
|
||||
email: `${id}@example.com`,
|
||||
username: `${id}@example.com`,
|
||||
displayName: id.toUpperCase(),
|
||||
serverUrl: 'https://mail.example.com',
|
||||
avatarColor: '#336699',
|
||||
cookieSlot: 0,
|
||||
isConnected: true,
|
||||
isDefault: id === 'a',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const openPopover = () => fireEvent.click(screen.getByTestId('account-switcher'));
|
||||
|
||||
describe('AccountSwitcher under Mountain View', () => {
|
||||
beforeEach(() => {
|
||||
useAccountStore.setState({ accounts: [account('a'), account('b')] } as never);
|
||||
useAuthStore.setState({ activeAccountId: 'a' } as never);
|
||||
useEmailStore.setState({ mailboxes: inbox(3), accountMailboxes: { b: inbox(5) } } as never);
|
||||
useSettingsStore.setState({ interfaceLayout: 'mountain-view' } as never);
|
||||
});
|
||||
|
||||
it('leads with the active account and does not repeat it in the list', () => {
|
||||
render(<AccountSwitcher variant="header" />);
|
||||
openPopover();
|
||||
|
||||
const options = screen.getAllByTestId('account-option');
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveAttribute('data-account-id', 'b');
|
||||
// The active address is still on screen - as the card, not as a row.
|
||||
expect(screen.getAllByText('a@example.com').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('draws plain rows: no colour stripe, no server line', () => {
|
||||
render(<AccountSwitcher variant="header" />);
|
||||
openPopover();
|
||||
|
||||
const row = screen.getByTestId('account-option');
|
||||
expect(row.getAttribute('style') ?? '').not.toMatch(/border/);
|
||||
// Tailwind's `hidden`: jsdom has no style sheet to compute visibility from.
|
||||
expect(within(row).getByText('mail.example.com').closest('.hidden')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the server line for an account in trouble', () => {
|
||||
useAccountStore.setState({ accounts: [account('a'), account('b', { hasError: true })] } as never);
|
||||
render(<AccountSwitcher variant="header" />);
|
||||
openPopover();
|
||||
|
||||
expect(within(screen.getByTestId('account-option')).getByText('mail.example.com').closest('.hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers add-account and sign-out-of-all as a pair of pills', () => {
|
||||
render(<AccountSwitcher variant="header" />);
|
||||
openPopover();
|
||||
|
||||
const actions = document.querySelector('[data-mv-account-actions]');
|
||||
expect(actions).not.toBeNull();
|
||||
expect(actions!.querySelectorAll('button')).toHaveLength(2);
|
||||
expect(screen.getAllByTestId('add-account')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('puts the unread waiting in the other account next to it', () => {
|
||||
render(<AccountSwitcher variant="header" />);
|
||||
openPopover();
|
||||
|
||||
expect(within(screen.getByTestId('account-option')).getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the merged inbox, totalling every connected account, when one is reachable', () => {
|
||||
const onSelectAllInboxes = vi.fn();
|
||||
render(<AccountSwitcher variant="header" onSelectAllInboxes={onSelectAllInboxes} />);
|
||||
openPopover();
|
||||
|
||||
const row = screen.getByTestId('all-inboxes');
|
||||
expect(within(row).getByText('8')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onSelectAllInboxes).toHaveBeenCalledOnce();
|
||||
// Choosing it closes the popover, as switching account does.
|
||||
expect(screen.queryByTestId('all-inboxes')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws no merged-inbox row when the caller has nowhere to send it', () => {
|
||||
render(<AccountSwitcher variant="header" />);
|
||||
openPopover();
|
||||
|
||||
expect(screen.queryByTestId('all-inboxes')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the default layout listing every account, unread-free', () => {
|
||||
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
|
||||
render(<AccountSwitcher variant="header" onSelectAllInboxes={vi.fn()} />);
|
||||
openPopover();
|
||||
|
||||
expect(screen.getAllByTestId('account-option')).toHaveLength(2);
|
||||
expect(screen.queryByTestId('all-inboxes')).toBeNull();
|
||||
expect(screen.queryByText('5')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// Under Mountain View the sidebar is one flat list: inbox, sent and drafts,
|
||||
// then a single "More" that reveals the folders you made, the shared ones and
|
||||
// the labels. Outside Mountain View the "Folders" / "Tags" sections stay as they
|
||||
// are, headings and all.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/contexts/drag-drop-context', () => ({
|
||||
useDragDropContext: () => ({ isDragging: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-mailbox-drop', () => ({
|
||||
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-tag-drop', () => ({
|
||||
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
|
||||
|
||||
const { Sidebar } = await import('../sidebar');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const { useUIStore } = await import('@/stores/ui-store');
|
||||
|
||||
const mailboxes: Mailbox[] = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, sortOrder: 0, myRights: { mayAddItems: true } },
|
||||
{ id: 'sent', name: 'Sent', role: 'sent', parentId: null, totalEmails: 0, unreadEmails: 0, sortOrder: 1, myRights: { mayAddItems: true } },
|
||||
{ id: 'drafts', name: 'Drafts', role: 'drafts', parentId: null, totalEmails: 0, unreadEmails: 0, sortOrder: 2, myRights: { mayAddItems: true } },
|
||||
{ id: 'trash', name: 'Trash', role: 'trash', parentId: null, totalEmails: 0, unreadEmails: 0, sortOrder: 3, myRights: { mayAddItems: true } },
|
||||
{ id: 'receipts', name: 'Receipts', parentId: null, totalEmails: 0, unreadEmails: 0, sortOrder: 4, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
/** Rows carry their untranslated folder name; the visible label is localised. */
|
||||
const folderRow = (name: string) => document.querySelector(`[data-folder-name="${name}"]`);
|
||||
|
||||
describe('Sidebar under Mountain View', () => {
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ sidebarCollapsed: false } as never);
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
|
||||
enableUnifiedMailbox: false,
|
||||
hideAccountSwitcher: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('drops the Folders heading and holds the rest behind More', () => {
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" mvShell />);
|
||||
|
||||
expect(screen.queryByText('folders')).toBeNull();
|
||||
expect(screen.getByText('show_more')).toBeInTheDocument();
|
||||
expect(folderRow('Receipts')).toBeNull();
|
||||
expect(folderRow('Trash')).toBeNull();
|
||||
// The labels heading goes with them: it shows only once expanded.
|
||||
expect(screen.queryByText('tags')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the places mail actually goes in view', () => {
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" mvShell />);
|
||||
|
||||
expect(folderRow('Inbox')).not.toBeNull();
|
||||
expect(folderRow('Sent')).not.toBeNull();
|
||||
expect(folderRow('Drafts')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('lists Starred right under the Inbox, and opens it', () => {
|
||||
const onOpenStarred = vi.fn();
|
||||
const { container } = render(
|
||||
<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" mvShell onOpenStarred={onOpenStarred} />,
|
||||
);
|
||||
|
||||
const rows = [...container.querySelectorAll('[data-folder-name]')].map((r) => r.getAttribute('data-folder-name'));
|
||||
expect(rows.indexOf('mv-starred')).toBe(rows.indexOf('Inbox') + 1);
|
||||
fireEvent.click(container.querySelector('[data-folder-name="mv-starred"] button:last-of-type')!);
|
||||
expect(onOpenStarred).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has no Starred row outside Mountain View', () => {
|
||||
const { container } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onOpenStarred={vi.fn()} />);
|
||||
expect(container.querySelector('[data-folder-name="mv-starred"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals the folders and the labels together, and takes them back', () => {
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" mvShell />);
|
||||
|
||||
fireEvent.click(screen.getByText('show_more'));
|
||||
expect(folderRow('Receipts')).not.toBeNull();
|
||||
expect(folderRow('Trash')).not.toBeNull();
|
||||
expect(screen.getByText('tags')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('show_fewer'));
|
||||
expect(folderRow('Receipts')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the default layout its sections and every folder', () => {
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
|
||||
expect(screen.getByText('folders')).toBeInTheDocument();
|
||||
expect(screen.queryByText('show_more')).toBeNull();
|
||||
expect(folderRow('Receipts')).not.toBeNull();
|
||||
expect(screen.getByText('tags')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sidebar collapsed rail under Mountain View', () => {
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ sidebarCollapsed: true } as never);
|
||||
useSettingsStore.setState({ emailKeywords: [], hideAccountSwitcher: true } as never);
|
||||
});
|
||||
|
||||
it('peeks the full list out on hover and puts it away again', () => {
|
||||
const { container } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" mvShell />);
|
||||
const rail = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(rail).not.toHaveAttribute('data-mv-rail-peek');
|
||||
|
||||
fireEvent.mouseEnter(rail);
|
||||
expect(rail).toHaveAttribute('data-mv-rail-peek');
|
||||
// Peeking means the list is legible, i.e. no longer icon-only.
|
||||
expect(screen.getByText('show_more')).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseLeave(rail);
|
||||
expect(rail).not.toHaveAttribute('data-mv-rail-peek');
|
||||
});
|
||||
|
||||
it('does not peek outside Mountain View', () => {
|
||||
const { container } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
const rail = container.firstElementChild as HTMLElement;
|
||||
|
||||
fireEvent.mouseEnter(rail);
|
||||
expect(rail).not.toHaveAttribute('data-mv-rail-peek');
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo, useId } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical, X } from "@/components/icons";
|
||||
import { Check, Plus, LogOut, Mails, Star, ChevronDown, AlertCircle, GripVertical, X } from "@/components/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
|
||||
import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread";
|
||||
import { isDocumentRTL } from "@/i18n/direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
@@ -18,22 +22,50 @@ interface AccountSwitcherProps {
|
||||
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
|
||||
variant?: "rail" | "expanded" | "header";
|
||||
className?: string;
|
||||
/**
|
||||
* Mountain View only: opens the one list that spans every account. It sits
|
||||
* at the head of the account popover, above the addresses it merges,
|
||||
* because it is the alternative to picking one of them. Omitted when the
|
||||
* unified view is off or out of reach, and then no row is drawn.
|
||||
*/
|
||||
onSelectAllInboxes?: () => void;
|
||||
/** Whether that merged list is the one currently on screen. */
|
||||
allInboxesSelected?: boolean;
|
||||
}
|
||||
|
||||
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
|
||||
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" | "xl" }) {
|
||||
return (
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="sm"
|
||||
className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
|
||||
className={cn(
|
||||
"flex-shrink-0",
|
||||
size === "md" && "w-9 h-9 text-sm",
|
||||
size === "xl" && "!w-20 !h-20 !text-2xl"
|
||||
)}
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSwitcher({ variant = "rail", className }: AccountSwitcherProps) {
|
||||
/** The popover's unread badge: a count, capped, or nothing at all when there is none. */
|
||||
function UnreadBadge({ count }: { count: number | undefined }) {
|
||||
if (!count) return null;
|
||||
return (
|
||||
<span className="ms-auto shrink-0 text-xs font-medium tabular-nums text-primary">
|
||||
{count > 999 ? "999+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSwitcher({
|
||||
variant = "rail",
|
||||
className,
|
||||
onSelectAllInboxes,
|
||||
allInboxesSelected = false,
|
||||
}: AccountSwitcherProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -50,6 +82,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
triggerRef: buttonRef,
|
||||
});
|
||||
|
||||
// The header avatar is Mountain View's account control; the same component
|
||||
// in the rail and the sidebar keeps the list it has always had, so the
|
||||
// default layout is untouched.
|
||||
const mvPopover = useSettingsStore((s) => s.interfaceLayout === "mountain-view") && variant === "header";
|
||||
const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
|
||||
const ownMailboxes = useEmailStore((s) => s.mailboxes);
|
||||
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
||||
const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
|
||||
@@ -203,6 +242,23 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
const displayName = toUnicodeEmail(activeAccount?.displayName || activeAccount?.label || "");
|
||||
const displayEmail = toUnicodeEmail(activeAccount?.email || activeAccount?.username || "");
|
||||
|
||||
// Mountain View leads its popover with the account you are already in - large avatar,
|
||||
// name, address - and lists only the others underneath, since choosing one is
|
||||
// the point of opening it. Every row carries the unread waiting there, so the
|
||||
// choice can be made without visiting each account in turn.
|
||||
const listedAccounts = mvPopover
|
||||
? displayAccounts.filter((account) => account.id !== activeAccountId)
|
||||
: displayAccounts;
|
||||
const unreadByAccount: Record<string, Mailbox[] | undefined> = activeAccountId
|
||||
? { ...accountMailboxes, [activeAccountId]: ownMailboxes }
|
||||
: accountMailboxes;
|
||||
const unreadFor = (account: AccountEntry) =>
|
||||
mvPopover ? inboxUnread(unreadByAccount[account.id]) : undefined;
|
||||
const allInboxesUnread = totalInboxUnread(
|
||||
accounts.filter((account) => account.isConnected).map((account) => account.id),
|
||||
unreadByAccount
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -250,15 +306,58 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
<div
|
||||
ref={popoverRef}
|
||||
style={popoverStyle}
|
||||
className="w-72 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
|
||||
className={cn(
|
||||
"border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden",
|
||||
// Width and radius come from the layout's own tokens, keyed on the
|
||||
// attribute below; only the elevation differs in markup.
|
||||
mvPopover ? "shadow-xl" : "w-72 rounded-lg"
|
||||
)}
|
||||
data-mv-account-popover={mvPopover ? "" : undefined}
|
||||
role="menu"
|
||||
id={menuId}
|
||||
aria-label={t("switch_account")}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
{/* Account List */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{displayAccounts.map((account) => {
|
||||
{mvPopover && activeAccount && (
|
||||
<div className="flex flex-col items-center gap-1 px-4 pt-6 pb-4 text-center">
|
||||
<AccountAvatar account={activeAccount} size="xl" />
|
||||
<p className="mt-2 text-base font-medium truncate max-w-full">{displayName}</p>
|
||||
<p className="text-sm text-muted-foreground truncate max-w-full">{displayEmail}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mvPopover && onSelectAllInboxes && (
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="all-inboxes"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onSelectAllInboxes();
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-3 text-start text-sm transition-colors",
|
||||
allInboxesSelected ? "bg-accent/50 font-medium" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Mails className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{t("unified_inbox")}</span>
|
||||
<UnreadBadge count={allInboxesUnread} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account List. Under Mountain View the active account is already the
|
||||
card above, so with a single login there is no list to draw. */}
|
||||
<div
|
||||
className={cn(
|
||||
"py-1 max-h-64 overflow-y-auto",
|
||||
mvPopover && "border-t border-border",
|
||||
mvPopover && listedAccounts.length === 0 && "hidden"
|
||||
)}
|
||||
>
|
||||
{listedAccounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
const isDraggable = !account.isDefault && accounts.length > 2;
|
||||
return (
|
||||
@@ -304,11 +403,14 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
{account.isDefault && (
|
||||
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||
)}
|
||||
<UnreadBadge count={unreadFor(account)} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{toUnicodeEmail(account.email || account.username)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{/* Server and connection state: Mountain View keeps the line
|
||||
only for an account in trouble. */}
|
||||
<div className={cn("flex items-center gap-1 mt-0.5", mvPopover && !account.hasError && "hidden")}>
|
||||
{account.hasError ? (
|
||||
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||
) : (
|
||||
@@ -348,8 +450,36 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mountain View: "Add another account" and "Sign out of all accounts" as two
|
||||
pills side by side. */}
|
||||
{mvPopover && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 border-t border-border px-4 py-3" data-mv-account-actions="">
|
||||
{accounts.length < getMaxAccounts() && (
|
||||
<button
|
||||
onClick={handleAddAccount}
|
||||
data-testid="add-account"
|
||||
className="flex items-center gap-2 rounded-full border border-border px-4 py-2 text-sm hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("add_account")}
|
||||
</button>
|
||||
)}
|
||||
{accounts.length > 1 && (
|
||||
<button
|
||||
onClick={handleLogoutAll}
|
||||
className="flex items-center gap-2 rounded-full border border-border px-4 py-2 text-sm hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("sign_out_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator + Add Account */}
|
||||
{accounts.length < getMaxAccounts() && (
|
||||
{!mvPopover && accounts.length < getMaxAccounts() && (
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={handleAddAccount}
|
||||
@@ -383,7 +513,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("sign_out_of", { account: displayEmail })}
|
||||
</button>
|
||||
{accounts.length > 1 && (
|
||||
{!mvPopover && accounts.length > 1 && (
|
||||
<button
|
||||
onClick={handleLogoutAll}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-muted transition-colors"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Menu, ArrowLeft, Plus, Search, X } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -29,6 +30,9 @@ interface MobileHeaderProps {
|
||||
onClearSearch?: () => void;
|
||||
/** Whether a query is currently applied, which reveals the clear button. */
|
||||
searchActive?: boolean;
|
||||
/** Mountain View: opens the merged across-accounts inbox from the avatar's popover. */
|
||||
onSelectAllInboxes?: () => void;
|
||||
allInboxesSelected?: boolean;
|
||||
}
|
||||
|
||||
export function MobileHeader({
|
||||
@@ -43,9 +47,12 @@ export function MobileHeader({
|
||||
searchPlaceholder,
|
||||
onClearSearch,
|
||||
searchActive = false,
|
||||
onSelectAllInboxes,
|
||||
allInboxesSelected = false,
|
||||
}: MobileHeaderProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
|
||||
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
|
||||
// Pane-aware: in Pro split mode the viewport is desktop-wide while the
|
||||
// pane is narrow. The Tailwind `lg:hidden` variant alone would never fire
|
||||
// there, so we additionally hide via JS when the surrounding pane is
|
||||
@@ -65,8 +72,12 @@ export function MobileHeader({
|
||||
|
||||
return (
|
||||
<header
|
||||
data-mobile-mail-header=""
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 h-14 border-b border-border bg-background shrink-0",
|
||||
"flex items-center justify-between h-14 shrink-0",
|
||||
mountainView
|
||||
? "mx-2 mt-2 mb-1 rounded-full border-0 bg-muted px-2 shadow-sm"
|
||||
: "border-b border-border bg-background px-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -101,7 +112,13 @@ export function MobileHeader({
|
||||
{onOpenSearch && (
|
||||
/* Not a single button: the clear control is a button of its own, and
|
||||
nesting one inside another is invalid markup. */
|
||||
<div className="flex-1 min-w-0 h-10 mx-1 flex items-center rounded-full bg-muted ps-3 pe-1">
|
||||
<div
|
||||
data-mobile-mail-search=""
|
||||
className={cn(
|
||||
"flex-1 min-w-0 h-10 flex items-center rounded-full pe-1",
|
||||
mountainView ? "mx-0 bg-transparent ps-2" : "mx-1 bg-muted ps-3"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
@@ -153,7 +170,11 @@ export function MobileHeader({
|
||||
opening the drawer. Last in this group so it sits on the far
|
||||
edge — opposite the menu button, and mirrored under RTL because
|
||||
the header is laid out with flex rather than fixed sides. */}
|
||||
<AccountSwitcher variant="header" />
|
||||
<AccountSwitcher
|
||||
variant="header"
|
||||
onSelectAllInboxes={onSelectAllInboxes}
|
||||
allInboxesSelected={allInboxesSelected}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
BookUser,
|
||||
Calendar,
|
||||
Filter,
|
||||
Grid3x3,
|
||||
HardDrive,
|
||||
Keyboard,
|
||||
Mail,
|
||||
Menu,
|
||||
Settings,
|
||||
iconForName,
|
||||
} from "@/components/icons";
|
||||
import type { AppIcon as AppIconType } from "@/components/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link, usePathname } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AccountSwitcher } from "@/components/layout/account-switcher";
|
||||
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
|
||||
import type { ContactSuggestion } from "@/lib/search-suggestions";
|
||||
|
||||
interface MvTopBarProps {
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
onSearchSubmit: (query: string) => void;
|
||||
onSearchClear: () => void;
|
||||
onSelectContact: (contact: ContactSuggestion, field: ContactSearchField) => void;
|
||||
/** Opens the advanced-filter panel that lives under the message list. */
|
||||
onToggleFilters: () => void;
|
||||
filtersOpen: boolean;
|
||||
activeFilterCount: number;
|
||||
searchDisabled?: boolean;
|
||||
onShowShortcuts?: () => void;
|
||||
onManageApps?: () => void;
|
||||
onInlineApp?: (appId: string, url: string, name: string) => void;
|
||||
onCloseInlineApp?: () => void;
|
||||
activeAppId?: string | null;
|
||||
/** Opens the merged across-accounts inbox from the account popover. */
|
||||
onSelectAllInboxes?: () => void;
|
||||
allInboxesSelected?: boolean;
|
||||
/**
|
||||
* Receives the node under the search field that the advanced-filter panel is
|
||||
* portalled into, so the panel drops from the field itself; without this
|
||||
* the panel would stay behind in the message-list column, two bars away from
|
||||
* the button that opens it.
|
||||
*/
|
||||
onFilterAnchorChange?: (node: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mountain View's global header: one 64px row that owns the hamburger, the
|
||||
* brand, the search field and the account cluster. It only renders on
|
||||
* desktop under `interfaceLayout === 'mountain-view'`; the default layout keeps
|
||||
* search inside the list column and navigation in the left rail, and neither
|
||||
* of those is touched here.
|
||||
*/
|
||||
export function MvTopBar({
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onSearchSubmit,
|
||||
onSearchClear,
|
||||
onSelectContact,
|
||||
onToggleFilters,
|
||||
filtersOpen,
|
||||
activeFilterCount,
|
||||
searchDisabled = false,
|
||||
onShowShortcuts,
|
||||
onManageApps,
|
||||
onInlineApp,
|
||||
onCloseInlineApp,
|
||||
activeAppId,
|
||||
onSelectAllInboxes,
|
||||
allInboxesSelected = false,
|
||||
onFilterAnchorChange,
|
||||
}: MvTopBarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const tSearch = useTranslations("advanced_search");
|
||||
const pathname = usePathname();
|
||||
const { appName, appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed);
|
||||
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled"));
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled("filesEnabled"));
|
||||
const sidebarApps = useResolvedSidebarApps();
|
||||
|
||||
const [appsOpen, setAppsOpen] = useState(false);
|
||||
const appsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeApps = useCallback(() => setAppsOpen(false), []);
|
||||
const { menuRef: appsMenuRef, onKeyDown: onAppsKeyDown } = useMenuNavigation<HTMLDivElement>({
|
||||
open: appsOpen,
|
||||
onClose: closeApps,
|
||||
triggerRef: appsButtonRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!appsOpen) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (
|
||||
appsButtonRef.current?.contains(e.target as Node) ||
|
||||
appsMenuRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setAppsOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [appsOpen, appsMenuRef]);
|
||||
|
||||
const apps: Array<{ id: string; icon: AppIconType; label: string; href: string }> = [
|
||||
{ id: "mail", icon: Mail, label: t("mail"), href: "/" },
|
||||
...(supportsCalendar && calendarEnabled
|
||||
? [{ id: "calendar", icon: Calendar, label: t("calendar"), href: "/calendar" }]
|
||||
: []),
|
||||
...((client?.supportsContacts() ?? false) && contactsEnabled
|
||||
? [{ id: "contacts", icon: BookUser, label: t("contacts"), href: "/contacts" }]
|
||||
: []),
|
||||
...((client?.supportsFiles() ?? false) && filesEnabled
|
||||
? [{ id: "files", icon: HardDrive, label: t("files"), href: "/files" }]
|
||||
: []),
|
||||
{ id: "settings", icon: Settings, label: t("settings"), href: "/settings" },
|
||||
];
|
||||
|
||||
const isActive = (href: string) =>
|
||||
!activeAppId && (href === "/" ? pathname === "/" || pathname === "" : pathname.startsWith(href));
|
||||
|
||||
const logoUrl = withBasePath(
|
||||
resolvedTheme === "dark" ? appLogoDarkUrl || appLogoLightUrl : appLogoLightUrl || appLogoDarkUrl
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="mv-topbar flex items-center h-16 gap-2 ps-1 pe-4 shrink-0 bg-background"
|
||||
data-mv-topbar=""
|
||||
>
|
||||
{/* Brand block: occupies the sidebar's own column width */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className="grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
aria-label={t("mobile.toggle_menu")}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 pe-4 min-w-0" aria-label={appName}>
|
||||
{logoUrl ? <img src={logoUrl} alt="" className="w-8 h-8 object-contain" /> : null}
|
||||
<span className="text-[22px] leading-none text-foreground/80 truncate">{appName}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search: left-anchored and capped, not centred */}
|
||||
<div className="mv-topbar-search relative flex items-center gap-1 flex-1 min-w-0 max-w-[720px]">
|
||||
<SearchBox
|
||||
value={searchQuery}
|
||||
onChange={onSearchQueryChange}
|
||||
onSubmit={onSearchSubmit}
|
||||
onClear={onSearchClear}
|
||||
onSelectContact={onSelectContact}
|
||||
disabled={searchDisabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFilters}
|
||||
disabled={searchDisabled}
|
||||
// Inside the field's trailing end, next to the typed query.
|
||||
className={cn(
|
||||
"absolute end-1 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-10 h-10 rounded-full transition-colors",
|
||||
searchDisabled && "opacity-50 cursor-not-allowed",
|
||||
filtersOpen || activeFilterCount > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={tSearch("toggle_filters")}
|
||||
aria-label={tSearch("toggle_filters")}
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
{!filtersOpen && activeFilterCount > 0 && (
|
||||
<span className="absolute top-1.5 end-1.5 grid place-items-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Anchor for the advanced-filter panel: the search field's own box,
|
||||
filter button included, so the panel shares the field's edges. It
|
||||
only marks the spot; the panel itself is drawn at the end of <body>. */}
|
||||
<div
|
||||
ref={onFilterAnchorChange}
|
||||
aria-hidden
|
||||
data-mv-filter-anchor=""
|
||||
className="pointer-events-none absolute start-0 end-0 top-full h-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Spacer keeps the right cluster pinned while search stays left */}
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onShowShortcuts && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowShortcuts}
|
||||
className="grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={t("keyboard_shortcuts")}
|
||||
aria-label={t("keyboard_shortcuts")}
|
||||
>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href="/settings"
|
||||
className="grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={t("settings")}
|
||||
aria-label={t("settings")}
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={appsButtonRef}
|
||||
type="button"
|
||||
onClick={() => setAppsOpen((v) => !v)}
|
||||
className={cn(
|
||||
"grid place-items-center w-12 h-12 rounded-full transition-colors",
|
||||
appsOpen
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={t("add_app")}
|
||||
aria-label={t("add_app")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={appsOpen}
|
||||
>
|
||||
<Grid3x3 className="w-5 h-5" />
|
||||
</button>
|
||||
{appsOpen && (
|
||||
<div
|
||||
ref={appsMenuRef}
|
||||
onKeyDown={onAppsKeyDown}
|
||||
role="menu"
|
||||
aria-label={t("nav_label")}
|
||||
className="absolute end-0 top-full mt-1 z-50 w-72 max-h-[70vh] overflow-y-auto rounded-2xl border border-border bg-popover p-3 shadow-xl"
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{apps.map((app) => {
|
||||
const Icon = app.icon;
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
href={app.href}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
if (activeAppId) onCloseInlineApp?.();
|
||||
setAppsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-lg py-3 px-1 text-center transition-colors hover:bg-muted",
|
||||
isActive(app.href) && "bg-muted"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-6 h-6 text-muted-foreground" />
|
||||
<span className="text-xs truncate max-w-full">{app.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = iconForName(app.icon);
|
||||
return (
|
||||
<button
|
||||
key={app.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setAppsOpen(false);
|
||||
if (activeAppId === app.id) {
|
||||
onCloseInlineApp?.();
|
||||
} else if (app.openMode === "tab") {
|
||||
window.open(app.url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
onInlineApp?.(app.id, app.url, app.name);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-lg py-3 px-1 text-center transition-colors hover:bg-muted",
|
||||
activeAppId === app.id && "bg-muted"
|
||||
)}
|
||||
>
|
||||
{AppIcon ? <AppIcon className="w-6 h-6 text-muted-foreground" /> : null}
|
||||
<span className="text-xs truncate max-w-full">{app.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{onManageApps && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setAppsOpen(false);
|
||||
onManageApps();
|
||||
}}
|
||||
className="mt-2 w-full rounded-lg py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("add_app")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccountSwitcher
|
||||
variant="header"
|
||||
className="ms-1"
|
||||
onSelectAllInboxes={onSelectAllInboxes}
|
||||
allInboxesSelected={allInboxesSelected}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -213,6 +213,7 @@ export function NavigationRail({
|
||||
const supportsFiles = client?.supportsFiles() ?? false;
|
||||
const supportsContacts = client?.supportsContacts() ?? false;
|
||||
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||
const mountainView = useSettingsStore((s) => s.interfaceLayout === 'mountain-view');
|
||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||
@@ -338,7 +339,8 @@ export function NavigationRail({
|
||||
return activeItemId === itemId;
|
||||
}
|
||||
if (href === "/") {
|
||||
return pathname === "/" || pathname === "";
|
||||
// Mountain View's bottom bar marks the mail tab on mail routes too.
|
||||
return pathname === "/" || pathname === "" || (mountainView && pathname.startsWith("/mail"));
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
@@ -360,7 +362,12 @@ export function NavigationRail({
|
||||
if (orientation === "horizontal") {
|
||||
return (
|
||||
<nav
|
||||
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]", className)}
|
||||
data-mobile-navigation=""
|
||||
className={cn(
|
||||
"flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
|
||||
mountainView && "min-h-16 border-t-0 bg-secondary shadow-[0_-1px_3px_rgb(0_0_0/0.08)]",
|
||||
className
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label={t("nav_label")}
|
||||
>
|
||||
@@ -381,14 +388,17 @@ export function NavigationRail({
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={cn(
|
||||
"relative",
|
||||
mountainView && isActive && "min-w-14 rounded-full bg-primary/15 px-4 py-[3px]"
|
||||
)}>
|
||||
<Icon className="w-5 h-5" />
|
||||
{item.badge != null && item.badge > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1">
|
||||
{item.badge > 99 ? "99+" : item.badge}
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
{isActive && !mountainView && (
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
+123
-17
@@ -17,6 +17,7 @@ import {
|
||||
ChevronsRight,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
User,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
NotebookPen,
|
||||
PenSquare,
|
||||
CalendarClock,
|
||||
BellOff,
|
||||
Mails,
|
||||
@@ -80,6 +82,12 @@ interface SidebarProps {
|
||||
onMailboxSelect?: (mailboxId: string) => void;
|
||||
onTagSelect?: (keywordId: string | null) => void;
|
||||
onCompose?: () => void;
|
||||
/**
|
||||
* Mountain View, desktop only: the rail grows a Compose pill at the top and
|
||||
* drops its own header, because the global top bar already carries the
|
||||
* collapse control and the account switcher.
|
||||
*/
|
||||
mvShell?: boolean;
|
||||
onSidebarClose?: () => void;
|
||||
onUnreadFilterClick?: (mailboxId: string) => void;
|
||||
onMarkFolderRead?: (mailboxId: string) => void;
|
||||
@@ -107,6 +115,8 @@ interface SidebarProps {
|
||||
/** Gated All mail / Unread / Starred entries in the "Unified Mailbox" section. */
|
||||
showCrossUnread?: boolean;
|
||||
showCrossStarred?: boolean;
|
||||
/** Mountain View: open Starred from its own row under the Inbox. */
|
||||
onOpenStarred?: () => void;
|
||||
showCrossAll?: boolean;
|
||||
/** Unread total across all cross-view folders (badge for unread/all). */
|
||||
crossUnreadCount?: number;
|
||||
@@ -320,6 +330,7 @@ function SidebarRow({
|
||||
{...(dropHandlers || {})}
|
||||
onContextMenu={onContextMenu}
|
||||
data-testid="folder-row"
|
||||
data-mv-nav-row=""
|
||||
data-folder-role={testRole ?? undefined}
|
||||
data-folder-name={testName ?? undefined}
|
||||
data-mailbox-id={testMailboxId ?? undefined}
|
||||
@@ -815,7 +826,7 @@ export function Sidebar({
|
||||
selectedKeyword = null,
|
||||
onMailboxSelect,
|
||||
onTagSelect,
|
||||
onCompose: _onCompose,
|
||||
onCompose,
|
||||
onSidebarClose,
|
||||
onUnreadFilterClick,
|
||||
onMarkFolderRead,
|
||||
@@ -836,6 +847,7 @@ export function Sidebar({
|
||||
crossAccountActive = false,
|
||||
showCrossUnread = false,
|
||||
showCrossStarred = false,
|
||||
onOpenStarred,
|
||||
showCrossAll = false,
|
||||
crossUnreadCount = 0,
|
||||
className,
|
||||
@@ -843,13 +855,23 @@ export function Sidebar({
|
||||
accountMailboxes,
|
||||
viewingAccountId = null,
|
||||
onAccountMailboxSelect,
|
||||
mvShell = false,
|
||||
}: SidebarProps) {
|
||||
const router = useRouter();
|
||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||
const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||
// Mountain View's collapsed rail is a peek, not a commitment: putting the pointer on
|
||||
// it slides the full list back out over the message list and takes it away
|
||||
// again on leaving, so a folder two clicks deep costs no clicks at all. It
|
||||
// floats rather than widening the column, or every hover would reflow the
|
||||
// list behind it.
|
||||
const [railHovered, setRailHovered] = useState(false);
|
||||
const hoverExpanded = mvShell && sidebarCollapsed && railHovered;
|
||||
const isCollapsed = sidebarCollapsed && !hoverExpanded;
|
||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||
const [showAllTags, setShowAllTags] = useState(false);
|
||||
const [moreExpanded, setMoreExpanded] = useState(false);
|
||||
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||
@@ -1011,6 +1033,22 @@ export function Sidebar({
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
||||
|
||||
// Mountain View's navigation is one flat list of the few places mail
|
||||
// actually goes, with everything else - the folders you made, the shared
|
||||
// ones, the labels - behind a single "More". The "Folders" and "Tags"
|
||||
// headings say the same thing the icons already do, so the first heading
|
||||
// goes and the rest move below the expander. The collapsed rail is left as
|
||||
// it is: it shows icons only, and hiding half of them behind a word would
|
||||
// leave a control with no room to put it.
|
||||
const mvFlat = mvShell && !isCollapsed;
|
||||
const isPrimaryFolder = (node: MailboxNode) =>
|
||||
node.role === 'inbox' || node.role === 'sent' || node.role === 'drafts';
|
||||
const primaryTree = mvFlat ? ownTree.filter(isPrimaryFolder) : ownTree;
|
||||
const secondaryTree = mvFlat ? ownTree.filter((node) => !isPrimaryFolder(node)) : [];
|
||||
// Everything under "More" is hidden together, so one flag covers the extra
|
||||
// folders, the shared accounts and the labels.
|
||||
const showSecondary = !mvFlat || moreExpanded;
|
||||
|
||||
// With nesting off every tag is its own root, so the same rows render through
|
||||
// one path whether or not the ids describe a hierarchy.
|
||||
const tagTree: KeywordNode[] = nestedTags
|
||||
@@ -1213,17 +1251,24 @@ export function Sidebar({
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={mvShell && sidebarCollapsed ? () => setRailHovered(true) : undefined}
|
||||
onMouseLeave={mvShell && sidebarCollapsed ? () => setRailHovered(false) : undefined}
|
||||
data-mv-rail-peek={hoverExpanded ? "" : undefined}
|
||||
className={cn(
|
||||
"relative flex flex-col h-full border-e transition-all duration-300 overflow-hidden",
|
||||
"bg-secondary border-border",
|
||||
"max-lg:w-full",
|
||||
isCollapsed ? "lg:w-12" : "lg:w-full",
|
||||
// Floats out of the rail's own column rather than widening it, so the
|
||||
// message list underneath never moves while the pointer passes over.
|
||||
// Width comes from --mv-rail-peek-w, keyed on data-mv-rail-peek.
|
||||
hoverExpanded && "lg:absolute lg:inset-y-0 lg:start-0 lg:z-40 lg:shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header - hidden in the Pro shell, which owns its own chrome and
|
||||
would otherwise render an empty strip (no collapse, no switcher). */}
|
||||
{!isEmbedded && (
|
||||
{!isEmbedded && !mvShell && (
|
||||
// Border lives on the wrapper (outside the h-14 box) so the bar's total
|
||||
// height matches the search/reply toolbars, which border-b their wrapper too.
|
||||
<div className="border-b border-border">
|
||||
@@ -1255,6 +1300,26 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mvShell && onCompose && (
|
||||
<div className={cn("pt-2 pb-3", isCollapsed ? "px-2" : "px-3")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCompose}
|
||||
data-mv-compose=""
|
||||
data-tour="compose-button"
|
||||
className={cn(
|
||||
"flex items-center bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-shadow",
|
||||
isCollapsed ? "w-14 justify-center" : "gap-3 ps-4 pe-6"
|
||||
)}
|
||||
title={t("compose_hint")}
|
||||
aria-label={t("compose")}
|
||||
>
|
||||
<PenSquare className="w-5 h-5 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="text-sm font-medium">{t("compose")}</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && <DemoBanner />}
|
||||
{!isCollapsed && <VacationBanner />}
|
||||
|
||||
@@ -1382,16 +1447,18 @@ export function Sidebar({
|
||||
})
|
||||
) : (
|
||||
<div onContextMenu={handleFoldersHeaderContextMenu}>
|
||||
<SidebarSectionHeader
|
||||
label={t("folders")}
|
||||
expanded={foldersExpanded}
|
||||
onToggle={toggleFolders}
|
||||
onSettings={openFolderSettings}
|
||||
settingsTitle={t('settings')}
|
||||
isCollapsed={isCollapsed}
|
||||
first={!showUnified}
|
||||
/>
|
||||
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
|
||||
{!mvFlat && (
|
||||
<SidebarSectionHeader
|
||||
label={t("folders")}
|
||||
expanded={foldersExpanded}
|
||||
onToggle={toggleFolders}
|
||||
onSettings={openFolderSettings}
|
||||
settingsTitle={t('settings')}
|
||||
isCollapsed={isCollapsed}
|
||||
first={!showUnified}
|
||||
/>
|
||||
)}
|
||||
{(mvFlat || (foldersExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{mailboxes.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
@@ -1399,7 +1466,7 @@ export function Sidebar({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{ownTree.map((node) => (
|
||||
{primaryTree.map((node) => (
|
||||
<Fragment key={node.id}>
|
||||
<MailboxTreeItem
|
||||
node={node}
|
||||
@@ -1413,9 +1480,48 @@ export function Sidebar({
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
{node.role === 'drafts' && renderScheduledRow('scheduled')}
|
||||
{/* Mountain View lists Starred right under the Inbox. */}
|
||||
{mvFlat && node.role === 'inbox' && onOpenStarred && (
|
||||
<SidebarRow
|
||||
icon={<Star className="w-4 h-4 text-muted-foreground" />}
|
||||
label={t("mailboxes.starred")}
|
||||
testName="mv-starred"
|
||||
depth={0}
|
||||
isSelected={selectedMailbox === CROSS_VIEW_IDS.starred}
|
||||
onClick={onOpenStarred}
|
||||
isCollapsed={false}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{!ownTree.some((node) => node.role === 'drafts') && renderScheduledRow('scheduled')}
|
||||
{!primaryTree.some((node) => node.role === 'drafts') && renderScheduledRow('scheduled')}
|
||||
{mvFlat && (
|
||||
<SidebarRow
|
||||
icon={moreExpanded
|
||||
? <ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
: <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
label={moreExpanded ? t("show_fewer") : t("show_more")}
|
||||
testName="mv-more"
|
||||
depth={0}
|
||||
isSelected={false}
|
||||
onClick={() => setMoreExpanded((prev) => !prev)}
|
||||
isCollapsed={false}
|
||||
/>
|
||||
)}
|
||||
{mvFlat && moreExpanded && secondaryTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -1423,7 +1529,7 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!useMultiAccount && sharedAccounts.length > 0 && (
|
||||
{!useMultiAccount && showSecondary && sharedAccounts.length > 0 && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("shared")}
|
||||
@@ -1482,7 +1588,7 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emailKeywords.length > 0 && (
|
||||
{showSecondary && emailKeywords.length > 0 && (
|
||||
<div data-tour="keyword-tags">
|
||||
<SidebarSectionHeader
|
||||
label={t("tags")}
|
||||
|
||||
+247
-100
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { EmailList } from "@/components/email/email-list";
|
||||
import { MvComposeWindow } from "@/components/email/mv-compose-window";
|
||||
import { MessageListTabs } from "@/components/email/message-list-tabs";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
@@ -25,7 +26,7 @@ const EmailComposer = dynamic(
|
||||
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email, Mailbox, type StateChange, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
|
||||
import { ThreadGroup, Email, Mailbox, type StateChange, isUnifiedMailboxId, UNIFIED_MAILBOX_IDS, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, CROSS_VIEW_IDS, isCrossViewId } from "@/lib/jmap/types";
|
||||
import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
@@ -42,13 +43,14 @@ import { ShareNotificationToaster } from "@/components/layout/share-notification
|
||||
import { CalendarEventNotificationToaster } from "@/components/layout/calendar-event-notification-toaster";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { usePaneSize } from "@/hooks/use-pane-size";
|
||||
import { usePaneId } from "@/hooks/use-pane-context";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
|
||||
@@ -70,14 +72,17 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||
import { isFilterEmpty, activeFilterCount, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
|
||||
import { FilterPanelHost } from "@/components/search/filter-panel-host";
|
||||
import type { ContactSuggestion } from "@/lib/search-suggestions";
|
||||
import type { Attachment } from "@/lib/jmap/types";
|
||||
import { peekListAttachments, requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments";
|
||||
import { useSearchHistoryStore } from "@/stores/search-history-store";
|
||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { MvListToolbar } from "@/components/email/mv-list-toolbar";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
@@ -166,7 +171,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const tCommon = useTranslations('common');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const { appName } = useConfig();
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const mailLayout = useEffectiveMailLayout();
|
||||
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
|
||||
// Phones present search full-screen from the header field rather than
|
||||
// giving it a permanent second bar under the header.
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
@@ -349,6 +355,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Mobile/tablet responsive hooks
|
||||
const { isMobile, isTablet } = useDeviceDetection();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
/**
|
||||
* Mountain View's list cursor. `j` / `k` there move a highlight without
|
||||
* opening the conversation, marking it read or changing the URL - only
|
||||
* Enter / `o` / a click do that. The app otherwise treats "selected" and
|
||||
* "open" as the same thing, so Mountain View keeps the selection (every
|
||||
* action already targets it) and records that it is only a cursor.
|
||||
*/
|
||||
const [cursorOnly, setCursorOnly] = useState(false);
|
||||
// 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 && !isTablet && !isEmbedded;
|
||||
const mvMobile = interfaceLayout === 'mountain-view' && isMobile && !isEmbedded;
|
||||
// Pane hosting (Pro shell). When this app renders inside a Pro pane, the
|
||||
// pane publishes its width and id; `isMobile` above is then pane-based,
|
||||
// and layout that would use viewport-fixed positioning must scope itself
|
||||
@@ -532,6 +550,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
unifiedCrossAccountGate &&
|
||||
accounts.filter((a) => a.isConnected).length > 1;
|
||||
|
||||
// Mountain View's account popover offers the merged inbox above the addresses it
|
||||
// merges. Offering it when it cannot be built would give a row that selects
|
||||
// an empty list, so it appears on exactly the terms the sidebar's own
|
||||
// unified section does.
|
||||
const allInboxesReachable = enableUnifiedMailbox && crossAccountActive;
|
||||
const allInboxesSelected = selectedMailbox === UNIFIED_MAILBOX_IDS.inbox;
|
||||
|
||||
// The node the Mountain View top bar renders under its search field; the filter
|
||||
// panel is portalled into it so it hangs off the control that opens it.
|
||||
const [filterAnchor, setFilterAnchor] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// Builds the populated UnifiedAccountClient[] used by the unified-view
|
||||
// effects and one-shot actions in this page. Reads the settings at call time
|
||||
// so the latest toggle values are always honored. When the cross-account
|
||||
@@ -687,7 +716,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
useBrowserNavigation({
|
||||
mailboxId: selectedMailbox,
|
||||
emailId: selectedEmail?.id ?? null,
|
||||
emailId: mvShell && cursorOnly ? null : (selectedEmail?.id ?? null),
|
||||
threadId: conversationThread?.threadId ?? null,
|
||||
composerOpen: showComposer,
|
||||
sidebarOpen,
|
||||
@@ -703,7 +732,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
const nextIndex = currentIndex < activeEmails.length - 1 ? currentIndex + 1 : currentIndex;
|
||||
if (nextIndex >= 0 && nextIndex < activeEmails.length) {
|
||||
handleEmailSelect(activeEmails[nextIndex]);
|
||||
void handleEmailSelect(activeEmails[nextIndex]);
|
||||
if (mvShell) setCursorOnly(true);
|
||||
}
|
||||
},
|
||||
onPreviousEmail: () => {
|
||||
@@ -711,13 +741,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : activeEmails.length;
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
||||
if (prevIndex >= 0 && prevIndex < activeEmails.length) {
|
||||
handleEmailSelect(activeEmails[prevIndex]);
|
||||
void handleEmailSelect(activeEmails[prevIndex]);
|
||||
if (mvShell) setCursorOnly(true);
|
||||
}
|
||||
},
|
||||
onOpenEmail: () => {
|
||||
// Email is already opened when selected
|
||||
// Outside Mountain View the email is already open by virtue of being
|
||||
// selected; under it this is the step that promotes cursor to open.
|
||||
setCursorOnly(false);
|
||||
},
|
||||
onCloseEmail: () => {
|
||||
setCursorOnly(false);
|
||||
selectEmail(null);
|
||||
if (isMobile) {
|
||||
setActiveView("list");
|
||||
@@ -868,6 +902,38 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onDeselectAll: () => {
|
||||
clearSelection();
|
||||
},
|
||||
// `x` under Mountain View: put the focused conversation in or out of the
|
||||
// selection. Only reachable there, where `x` is handed over from thread
|
||||
// expansion.
|
||||
onToggleSelection: () => {
|
||||
if (isScheduledView || !selectedEmail) return;
|
||||
toggleEmailSelection(selectedEmail.id);
|
||||
},
|
||||
// Mountain View's `g` sequences. `g s` goes to the "All starred" view when
|
||||
// the user has one - a list you arrive at and leave, like the other `g`
|
||||
// destinations. Without it there is no starred destination here, and the
|
||||
// fallback is the advanced-search star toggle: it shows the same messages,
|
||||
// but as a search you then have to clear. All mail uses the folder the
|
||||
// account nominated in Layout settings, falling back to its Archive.
|
||||
onGoToMailbox: (target: GoToMailboxTarget) => {
|
||||
if (isScheduledView && target !== 'inbox') return;
|
||||
if (target === 'starred') {
|
||||
goToStarred();
|
||||
return;
|
||||
}
|
||||
const roleFor = { inbox: 'inbox', sent: 'sent', drafts: 'drafts' } as const;
|
||||
if (target === 'all') {
|
||||
const configured = viewingAccountId
|
||||
? useSettingsStore.getState().allMailFolderIds[viewingAccountId]
|
||||
: undefined;
|
||||
const allMailId = (typeof configured === 'string' ? configured : undefined)
|
||||
?? mailboxes.find((mb) => mb.role === 'archive')?.id;
|
||||
if (allMailId) void handleMailboxSelect(allMailId);
|
||||
return;
|
||||
}
|
||||
const mailbox = mailboxes.find((mb) => mb.role === roleFor[target]);
|
||||
if (mailbox) void handleMailboxSelect(mailbox.id);
|
||||
},
|
||||
// `x` in the help modal: expand/collapse the selected email's thread. Same
|
||||
// steps as EmailList.handleToggleThreadExpansion - expanding pulls the
|
||||
// thread's messages and marks them read, collapsing only toggles. (#683)
|
||||
@@ -885,7 +951,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes, isScheduledView]);
|
||||
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes, isScheduledView, mvShell, showCrossStarred]);
|
||||
|
||||
// Initialize keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
@@ -893,6 +959,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
emails: activeEmails,
|
||||
selectedEmailId: selectedEmail?.id,
|
||||
selectionCount: selectedEmailIds.size,
|
||||
mountainViewKeys: mvShell,
|
||||
handlers: keyboardHandlers,
|
||||
});
|
||||
|
||||
@@ -1677,6 +1744,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A Mountain View cursor is not a read receipt: the mail is only marked once it is
|
||||
// actually opened.
|
||||
if (mvShell && cursorOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current setting value
|
||||
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
||||
debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
||||
@@ -1707,7 +1780,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmail?.id, isScheduledView]);
|
||||
}, [selectedEmail?.id, isScheduledView, mvShell, cursorOnly]);
|
||||
|
||||
// Handle new email notifications - play sound
|
||||
useEffect(() => {
|
||||
@@ -3121,6 +3194,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Starred for Mountain View: the "All starred" view when the user has one, otherwise
|
||||
// the advanced-search star toggle, which shows the same messages as a
|
||||
// search. Used by `g s` and by Mountain View's Starred row.
|
||||
const goToStarred = () => {
|
||||
if (showCrossStarred) {
|
||||
void handleMailboxSelect(CROSS_VIEW_IDS.starred);
|
||||
return;
|
||||
}
|
||||
setSearchFilters({ ...DEFAULT_SEARCH_FILTERS, isStarred: true });
|
||||
void handleAdvancedSearch();
|
||||
};
|
||||
|
||||
const handleAdvancedSearch = async () => {
|
||||
if (!client) return;
|
||||
await advancedSearch(client);
|
||||
@@ -3468,7 +3553,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
})();
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
// Under Mountain View on desktop the composer floats over the list in a
|
||||
// docked window instead of taking the reading pane.
|
||||
const floatingComposer = mvShell && showComposer && !isEmbedded;
|
||||
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
|
||||
|| Boolean(selectedEmail && !(mvShell && cursorOnly));
|
||||
const shouldCollapseListPane = (isTablet && !tabletListVisible && !readingPaneEmpty) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
@@ -3478,6 +3567,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
email: { id: string } & Partial<Pick<Email, 'sourceClientAccountId' | 'sourceAccountId'>>,
|
||||
) => {
|
||||
if (!client || !email) return;
|
||||
setCursorOnly(false);
|
||||
|
||||
// If composing, suspend the composer (unmount will trigger onSaveState)
|
||||
if (showComposer) {
|
||||
@@ -3695,6 +3785,87 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</button>
|
||||
);
|
||||
|
||||
// The composer, rendered in the reading pane - or, under Mountain View on
|
||||
// desktop, in a window docked over the list (see MvComposeWindow).
|
||||
const composerNode = (
|
||||
<ErrorBoundary
|
||||
fallback={ComposerErrorFallback}
|
||||
onReset={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
setComposerQuoteHeader(null);
|
||||
}}
|
||||
>
|
||||
<EmailComposer
|
||||
key={composerSessionId}
|
||||
mode={pendingDraft?.mode ?? composerMode}
|
||||
composeFromAccountEmail={resolveComposeAccountEmail(
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
useAccountStore
|
||||
.getState()
|
||||
.getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email,
|
||||
)}
|
||||
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
subject: selectedEmail.subject,
|
||||
...getQuoteBodies(selectedEmail),
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
// The login that holds the message, so the reply
|
||||
// defaults to that account's identity (#1104).
|
||||
accountId: selectedEmail.sourceClientAccountId ?? viewingAccountId ?? undefined,
|
||||
attachments: selectedEmail.attachments,
|
||||
messageId: selectedEmail.messageId,
|
||||
inReplyTo: selectedEmail.inReplyTo,
|
||||
references: selectedEmail.references,
|
||||
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||
quoteHeaderText: composerQuoteHeader?.text,
|
||||
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
onSaveState={(data) => {
|
||||
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
|
||||
suppressComposerStateSaveSessionRef.current = null;
|
||||
return;
|
||||
}
|
||||
setPendingDraft(data);
|
||||
}}
|
||||
onSend={async (data) => {
|
||||
await handleEmailSend(data);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
onScheduledSendCreated={async () => {
|
||||
if (client) {
|
||||
await refreshScheduledMetadata(client);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
}
|
||||
setShowComposer(false);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
setComposerDraftText("");
|
||||
setPendingDraft(null);
|
||||
setComposerQuoteHeader(null);
|
||||
if (isMobile) {
|
||||
setActiveView('list');
|
||||
}
|
||||
}}
|
||||
requestCloseRef={composerRequestCloseRef}
|
||||
onDiscardDraft={(draftId) => {
|
||||
handleDiscardDraft(draftId);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
@@ -3717,9 +3888,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
<span>{tCommon('reconnecting')}</span>
|
||||
</div>
|
||||
)}
|
||||
{mvShell && !fullscreenReading && (
|
||||
<MvTopBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onSearchSubmit={handleSearch}
|
||||
onSearchClear={handleClearSearch}
|
||||
onSelectContact={handleSelectContactSuggestion}
|
||||
onToggleFilters={toggleAdvancedSearch}
|
||||
filtersOpen={isAdvancedSearchOpen}
|
||||
activeFilterCount={activeFilterCount(searchFilters)}
|
||||
searchDisabled={isScheduledView}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
onSelectAllInboxes={
|
||||
allInboxesReachable ? () => handleMailboxSelect(UNIFIED_MAILBOX_IDS.inbox) : undefined
|
||||
}
|
||||
allInboxesSelected={allInboxesSelected}
|
||||
onFilterAnchorChange={setFilterAnchor}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Desktop Navigation Rail (hidden when embedded inside Pro shell) */}
|
||||
{!isMobile && !isTablet && !isEmbedded && (
|
||||
{/* Desktop Navigation Rail (hidden when embedded inside Pro shell, and
|
||||
under Mountain View, whose header carries the app grid instead) */}
|
||||
{!isMobile && !isTablet && !isEmbedded && !mvShell && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -3793,7 +3988,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
inlineApp && "hidden",
|
||||
fullscreenReading && "hidden"
|
||||
)}
|
||||
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 48 : sidebarWidth } : undefined}
|
||||
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? (mvShell ? 72 : 48) : sidebarWidth } : undefined}
|
||||
>
|
||||
<ErrorBoundary fallback={SidebarErrorFallback}>
|
||||
<Sidebar
|
||||
@@ -3807,6 +4002,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
crossAccountActive={crossAccountActive}
|
||||
showCrossUnread={showCrossUnread}
|
||||
showCrossStarred={showCrossStarred}
|
||||
onOpenStarred={mvShell ? goToStarred : undefined}
|
||||
showCrossAll={showCrossAll}
|
||||
crossUnreadCount={crossUnreadCount}
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
@@ -3833,6 +4029,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
}}
|
||||
onSidebarClose={() => setSidebarOpen(false)}
|
||||
mvShell={mvShell}
|
||||
multiAccountMode={isEmbedded}
|
||||
accountMailboxes={accountMailboxes}
|
||||
viewingAccountId={viewingAccountId}
|
||||
@@ -3894,6 +4091,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
searchPlaceholder={searchQuery || t('sidebar.search_placeholder_hint')}
|
||||
searchActive={!!searchQuery}
|
||||
onClearSearch={handleClearSearch}
|
||||
onSelectAllInboxes={
|
||||
allInboxesReachable ? () => handleMailboxSelect(UNIFIED_MAILBOX_IDS.inbox) : undefined
|
||||
}
|
||||
allInboxesSelected={allInboxesSelected}
|
||||
/>
|
||||
|
||||
{/* Search Bar + Inline Advanced Filters. On phones this is not a
|
||||
@@ -3920,6 +4121,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
<span className="font-medium truncate">{t('sidebar.search_placeholder_hint')}</span>
|
||||
</div>
|
||||
)}
|
||||
{mvShell ? (
|
||||
<MvListToolbar
|
||||
loadedCount={activeEmails.length}
|
||||
totalCount={mailboxes.find((mb) => mb.id === selectedMailbox)?.totalEmails}
|
||||
onRefresh={handleManualRefresh}
|
||||
isRefreshing={isManualRefreshing}
|
||||
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
|
||||
onMarkAllFoldersRead={handleMarkAllFoldersRead}
|
||||
onEmptyFolder={selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
|
||||
disabled={isScheduledView}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 h-14 flex items-center">
|
||||
<div className="flex items-center gap-1.5 w-full">
|
||||
{/* Select / Select All toggle */}
|
||||
@@ -4004,10 +4217,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Area */}
|
||||
{/* Filter Area. Under Mountain View the button that opens this
|
||||
lives in the global top bar, so the panel drops from the
|
||||
search field there rather than from the list header. */}
|
||||
{isAdvancedSearchOpen && (
|
||||
<div className="px-3 pb-3 space-y-2.5 animate-in slide-in-from-top-1 fade-in duration-150">
|
||||
<FilterPanelHost anchor={mvShell ? filterAnchor : null}>
|
||||
{/* Quick toggle filters + clear */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -4188,7 +4404,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FilterPanelHost>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -4215,6 +4431,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
hideBatchToolbar={mvShell}
|
||||
emails={activeEmails}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
isLoading={activeIsLoading}
|
||||
@@ -4316,8 +4533,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Floating Compose Button */}
|
||||
<Button
|
||||
{/* Floating Compose Button - on desktop Mountain View puts Compose
|
||||
at the top of the navigation rail instead. */}
|
||||
{!mvShell && <Button
|
||||
onClick={() => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
@@ -4325,15 +4543,20 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
className={cn(
|
||||
"absolute z-40 rounded-full shadow-lg",
|
||||
isMobile ? "bottom-4 end-4 h-14 w-14" : "bottom-4 end-4 h-12 w-12"
|
||||
"absolute z-40 shadow-lg",
|
||||
mvMobile
|
||||
? "bottom-4 end-4 h-14 gap-3 rounded-2xl px-5"
|
||||
: isMobile
|
||||
? "bottom-4 end-4 h-14 w-14 rounded-full"
|
||||
: "bottom-4 end-4 h-12 w-12 rounded-full"
|
||||
)}
|
||||
aria-label={t('sidebar.compose')}
|
||||
title={t('sidebar.compose_hint')}
|
||||
data-tour="compose-button"
|
||||
>
|
||||
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
|
||||
</Button>
|
||||
{mvMobile && <span className="text-sm font-medium">{t('sidebar.compose')}</span>}
|
||||
</Button>}
|
||||
</div>
|
||||
|
||||
{/* Email list resize handle (desktop only) */}
|
||||
@@ -4377,84 +4600,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
{/* Inline Composer - shown in viewer pane.
|
||||
In Pro/embedded mode the composer is hoisted into its own
|
||||
Pro tab (see the effect below), so we never render it inline. */}
|
||||
{(showComposer && !isEmbedded) ? (
|
||||
<ErrorBoundary
|
||||
fallback={ComposerErrorFallback}
|
||||
onReset={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
setComposerQuoteHeader(null);
|
||||
}}
|
||||
>
|
||||
<EmailComposer
|
||||
key={composerSessionId}
|
||||
mode={pendingDraft?.mode ?? composerMode}
|
||||
composeFromAccountEmail={resolveComposeAccountEmail(
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
useAccountStore
|
||||
.getState()
|
||||
.getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email,
|
||||
)}
|
||||
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
subject: selectedEmail.subject,
|
||||
...getQuoteBodies(selectedEmail),
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
// The login that holds the message, so the reply
|
||||
// defaults to that account's identity (#1104).
|
||||
accountId: selectedEmail.sourceClientAccountId ?? viewingAccountId ?? undefined,
|
||||
attachments: selectedEmail.attachments,
|
||||
messageId: selectedEmail.messageId,
|
||||
inReplyTo: selectedEmail.inReplyTo,
|
||||
references: selectedEmail.references,
|
||||
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||
quoteHeaderText: composerQuoteHeader?.text,
|
||||
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
onSaveState={(data) => {
|
||||
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
|
||||
suppressComposerStateSaveSessionRef.current = null;
|
||||
return;
|
||||
}
|
||||
setPendingDraft(data);
|
||||
}}
|
||||
onSend={async (data) => {
|
||||
await handleEmailSend(data);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
onScheduledSendCreated={async () => {
|
||||
if (client) {
|
||||
await refreshScheduledMetadata(client);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
}
|
||||
setShowComposer(false);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
setComposerDraftText("");
|
||||
setPendingDraft(null);
|
||||
setComposerQuoteHeader(null);
|
||||
if (isMobile) {
|
||||
setActiveView('list');
|
||||
}
|
||||
}}
|
||||
requestCloseRef={composerRequestCloseRef}
|
||||
onDiscardDraft={(draftId) => {
|
||||
handleDiscardDraft(draftId);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
{(showComposer && !isEmbedded && !floatingComposer) ? composerNode : (
|
||||
<>
|
||||
{/* Pending draft banner */}
|
||||
{pendingDraft && (
|
||||
@@ -4654,6 +4800,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
<ShareNotificationToaster client={client} />
|
||||
<CalendarEventNotificationToaster client={client} />
|
||||
<TotpReauthDialog />
|
||||
{floatingComposer && <MvComposeWindow>{composerNode}</MvComposeWindow>}
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FilterPanelHost } from '../filter-panel-host';
|
||||
|
||||
// Mountain View moves the filter button into the global top bar. The panel
|
||||
// follows it there, given an anchor; without one it stays in the flow of the
|
||||
// message-list column, which is the default layout's arrangement.
|
||||
|
||||
describe('FilterPanelHost', () => {
|
||||
it('renders in place when there is no anchor', () => {
|
||||
const { container } = render(
|
||||
<FilterPanelHost anchor={null}><span>filters</span></FilterPanelHost>
|
||||
);
|
||||
|
||||
expect(container).toContainElement(screen.getByText('filters'));
|
||||
expect(container.querySelector('[data-mv-filter-panel]')).toBeNull();
|
||||
});
|
||||
|
||||
it('lines the panel up with the anchor: same leading edge, same width, just below', () => {
|
||||
const anchor = document.createElement('div');
|
||||
anchor.getBoundingClientRect = () =>
|
||||
({ left: 165, width: 505, top: 56, bottom: 56, right: 670, height: 0, x: 165, y: 56, toJSON: () => ({}) }) as DOMRect;
|
||||
document.body.appendChild(anchor);
|
||||
|
||||
const { container } = render(
|
||||
<FilterPanelHost anchor={anchor}><span>filters</span></FilterPanelHost>
|
||||
);
|
||||
|
||||
const panel = document.querySelector<HTMLElement>('[data-mv-filter-panel]')!;
|
||||
expect(panel).toContainElement(screen.getByText('filters'));
|
||||
expect(container).not.toContainElement(panel);
|
||||
expect(panel.style.left).toBe('165px');
|
||||
expect(panel.style.width).toBe('505px');
|
||||
expect(Number.parseInt(panel.style.top, 10)).toBeGreaterThanOrEqual(56);
|
||||
|
||||
anchor.remove();
|
||||
});
|
||||
|
||||
it('draws it at the end of <body>, above the sidebar column and not inside the top bar', () => {
|
||||
const anchor = document.createElement('div');
|
||||
document.body.appendChild(anchor);
|
||||
|
||||
render(<FilterPanelHost anchor={anchor}><span>filters</span></FilterPanelHost>);
|
||||
|
||||
const panel = document.querySelector<HTMLElement>('[data-mv-filter-panel]')!;
|
||||
// The sidebar column is z-50 and later in the document than the top bar,
|
||||
// so inside the bar the panel lost that tie and was painted over.
|
||||
expect(anchor).not.toContainElement(panel);
|
||||
expect(panel.parentElement).toBe(document.body);
|
||||
expect(panel.className).toContain('fixed');
|
||||
expect(panel.className).toContain('z-50');
|
||||
|
||||
anchor.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/**
|
||||
* Where the advanced-filter panel is drawn.
|
||||
*
|
||||
* With no anchor it stays where it has always been: in the flow of the
|
||||
* message-list column, directly under the header that carries the filter
|
||||
* button. That is the default layout, and nothing about it changes.
|
||||
*
|
||||
* Mountain View moves the filter button up into the global top bar, next to
|
||||
* the search field. Given the anchor the top bar renders under that field, the
|
||||
* panel drops from it instead: same leading edge, same width, directly
|
||||
* below.
|
||||
*
|
||||
* It is portalled to <body> and positioned from the anchor's box rather than
|
||||
* rendered inside the anchor. Inside the top bar it shared the root stacking
|
||||
* context with the sidebar column, which carries the same z-index and comes
|
||||
* later in the document, so the sidebar painted over the panel's leading half.
|
||||
* At the end of <body> it wins that tie, and it still loses to any dialog
|
||||
* opened afterwards, which is the order one wants.
|
||||
*/
|
||||
export function FilterPanelHost({
|
||||
anchor,
|
||||
children,
|
||||
}: {
|
||||
anchor: HTMLElement | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const box = useAnchorBox(anchor);
|
||||
|
||||
const panel = (
|
||||
<div className="px-3 pb-3 space-y-2.5 animate-in slide-in-from-top-1 fade-in duration-150">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!anchor) return panel;
|
||||
if (!box) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-mv-filter-panel=""
|
||||
style={box}
|
||||
className="fixed z-50 max-h-[calc(100dvh-5rem)] overflow-y-auto rounded-lg border border-border bg-popover pt-3 shadow-xl"
|
||||
>
|
||||
{panel}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/** The anchor's leading edge, width and bottom, kept current as the bar resizes. */
|
||||
function useAnchorBox(anchor: HTMLElement | null): CSSProperties | null {
|
||||
const [box, setBox] = useState<CSSProperties | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor) {
|
||||
setBox(null);
|
||||
return;
|
||||
}
|
||||
const measure = () => {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
setBox({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
};
|
||||
measure();
|
||||
|
||||
// The field is flex-sized, so it moves with the sidebar and the window;
|
||||
// either observer alone misses one of the two.
|
||||
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
|
||||
observer?.observe(anchor);
|
||||
if (anchor.parentElement) observer?.observe(anchor.parentElement);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, [anchor]);
|
||||
|
||||
return box;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Check, Folder } from '@/components/icons';
|
||||
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
||||
import { useSettingsStore, type ToolbarPosition, type MailLayout, type InterfaceLayout } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
@@ -118,7 +118,7 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, tintListRowsByAccount, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, tintListRowsByAccount, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, interfaceLayout, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const activeAccountId = useAccountStore(s => s.activeAccountId);
|
||||
@@ -168,6 +168,20 @@ export function LayoutSettings() {
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* The interface layout decides where search, navigation and toolbars
|
||||
go - a layout choice, so it leads the Layout tab. */}
|
||||
{!isSettingHidden('interfaceLayout') && (
|
||||
<SettingItem label={t('interface_layout.label')} description={t('interface_layout.description')} locked={isSettingLocked('interfaceLayout')}>
|
||||
<RadioGroup
|
||||
value={interfaceLayout}
|
||||
onChange={(value) => updateSetting('interfaceLayout', value as InterfaceLayout)}
|
||||
options={[
|
||||
{ value: 'default', label: t('interface_layout.default') },
|
||||
{ value: 'mountain-view', label: t('interface_layout.mountain_view') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
{!isSettingHidden('mailLayout') && (
|
||||
<SettingItem label={tEmail('mail_layout.label')} description={tEmail('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
|
||||
<div className="w-[22rem] max-w-full">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useEffectiveMailLayout } from '@/hooks/use-effective-mail-layout';
|
||||
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
|
||||
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -28,7 +29,6 @@ export function ReadingSettings() {
|
||||
clearSearchOnFolderChange,
|
||||
showPreview,
|
||||
showVerificationCodes,
|
||||
mailLayout,
|
||||
disableThreading,
|
||||
emailsPerPage,
|
||||
mailAttachmentAction,
|
||||
@@ -44,7 +44,7 @@ export function ReadingSettings() {
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
const isFocusedLayout = mailLayout === 'focus';
|
||||
const isFocusedLayout = useEffectiveMailLayout() === 'focus';
|
||||
|
||||
const handleReorganizeArchive = async () => {
|
||||
const { client } = useAuthStore.getState();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { effectiveMailLayout, useSettingsStore, type MailLayout } from "@/stores/settings-store";
|
||||
|
||||
/** The mail layout in force: the saved one, or `focus` under Mountain View. */
|
||||
export function useEffectiveMailLayout(): MailLayout {
|
||||
return useSettingsStore((state) => effectiveMailLayout(state.mailLayout, state.interfaceLayout));
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import type { InterfaceLayout } from "@/stores/settings-store";
|
||||
|
||||
export interface KeyboardShortcutHandlers {
|
||||
// Navigation
|
||||
@@ -34,10 +35,34 @@ export interface KeyboardShortcutHandlers {
|
||||
|
||||
// Thread actions
|
||||
onToggleThreadExpansion?: () => void;
|
||||
|
||||
/** Mountain View's `x`: put the focused conversation in or out of the selection. */
|
||||
onToggleSelection?: () => void;
|
||||
/** Mountain View's `g` sequences: `g` then i / s / t / d / a. */
|
||||
onGoToMailbox?: (target: GoToMailboxTarget) => void;
|
||||
}
|
||||
|
||||
export type GoToMailboxTarget = 'inbox' | 'starred' | 'sent' | 'drafts' | 'all';
|
||||
|
||||
/** The letter each `g` sequence ends on. */
|
||||
const GO_TO_KEYS: Record<string, GoToMailboxTarget> = {
|
||||
i: 'inbox',
|
||||
s: 'starred',
|
||||
t: 'sent',
|
||||
d: 'drafts',
|
||||
a: 'all',
|
||||
};
|
||||
|
||||
/** How long a pending `g` waits for its second key before lapsing. */
|
||||
const GO_TO_SEQUENCE_MS = 1500;
|
||||
|
||||
export interface UseKeyboardShortcutsOptions {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Mountain View assigns `x` to selection rather than to thread expansion,
|
||||
* and enables the `g` sequences.
|
||||
*/
|
||||
mountainViewKeys?: boolean;
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
selectionCount?: number;
|
||||
@@ -69,12 +94,17 @@ function physicalShortcutKey(event: KeyboardEvent): string {
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled = true,
|
||||
mountainViewKeys = false,
|
||||
emails,
|
||||
selectedEmailId,
|
||||
selectionCount = 0,
|
||||
handlers,
|
||||
}: UseKeyboardShortcutsOptions) {
|
||||
const handlersRef = useRef(handlers);
|
||||
// A `g` waiting for the letter that completes it. Held in a ref so the
|
||||
// listener stays stable; cleared on timeout, on completion, and on any key
|
||||
// that isn't part of a sequence.
|
||||
const pendingGoTo = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Keep handlers ref updated
|
||||
useEffect(() => {
|
||||
@@ -108,6 +138,19 @@ export function useKeyboardShortcuts({
|
||||
|
||||
const hasBatchTarget = !!selectedEmailId || selectionCount > 0;
|
||||
|
||||
// Second half of a `g` sequence. Checked before the switch so `s`, `t`,
|
||||
// `a` and `d` reach the folder jump instead of star / reply-all.
|
||||
if (mountainViewKeys && pendingGoTo.current !== null) {
|
||||
clearTimeout(pendingGoTo.current);
|
||||
pendingGoTo.current = null;
|
||||
const target = GO_TO_KEYS[key];
|
||||
if (target && h.onGoToMailbox) {
|
||||
event.preventDefault();
|
||||
h.onGoToMailbox(target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
// Navigation
|
||||
case "j":
|
||||
@@ -226,21 +269,44 @@ export function useKeyboardShortcuts({
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
h.onRefresh?.();
|
||||
} else if (mountainViewKeys && h.onGoToMailbox) {
|
||||
// Arm the sequence; the next key either completes it or lapses.
|
||||
event.preventDefault();
|
||||
pendingGoTo.current = setTimeout(() => {
|
||||
pendingGoTo.current = null;
|
||||
}, GO_TO_SEQUENCE_MS);
|
||||
}
|
||||
break;
|
||||
|
||||
// Thread actions
|
||||
case "x":
|
||||
if (selectedEmailId) {
|
||||
if (mountainViewKeys) {
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onToggleSelection?.();
|
||||
}
|
||||
} else if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onToggleThreadExpansion?.();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[selectedEmailId, selectionCount]
|
||||
[selectedEmailId, selectionCount, mountainViewKeys]
|
||||
);
|
||||
|
||||
// A pending sequence must not outlive the hook.
|
||||
useEffect(() => () => {
|
||||
if (pendingGoTo.current !== null) clearTimeout(pendingGoTo.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountainViewKeys && pendingGoTo.current !== null) {
|
||||
clearTimeout(pendingGoTo.current);
|
||||
pendingGoTo.current = null;
|
||||
}
|
||||
}, [mountainViewKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -273,6 +339,16 @@ export function useKeyboardShortcuts({
|
||||
return { getAdjacentEmailIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the help modal. `layout` limits a row to one interface layout:
|
||||
* Mountain View adds the `g` sequences and gives `x` to selection.
|
||||
*/
|
||||
export interface ShortcutEntry {
|
||||
readonly key: string;
|
||||
readonly description: string;
|
||||
readonly layout?: InterfaceLayout;
|
||||
}
|
||||
|
||||
// Shortcut definitions for the help modal
|
||||
export const KEYBOARD_SHORTCUTS = {
|
||||
navigation: [
|
||||
@@ -280,6 +356,7 @@ export const KEYBOARD_SHORTCUTS = {
|
||||
{ key: "k / ↑", description: "shortcuts.navigation.previous_email" },
|
||||
{ key: "Enter / o", description: "shortcuts.navigation.open_email" },
|
||||
{ key: "Esc", description: "shortcuts.navigation.close_email" },
|
||||
{ key: "g → i / s / t / d / a", description: "shortcuts.navigation.go_to_folder", layout: "mountain-view" },
|
||||
],
|
||||
actions: [
|
||||
{ key: "r", description: "shortcuts.actions.reply" },
|
||||
@@ -301,7 +378,8 @@ export const KEYBOARD_SHORTCUTS = {
|
||||
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
|
||||
],
|
||||
threads: [
|
||||
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
||||
{ key: "x", description: "shortcuts.threads.expand_collapse", layout: "default" },
|
||||
{ key: "x", description: "shortcuts.threads.select_mountain_view", layout: "mountain-view" },
|
||||
],
|
||||
composer: [
|
||||
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
|
||||
|
||||
@@ -14,6 +14,12 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
* sidebar reads this cache to render a Thunderbird-style per-account folder
|
||||
* tree (see [[project_pro_mode]]). Outside Pro the cache stays empty.
|
||||
*
|
||||
* Mountain View needs the same cache for a different reason: its account
|
||||
* popover puts the unread waiting in each account next to that address, and
|
||||
* the active login's folder list knows nothing about the others. It is only
|
||||
* worth the extra round trips once a second account is connected - with one
|
||||
* account the popover has nothing to compare.
|
||||
*
|
||||
* Each login is loaded once per client, not once per connection: while a
|
||||
* browser restores its logins they connect one after another, and reloading
|
||||
* every connected account on each connection cost 1 + 2 + … + N folder lists
|
||||
@@ -25,6 +31,7 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
export function useProMultiAccountMailboxes(): void {
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
const mountainView = useSettingsStore((s) => s.interfaceLayout === "mountain-view");
|
||||
// The ids of the connected logins, as one string: `accounts` itself changes
|
||||
// for display-name and login-time updates that call for no fetch at all.
|
||||
const connectedSignature = useAccountStore((s) =>
|
||||
@@ -35,8 +42,8 @@ export function useProMultiAccountMailboxes(): void {
|
||||
const lastActive = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!proInterface && !isEmbedded) return;
|
||||
const connected = connectedSignature ? connectedSignature.split("\n") : [];
|
||||
if (!proInterface && !isEmbedded && !(mountainView && connected.length > 1)) return;
|
||||
if (connected.length === 0) return;
|
||||
|
||||
if (lastActive.current !== null && lastActive.current !== activeAccountId) {
|
||||
@@ -58,5 +65,5 @@ export function useProMultiAccountMailboxes(): void {
|
||||
if (!ok && loaded.current.get(id) === client) loaded.current.delete(id);
|
||||
});
|
||||
}
|
||||
}, [proInterface, isEmbedded, connectedSignature, activeAccountId]);
|
||||
}, [proInterface, isEmbedded, mountainView, connectedSignature, activeAccountId]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { inboxUnread, totalInboxUnread } from '../account-inbox-unread';
|
||||
import type { Mailbox } from '../jmap/types';
|
||||
|
||||
const makeMailbox = (overrides: Partial<Mailbox> = {}): Mailbox => ({
|
||||
id: 'mb-1',
|
||||
name: 'Inbox',
|
||||
role: 'inbox',
|
||||
sortOrder: 0,
|
||||
totalEmails: 10,
|
||||
unreadEmails: 3,
|
||||
totalThreads: 10,
|
||||
unreadThreads: 3,
|
||||
isSubscribed: true,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('inboxUnread', () => {
|
||||
it('reads the unread count off the account\'s own inbox', () => {
|
||||
expect(inboxUnread([makeMailbox({ unreadEmails: 7 })])).toBe(7);
|
||||
});
|
||||
|
||||
it('tells "not loaded yet" apart from a real zero', () => {
|
||||
expect(inboxUnread(undefined)).toBeUndefined();
|
||||
expect(inboxUnread([])).toBeUndefined();
|
||||
expect(inboxUnread([makeMailbox({ unreadEmails: 0 })])).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a shared inbox, which belongs to somebody else', () => {
|
||||
const shared = makeMailbox({ id: 'mb-shared', isShared: true, unreadEmails: 99 });
|
||||
expect(inboxUnread([shared])).toBeUndefined();
|
||||
expect(inboxUnread([shared, makeMailbox({ unreadEmails: 2 })])).toBe(2);
|
||||
});
|
||||
|
||||
it('has nothing to report for an account with no inbox role', () => {
|
||||
expect(inboxUnread([makeMailbox({ role: 'archive' })])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('totalInboxUnread', () => {
|
||||
it('adds up the accounts it was asked about, and only those', () => {
|
||||
const byAccount = {
|
||||
a: [makeMailbox({ unreadEmails: 4 })],
|
||||
b: [makeMailbox({ unreadEmails: 5 })],
|
||||
c: [makeMailbox({ unreadEmails: 100 })],
|
||||
};
|
||||
expect(totalInboxUnread(['a', 'b'], byAccount)).toBe(9);
|
||||
});
|
||||
|
||||
it('counts an account whose folders have not arrived as nothing', () => {
|
||||
expect(totalInboxUnread(['a', 'b'], { a: [makeMailbox({ unreadEmails: 4 })] })).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
|
||||
/**
|
||||
* Unread messages waiting in an account's own inbox.
|
||||
*
|
||||
* Mountain View's account popover puts this number next to every address, so
|
||||
* that switching account is a decision one can take from the popover instead
|
||||
* of having to visit each account in turn.
|
||||
*
|
||||
* Shared folders are skipped: they are someone else's inbox, they are listed
|
||||
* under their owner in the sidebar, and counting them here would attribute
|
||||
* another person's unread mail to this account. `undefined` means "not known
|
||||
* yet" - the account's folders have not been fetched - and is deliberately
|
||||
* distinct from a known zero, which the caller should render as no badge
|
||||
* rather than as a "0".
|
||||
*/
|
||||
export function inboxUnread(mailboxes: Mailbox[] | undefined): number | undefined {
|
||||
if (!mailboxes || mailboxes.length === 0) return undefined;
|
||||
const inbox = mailboxes.find((mailbox) => mailbox.role === "inbox" && !mailbox.isShared);
|
||||
return inbox ? inbox.unreadEmails : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What "All inboxes" carries: the unread of every listed account's inbox.
|
||||
*
|
||||
* Accounts whose folders are still loading contribute nothing rather than
|
||||
* blocking the total, so the badge grows as the fetches land instead of
|
||||
* appearing all at once several seconds in.
|
||||
*/
|
||||
export function totalInboxUnread(
|
||||
accountIds: readonly string[],
|
||||
mailboxesByAccount: Record<string, Mailbox[] | undefined>
|
||||
): number {
|
||||
return accountIds.reduce((total, id) => total + (inboxUnread(mailboxesByAccount[id]) ?? 0), 0);
|
||||
}
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "إظهار الكل ({count})",
|
||||
"show_fewer_tags": "إظهار أقل",
|
||||
"folders": "المجلدات",
|
||||
"show_more": "المزيد",
|
||||
"show_fewer": "أقل",
|
||||
"shared": "مشترك",
|
||||
"mail": "البريد",
|
||||
"nav_label": "التنقل",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "تم الرد عليها",
|
||||
"forwarded": "تمت إعادة توجيهها",
|
||||
"has_attachment": "تحتوي على مرفق",
|
||||
"conversations_range": "{from}–{to, number} من {total, number}",
|
||||
"conversations_count": "{count} من {total} محادثة",
|
||||
"conversations_count_plus": "{count}+ محادثة",
|
||||
"conversations_count_simple": "{count} محادثة",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "تصغير",
|
||||
"maximize": "ملء الشاشة",
|
||||
"restore": "استعادة",
|
||||
"read_receipt_on": "إيصال القراءة مطلوب (انقر للتعطيل)",
|
||||
"read_receipt_off": "طلب إيصال قراءة",
|
||||
"dsn_on": "تم طلب إشعار التسليم (انقر للتعطيل)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "عادي",
|
||||
"comfortable": "مريح"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "تخطيط الواجهة",
|
||||
"description": "موضع البحث والتنقل وأشرطة الأدوات",
|
||||
"default": "افتراضي",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "تفعيل الحركات",
|
||||
"description": "إظهار الانتقالات والتأثيرات السلسة"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "الرسالة التالية",
|
||||
"previous_email": "الرسالة السابقة",
|
||||
"open_email": "فتح الرسالة",
|
||||
"close_email": "إغلاق / إلغاء التحديد"
|
||||
"close_email": "إغلاق / إلغاء التحديد",
|
||||
"go_to_folder": "الانتقال إلى المجلد"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "رد",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "البحث في كل شيء"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "توسيع/طي المحادثة"
|
||||
"expand_collapse": "توسيع/طي المحادثة",
|
||||
"select_mountain_view": "تحديد المحادثة"
|
||||
},
|
||||
"composer": {
|
||||
"send": "إرسال الرسالة",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Mostra-ho tot ({count})",
|
||||
"show_fewer_tags": "Mostra'n menys",
|
||||
"folders": "Carpetes",
|
||||
"show_more": "Més",
|
||||
"show_fewer": "Menys",
|
||||
"shared": "Compartit",
|
||||
"mail": "Correu",
|
||||
"nav_label": "Navegació",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Respost",
|
||||
"forwarded": "Reenviat",
|
||||
"has_attachment": "Té adjunt",
|
||||
"conversations_range": "{from}–{to, number} de {total, number}",
|
||||
"conversations_count": "{count} de {total} converses",
|
||||
"conversations_count_plus": "{count}+ converses",
|
||||
"conversations_count_simple": "{count} converses",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimitza",
|
||||
"maximize": "Pantalla completa",
|
||||
"restore": "Restaura",
|
||||
"read_receipt_on": "Confirmació de lectura sol·licitada (feu clic per desactivar-la)",
|
||||
"read_receipt_off": "Sol·licita una confirmació de lectura",
|
||||
"dsn_on": "Notificació de lliurament sol·licitada (fes clic per desactivar)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "Normal",
|
||||
"comfortable": "Còmoda"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Disposició de la interfície",
|
||||
"description": "On se situen la cerca, la navegació i les barres d'eines",
|
||||
"default": "Predeterminat",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Activa les animacions",
|
||||
"description": "Mostra transicions i efectes suaus"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Correu següent",
|
||||
"previous_email": "Correu anterior",
|
||||
"open_email": "Obre el correu",
|
||||
"close_email": "Tanca / Desselecciona"
|
||||
"close_email": "Tanca / Desselecciona",
|
||||
"go_to_folder": "Vés a la carpeta"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Respon",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Cerca-ho tot"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Desplega/replega el fil"
|
||||
"expand_collapse": "Desplega/replega el fil",
|
||||
"select_mountain_view": "Selecciona la conversa"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Envia el correu",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Zobrazit vše ({count})",
|
||||
"show_fewer_tags": "Zobrazit méně",
|
||||
"folders": "Složky",
|
||||
"show_more": "Více",
|
||||
"show_fewer": "Méně",
|
||||
"shared": "Sdílené",
|
||||
"mail": "Pošta",
|
||||
"nav_label": "Navigace",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Zodpovězeno",
|
||||
"forwarded": "Přeposláno",
|
||||
"has_attachment": "Obsahuje přílohu",
|
||||
"conversations_range": "{from}–{to, number} z {total, number}",
|
||||
"conversations_count": "{count} z {total} konverzací",
|
||||
"conversations_count_plus": "Více než {count} konverzací",
|
||||
"conversations_count_simple": "{count} konverzací",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimalizovat",
|
||||
"maximize": "Celá obrazovka",
|
||||
"restore": "Obnovit",
|
||||
"read_receipt_on": "Vyžádáno potvrzení o přečtení (kliknutím vypnete)",
|
||||
"read_receipt_off": "Vyžádat potvrzení o přečtení",
|
||||
"dsn_on": "Oznámení o doručení vyžádáno (kliknutím vypnete)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Běžná",
|
||||
"comfortable": "Pohodlná"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Rozvržení rozhraní",
|
||||
"description": "Kde se nachází hledání, navigace a panely nástrojů",
|
||||
"default": "Výchozí",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Povolit animace",
|
||||
"description": "Zobrazovat plynulé přechody a efekty"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Další zpráva",
|
||||
"previous_email": "Předchozí zpráva",
|
||||
"open_email": "Otevřít zprávu",
|
||||
"close_email": "Zavřít / Zrušit výběr"
|
||||
"close_email": "Zavřít / Zrušit výběr",
|
||||
"go_to_folder": "Přejít do složky"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Odpovědět",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Hledat ve všem"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Rozbalit/sbalit vlákno"
|
||||
"expand_collapse": "Rozbalit/sbalit vlákno",
|
||||
"select_mountain_view": "Vybrat konverzaci"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Odeslat e-mail",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Vis alle ({count})",
|
||||
"show_fewer_tags": "Vis færre",
|
||||
"folders": "Mapper",
|
||||
"show_more": "Mere",
|
||||
"show_fewer": "Mindre",
|
||||
"shared": "Delt",
|
||||
"mail": "Mail",
|
||||
"nav_label": "Navigation",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Besvaret",
|
||||
"forwarded": "Videresendt",
|
||||
"has_attachment": "Har vedhæftning",
|
||||
"conversations_range": "{from}–{to, number} af {total, number}",
|
||||
"conversations_count": "{count} af {total} samtaler",
|
||||
"conversations_count_plus": "{count}+ samtaler",
|
||||
"conversations_count_simple": "{count} samtaler",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimer",
|
||||
"maximize": "Fuld skærm",
|
||||
"restore": "Gendan",
|
||||
"read_receipt_on": "Læsekvittering anmodet (klik for at deaktivere)",
|
||||
"read_receipt_off": "Anmod om læsekvittering",
|
||||
"dsn_on": "Leveringsbesked anmodet (klik for at slå fra)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "Almindelig",
|
||||
"comfortable": "Komfortabel"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Grænsefladelayout",
|
||||
"description": "Hvor søgning, navigation og værktøjslinjer er placeret",
|
||||
"default": "Standard",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Aktivér animationer",
|
||||
"description": "Vis glatte overgange og effekter"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Næste e-mail",
|
||||
"previous_email": "Forrige e-mail",
|
||||
"open_email": "Åbn e-mail",
|
||||
"close_email": "Luk / Fravaælg"
|
||||
"close_email": "Luk / Fravaælg",
|
||||
"go_to_folder": "Gå til mappe"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Svar",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Søg i alt"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Udvid/skjul tråd"
|
||||
"expand_collapse": "Udvid/skjul tråd",
|
||||
"select_mountain_view": "Vælg samtalen"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Send e-mail",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Alle anzeigen ({count})",
|
||||
"show_fewer_tags": "Weniger anzeigen",
|
||||
"folders": "Ordner",
|
||||
"show_more": "Mehr",
|
||||
"show_fewer": "Weniger",
|
||||
"mail": "E-Mail",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Beantwortet",
|
||||
"forwarded": "Weitergeleitet",
|
||||
"has_attachment": "Mit Anhang",
|
||||
"conversations_range": "{from}–{to, number} von {total, number}",
|
||||
"conversations_count": "{count} von {total} Unterhaltungen",
|
||||
"conversations_count_plus": "{count}+ Unterhaltungen",
|
||||
"conversations_count_simple": "{count} Unterhaltungen",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimieren",
|
||||
"maximize": "Vollbild",
|
||||
"restore": "Wiederherstellen",
|
||||
"read_receipt_on": "Lesebestätigung angefordert (klicken zum Deaktivieren)",
|
||||
"read_receipt_off": "Lesebestätigung anfordern",
|
||||
"dsn_on": "Zustellbenachrichtigung angefordert (zum Deaktivieren klicken)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Normal",
|
||||
"comfortable": "Komfortabel"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Oberflächen-Layout",
|
||||
"description": "Wo Suche, Navigation und Symbolleisten liegen",
|
||||
"default": "Standard",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Animationen aktivieren",
|
||||
"description": "Weiche Übergänge und Effekte anzeigen"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Nächste E-Mail",
|
||||
"previous_email": "Vorherige E-Mail",
|
||||
"open_email": "E-Mail öffnen",
|
||||
"close_email": "Schließen / Auswahl aufheben"
|
||||
"close_email": "Schließen / Auswahl aufheben",
|
||||
"go_to_folder": "Zum Ordner springen"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Antworten",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Alles durchsuchen"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Unterhaltung erweitern/einklappen"
|
||||
"expand_collapse": "Unterhaltung erweitern/einklappen",
|
||||
"select_mountain_view": "Konversation auswählen"
|
||||
},
|
||||
"composer": {
|
||||
"send": "E-Mail senden",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Show all ({count})",
|
||||
"show_fewer_tags": "Show less",
|
||||
"folders": "Folders",
|
||||
"show_more": "More",
|
||||
"show_fewer": "Less",
|
||||
"shared": "Shared",
|
||||
"mail": "Mail",
|
||||
"nav_label": "Navigation",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Replied",
|
||||
"forwarded": "Forwarded",
|
||||
"has_attachment": "Has attachment",
|
||||
"conversations_range": "{from}–{to, number} of {total, number}",
|
||||
"conversations_count": "{count} of {total} conversations",
|
||||
"conversations_count_plus": "{count}+ conversations",
|
||||
"conversations_count_simple": "{count} conversations",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Full screen",
|
||||
"restore": "Restore",
|
||||
"read_receipt_on": "Read receipt requested (click to disable)",
|
||||
"read_receipt_off": "Request a read receipt",
|
||||
"dsn_on": "Delivery notification requested (click to disable)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "Regular",
|
||||
"comfortable": "Comfortable"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Interface layout",
|
||||
"description": "Where search, navigation and toolbars sit",
|
||||
"default": "Default",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Enable Animations",
|
||||
"description": "Show smooth transitions and effects"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Next email",
|
||||
"previous_email": "Previous email",
|
||||
"open_email": "Open email",
|
||||
"close_email": "Close / Deselect"
|
||||
"close_email": "Close / Deselect",
|
||||
"go_to_folder": "Go to folder"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Reply",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Search everything"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Expand/collapse thread"
|
||||
"expand_collapse": "Expand/collapse thread",
|
||||
"select_mountain_view": "Select the conversation"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Send email",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Mostrar todo ({count})",
|
||||
"show_fewer_tags": "Mostrar menos",
|
||||
"folders": "Carpetas",
|
||||
"show_more": "Más",
|
||||
"show_fewer": "Menos",
|
||||
"mail": "Correo",
|
||||
"nav_label": "Navegación",
|
||||
"add_app": "Apps",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Respondido",
|
||||
"forwarded": "Reenviado",
|
||||
"has_attachment": "Tiene adjunto",
|
||||
"conversations_range": "{from}–{to, number} de {total, number}",
|
||||
"conversations_count": "{count} de {total} conversaciones",
|
||||
"conversations_count_plus": "{count}+ conversaciones",
|
||||
"conversations_count_simple": "{count} conversaciones",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Pantalla completa",
|
||||
"restore": "Restaurar",
|
||||
"read_receipt_on": "Confirmación de lectura solicitada (haz clic para desactivar)",
|
||||
"read_receipt_off": "Solicitar confirmación de lectura",
|
||||
"dsn_on": "Notificación de entrega solicitada (clic para desactivar)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Normal",
|
||||
"comfortable": "Cómodo"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Diseño de la interfaz",
|
||||
"description": "Dónde se sitúan la búsqueda, la navegación y las barras de herramientas",
|
||||
"default": "Predeterminado",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Habilitar Animaciones",
|
||||
"description": "Mostrar transiciones y efectos suaves"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Siguiente correo",
|
||||
"previous_email": "Correo anterior",
|
||||
"open_email": "Abrir correo",
|
||||
"close_email": "Cerrar / Deseleccionar"
|
||||
"close_email": "Cerrar / Deseleccionar",
|
||||
"go_to_folder": "Ir a la carpeta"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Responder",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Buscar en todo"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Expandir/contraer conversación"
|
||||
"expand_collapse": "Expandir/contraer conversación",
|
||||
"select_mountain_view": "Seleccionar la conversación"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Enviar correo",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "نمایش همه ({count})",
|
||||
"show_fewer_tags": "نمایش کمتر",
|
||||
"folders": "پوشهها",
|
||||
"show_more": "بیشتر",
|
||||
"show_fewer": "کمتر",
|
||||
"shared": "اشتراکی",
|
||||
"mail": "ایمیل",
|
||||
"nav_label": "ناوبری",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "پاسخ داده شده",
|
||||
"forwarded": "بازارسال شده",
|
||||
"has_attachment": "دارای پیوست",
|
||||
"conversations_range": "{from}–{to, number} از {total, number}",
|
||||
"conversations_count": "{count} از {total} مکالمه",
|
||||
"conversations_count_plus": "+{count} مکالمه",
|
||||
"conversations_count_simple": "{count} مکالمه",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "کوچک کردن",
|
||||
"maximize": "تمامصفحه",
|
||||
"restore": "بازگردانی",
|
||||
"read_receipt_on": "درخواست تأیید خواندن فعال (کلیک برای غیرفعال کردن)",
|
||||
"read_receipt_off": "درخواست تأیید خواندن",
|
||||
"dsn_on": "اعلان تحویل درخواست شد (برای غیرفعال کردن کلیک کنید)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "معمولی",
|
||||
"comfortable": "راحت"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "چیدمان رابط",
|
||||
"description": "جای جستوجو، ناوبری و نوارهای ابزار",
|
||||
"default": "پیشفرض",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "فعال کردن انیمیشنها",
|
||||
"description": "نمایش transitions و افکتهای نرم"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "ایمیل بعدی",
|
||||
"previous_email": "ایمیل قبلی",
|
||||
"open_email": "باز کردن ایمیل",
|
||||
"close_email": "بستن / لغو انتخاب"
|
||||
"close_email": "بستن / لغو انتخاب",
|
||||
"go_to_folder": "رفتن به پوشه"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "پاسخ",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "جستجو در همه جا"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "باز/بسته کردن رشته"
|
||||
"expand_collapse": "باز/بسته کردن رشته",
|
||||
"select_mountain_view": "انتخاب گفتوگو"
|
||||
},
|
||||
"composer": {
|
||||
"send": "ارسال ایمیل",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Tout afficher ({count})",
|
||||
"show_fewer_tags": "Afficher moins",
|
||||
"folders": "Dossiers",
|
||||
"show_more": "Plus",
|
||||
"show_fewer": "Moins",
|
||||
"mail": "Messagerie",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Répondu",
|
||||
"forwarded": "Transféré",
|
||||
"has_attachment": "Avec pièce jointe",
|
||||
"conversations_range": "{from}–{to, number} sur {total, number}",
|
||||
"conversations_count": "{count} sur {total} conversations",
|
||||
"conversations_count_plus": "{count}+ conversations",
|
||||
"conversations_count_simple": "{count} conversations",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Réduire",
|
||||
"maximize": "Plein écran",
|
||||
"restore": "Restaurer",
|
||||
"read_receipt_on": "Accusé de lecture demandé (cliquez pour désactiver)",
|
||||
"read_receipt_off": "Demander un accusé de lecture",
|
||||
"dsn_on": "Notification de remise demandée (cliquer pour désactiver)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Normale",
|
||||
"comfortable": "Confortable"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Disposition de l'interface",
|
||||
"description": "Où se trouvent la recherche, la navigation et les barres d'outils",
|
||||
"default": "Par défaut",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Activer les animations",
|
||||
"description": "Afficher les transitions et effets fluides"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Email suivant",
|
||||
"previous_email": "Email précédent",
|
||||
"open_email": "Ouvrir l'email",
|
||||
"close_email": "Fermer / Désélectionner"
|
||||
"close_email": "Fermer / Désélectionner",
|
||||
"go_to_folder": "Aller au dossier"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Répondre",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Tout rechercher"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Développer/réduire la conversation"
|
||||
"expand_collapse": "Développer/réduire la conversation",
|
||||
"select_mountain_view": "Sélectionner la conversation"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Envoyer l'e-mail",
|
||||
|
||||
+16
-2
@@ -133,6 +133,8 @@
|
||||
"show_all_tags": "הצג הכל ({count})",
|
||||
"show_fewer_tags": "הצג פחות",
|
||||
"folders": "תיקיות",
|
||||
"show_more": "עוד",
|
||||
"show_fewer": "פחות",
|
||||
"mail": "דוֹאַר",
|
||||
"nav_label": "ניווט",
|
||||
"add_app": "אפליקציות",
|
||||
@@ -202,6 +204,7 @@
|
||||
"replied": "נענה",
|
||||
"forwarded": "הועבר",
|
||||
"has_attachment": "כולל קובץ מצורף",
|
||||
"conversations_range": "{from}–{to, number} מתוך {total, number}",
|
||||
"conversations_count": "{count}מתוך שיחות{total}",
|
||||
"conversations_count_plus": "{count}+ שיחות",
|
||||
"conversations_count_simple": "שיחות{count}",
|
||||
@@ -562,6 +565,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "מזעור",
|
||||
"maximize": "מסך מלא",
|
||||
"restore": "שחזור",
|
||||
"new_message": "הודעה חדשה",
|
||||
"signature_edit_hint": "לחץ לחיצה כפולה לעריכת החתימה",
|
||||
"reply": "השב",
|
||||
@@ -1033,6 +1039,12 @@
|
||||
"regular": "רגיל",
|
||||
"comfortable": "נוח"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "פריסת הממשק",
|
||||
"description": "היכן ממוקמים החיפוש, הניווט וסרגלי הכלים",
|
||||
"default": "ברירת מחדל",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "הפעל אנימציות",
|
||||
"description": "הצג מעברים ואפקטים חלקים"
|
||||
@@ -2368,7 +2380,8 @@
|
||||
"next_email": "המייל הבא",
|
||||
"previous_email": "אימייל קודם",
|
||||
"open_email": "פתח אימייל",
|
||||
"close_email": "סגור / בטל בחירה"
|
||||
"close_email": "סגור / בטל בחירה",
|
||||
"go_to_folder": "מעבר לתיקייה"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "השב",
|
||||
@@ -2390,7 +2403,8 @@
|
||||
"global_search": "חיפוש בכל מקום"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "הרחב/כווץ את השרשור"
|
||||
"expand_collapse": "הרחב/כווץ את השרשור",
|
||||
"select_mountain_view": "בחירת השיחה"
|
||||
},
|
||||
"composer": {
|
||||
"template_picker": "פתח את בוחר התבניות",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Összes megjelenítése ({count})",
|
||||
"show_fewer_tags": "Kevesebb megjelenítése",
|
||||
"folders": "Mappák",
|
||||
"show_more": "Több",
|
||||
"show_fewer": "Kevesebb",
|
||||
"shared": "Megosztott",
|
||||
"mail": "Levelek",
|
||||
"nav_label": "Navigáció",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Megválaszolva",
|
||||
"forwarded": "Továbbítva",
|
||||
"has_attachment": "Melléklettel",
|
||||
"conversations_range": "{from}–{to, number} / {total, number}",
|
||||
"conversations_count": "{count} / {total} beszélgetés",
|
||||
"conversations_count_plus": "{count}+ beszélgetés",
|
||||
"conversations_count_simple": "{count} beszélgetés",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Kis méret",
|
||||
"maximize": "Teljes képernyő",
|
||||
"restore": "Visszaállítás",
|
||||
"read_receipt_on": "Olvasási visszaigazolás kérve (kattints a letiltáshoz)",
|
||||
"read_receipt_off": "Olvasási visszaigazolás kérése",
|
||||
"dsn_on": "Kézbesítési értesítés kérve (kattints a kikapcsoláshoz)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "Normál",
|
||||
"comfortable": "Kényelmes"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Felület elrendezése",
|
||||
"description": "Hol helyezkedik el a keresés, a navigáció és az eszköztárak",
|
||||
"default": "Alapértelmezett",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Animációk engedélyezése",
|
||||
"description": "Simább átmenetek és effektek megjelenítése"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Következő e-mail",
|
||||
"previous_email": "Előző e-mail",
|
||||
"open_email": "E-mail megnyitása",
|
||||
"close_email": "Bezárás / Kijelölés megszüntetése"
|
||||
"close_email": "Bezárás / Kijelölés megszüntetése",
|
||||
"go_to_folder": "Ugrás mappára"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Válasz",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Keresés mindenben"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Beszélgetés kibontása/összecsukása"
|
||||
"expand_collapse": "Beszélgetés kibontása/összecsukása",
|
||||
"select_mountain_view": "Beszélgetés kijelölése"
|
||||
},
|
||||
"composer": {
|
||||
"send": "E-mail küldése",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Mostra tutto ({count})",
|
||||
"show_fewer_tags": "Mostra meno",
|
||||
"folders": "Cartelle",
|
||||
"show_more": "Altro",
|
||||
"show_fewer": "Riduci",
|
||||
"mail": "Posta",
|
||||
"nav_label": "Navigazione",
|
||||
"add_app": "App",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Risposto",
|
||||
"forwarded": "Inoltrato",
|
||||
"has_attachment": "Con allegato",
|
||||
"conversations_range": "{from}–{to, number} di {total, number}",
|
||||
"conversations_count": "{count} di {total} conversazioni",
|
||||
"conversations_count_plus": "{count}+ conversazioni",
|
||||
"conversations_count_simple": "{count} conversazioni",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Riduci a icona",
|
||||
"maximize": "Schermo intero",
|
||||
"restore": "Ripristina",
|
||||
"read_receipt_on": "Conferma di lettura richiesta (clicca per disattivare)",
|
||||
"read_receipt_off": "Richiedi una conferma di lettura",
|
||||
"dsn_on": "Notifica di consegna richiesta (clic per disattivare)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Normale",
|
||||
"comfortable": "Comodo"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Layout dell'interfaccia",
|
||||
"description": "Dove si trovano ricerca, navigazione e barre degli strumenti",
|
||||
"default": "Predefinito",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Abilita animazioni",
|
||||
"description": "Mostra transizioni ed effetti fluidi"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Messaggio successivo",
|
||||
"previous_email": "Messaggio precedente",
|
||||
"open_email": "Apri messaggio",
|
||||
"close_email": "Chiudi / Deseleziona"
|
||||
"close_email": "Chiudi / Deseleziona",
|
||||
"go_to_folder": "Vai alla cartella"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Rispondi",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Cerca ovunque"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Espandi/comprimi conversazione"
|
||||
"expand_collapse": "Espandi/comprimi conversazione",
|
||||
"select_mountain_view": "Seleziona la conversazione"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Invia email",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "すべて表示({count})",
|
||||
"show_fewer_tags": "表示を減らす",
|
||||
"folders": "フォルダ",
|
||||
"show_more": "もっと見る",
|
||||
"show_fewer": "折りたたむ",
|
||||
"mail": "メール",
|
||||
"nav_label": "ナビゲーション",
|
||||
"add_app": "アプリ",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "返信済み",
|
||||
"forwarded": "転送済み",
|
||||
"has_attachment": "添付ファイルあり",
|
||||
"conversations_range": "{total, number} 件中 {from}–{to, number} 件",
|
||||
"conversations_count": "{total}件中{count}件の会話",
|
||||
"conversations_count_plus": "{count}件以上の会話",
|
||||
"conversations_count_simple": "{count}件の会話",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "最小化",
|
||||
"maximize": "全画面表示",
|
||||
"restore": "元に戻す",
|
||||
"read_receipt_on": "開封確認を要求中(クリックで無効化)",
|
||||
"read_receipt_off": "開封確認を要求する",
|
||||
"dsn_on": "配信通知を要求中(クリックで解除)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "標準",
|
||||
"comfortable": "ゆったり"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "インターフェースのレイアウト",
|
||||
"description": "検索・ナビゲーション・ツールバーの配置",
|
||||
"default": "デフォルト",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "アニメーションを有効にする",
|
||||
"description": "スムーズな遷移とエフェクトを表示"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "次のメール",
|
||||
"previous_email": "前のメール",
|
||||
"open_email": "メールを開く",
|
||||
"close_email": "閉じる / 選択解除"
|
||||
"close_email": "閉じる / 選択解除",
|
||||
"go_to_folder": "フォルダーに移動"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "返信",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "すべてを検索"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "スレッドの展開/折りたたみ"
|
||||
"expand_collapse": "スレッドの展開/折りたたみ",
|
||||
"select_mountain_view": "スレッドを選択"
|
||||
},
|
||||
"composer": {
|
||||
"send": "メールを送信",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "전체 보기({count})",
|
||||
"show_fewer_tags": "간략히 보기",
|
||||
"folders": "폴더",
|
||||
"show_more": "더보기",
|
||||
"show_fewer": "간략히",
|
||||
"mail": "메일",
|
||||
"nav_label": "내비게이션",
|
||||
"add_app": "앱",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "답장함",
|
||||
"forwarded": "전달함",
|
||||
"has_attachment": "첨부 파일 있음",
|
||||
"conversations_range": "{total, number}개 중 {from}–{to, number}",
|
||||
"conversations_count": "전체 {total}개 중 {count}개의 대화",
|
||||
"conversations_count_plus": "{count}개 이상의 대화",
|
||||
"conversations_count_simple": "{count}개의 대화",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "최소화",
|
||||
"maximize": "전체 화면",
|
||||
"restore": "복원",
|
||||
"read_receipt_on": "읽음 확인 요청됨 (클릭하여 해제)",
|
||||
"read_receipt_off": "읽음 확인 요청",
|
||||
"dsn_on": "배달 알림 요청됨 (클릭하여 해제)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "보통",
|
||||
"comfortable": "넓게"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "인터페이스 레이아웃",
|
||||
"description": "검색, 탐색, 도구 모음의 위치",
|
||||
"default": "기본",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "애니메이션 켜기",
|
||||
"description": "부드러운 전환 효과를 사용해요"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "다음 메일",
|
||||
"previous_email": "이전 메일",
|
||||
"open_email": "메일 열기",
|
||||
"close_email": "닫기 / 선택 해제"
|
||||
"close_email": "닫기 / 선택 해제",
|
||||
"go_to_folder": "폴더로 이동"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "답장",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "전체 검색"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "대화 펼치기/접기"
|
||||
"expand_collapse": "대화 펼치기/접기",
|
||||
"select_mountain_view": "대화 선택"
|
||||
},
|
||||
"composer": {
|
||||
"send": "이메일 보내기",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Rādīt visus ({count})",
|
||||
"show_fewer_tags": "Rādīt mazāk",
|
||||
"folders": "Mapes",
|
||||
"show_more": "Vairāk",
|
||||
"show_fewer": "Mazāk",
|
||||
"mail": "Pasts",
|
||||
"nav_label": "Navigācija",
|
||||
"add_app": "Lietotnes",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Atbildēts",
|
||||
"forwarded": "Pārsūtīts",
|
||||
"has_attachment": "Ar pielikumu",
|
||||
"conversations_range": "{from}–{to, number} no {total, number}",
|
||||
"conversations_count": "{count} no {total} sarunām",
|
||||
"conversations_count_plus": "{count}+ sarunas",
|
||||
"conversations_count_simple": "{count} sarunas",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimizēt",
|
||||
"maximize": "Pilnekrāns",
|
||||
"restore": "Atjaunot",
|
||||
"read_receipt_on": "Pieprasīts lasīšanas apstiprinājums (noklikšķiniet, lai atspējotu)",
|
||||
"read_receipt_off": "Pieprasīt lasīšanas apstiprinājumu",
|
||||
"dsn_on": "Piegādes paziņojums pieprasīts (noklikšķiniet, lai atspējotu)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Parasts",
|
||||
"comfortable": "Plašs"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Saskarnes izkārtojums",
|
||||
"description": "Kur atrodas meklēšana, navigācija un rīkjoslas",
|
||||
"default": "Noklusējuma",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Iespējot animācijas",
|
||||
"description": "Rādīt plūstošas pārejas un efektus"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Nākamā vēstule",
|
||||
"previous_email": "Iepriekšējā vēstule",
|
||||
"open_email": "Atvērt vēstuli",
|
||||
"close_email": "Aizvērt / Noņemt atlasi"
|
||||
"close_email": "Aizvērt / Noņemt atlasi",
|
||||
"go_to_folder": "Pāriet uz mapi"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Atbildēt",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Meklēt visā"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Izvērst/sairt sarunu"
|
||||
"expand_collapse": "Izvērst/sairt sarunu",
|
||||
"select_mountain_view": "Atlasīt sarunu"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Nosūtīt e-pastu",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Бүгдийг нь харуулах ({count})",
|
||||
"show_fewer_tags": "Бага харуулах",
|
||||
"folders": "Хавтас",
|
||||
"show_more": "Илүү",
|
||||
"show_fewer": "Бага",
|
||||
"shared": "Хуваалцсан",
|
||||
"mail": "Мэйл",
|
||||
"nav_label": "Навигаци",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Хариулсан",
|
||||
"forwarded": "Дамжуулсан",
|
||||
"has_attachment": "Хавсралттай",
|
||||
"conversations_range": "{from}–{to, number} / {total, number}",
|
||||
"conversations_count": "{total} харилцан ярианы {count}",
|
||||
"conversations_count_plus": "{count}+ харилцан яриа",
|
||||
"conversations_count_simple": "{count} харилцан яриа",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Багасгах",
|
||||
"maximize": "Бүтэн дэлгэц",
|
||||
"restore": "Сэргээх",
|
||||
"read_receipt_on": "Уншсан баримт хүссэн (идэвхгүй болгохын тулд дарна уу)",
|
||||
"read_receipt_off": "Уншсан баримт хүсэх",
|
||||
"dsn_on": "Хүргэлтийн мэдэгдэл хүссэн (идэвхгүй болгох бол дарна уу)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "Тогтмол",
|
||||
"comfortable": "Тав тухтай"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Интерфейсийн байрлал",
|
||||
"description": "Хайлт, навигаци, хэрэгслийн мөр хаана байрлах",
|
||||
"default": "Үндсэн",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Анимацийг идэвхжүүлэх",
|
||||
"description": "Гөлгөр шилжилт, эффектийг харуулах"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Дараагийн имэйл",
|
||||
"previous_email": "Өмнөх имэйл",
|
||||
"open_email": "Имэйл нээх",
|
||||
"close_email": "Хаах / Сонголтыг цуцлах"
|
||||
"close_email": "Хаах / Сонголтыг цуцлах",
|
||||
"go_to_folder": "Хавтас руу очих"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Хариулах",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Бүгдийг хайх"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Хэлхээсийг дэлгэх/хумих"
|
||||
"expand_collapse": "Хэлхээсийг дэлгэх/хумих",
|
||||
"select_mountain_view": "Харилцааг сонгох"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Имэйл илгээх",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Vis alle ({count})",
|
||||
"show_fewer_tags": "Vis færre",
|
||||
"folders": "Mapper",
|
||||
"show_more": "Mer",
|
||||
"show_fewer": "Mindre",
|
||||
"shared": "Delt",
|
||||
"mail": "E-post",
|
||||
"nav_label": "Navigasjon",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Besvart",
|
||||
"forwarded": "Videresendt",
|
||||
"has_attachment": "Har vedlegg",
|
||||
"conversations_range": "{from}–{to, number} av {total, number}",
|
||||
"conversations_count": "{count} av {total} samtaler",
|
||||
"conversations_count_plus": "{count}+ samtaler",
|
||||
"conversations_count_simple": "{count} samtaler",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimer",
|
||||
"maximize": "Fullskjerm",
|
||||
"restore": "Gjenopprett",
|
||||
"read_receipt_on": "Ber om lesebekreftelse (klikk for å slå av)",
|
||||
"read_receipt_off": "Be om lesebekreftelse",
|
||||
"dsn_on": "Leveringsvarsel forespurt (klikk for å slå av)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "Vanlig",
|
||||
"comfortable": "Luftig"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Grensesnittoppsett",
|
||||
"description": "Hvor søk, navigasjon og verktøylinjer ligger",
|
||||
"default": "Standard",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Aktiver animasjoner",
|
||||
"description": "Vis jevne overganger og effekter"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Neste e-post",
|
||||
"previous_email": "Forrige e-post",
|
||||
"open_email": "Åpne e-post",
|
||||
"close_email": "Lukk / fjern valget"
|
||||
"close_email": "Lukk / fjern valget",
|
||||
"go_to_folder": "Gå til mappe"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Svar",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Søk i alt"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Utvid eller fold sammen tråden"
|
||||
"expand_collapse": "Utvid eller fold sammen tråden",
|
||||
"select_mountain_view": "Velg samtalen"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Send e-post",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Alles tonen ({count})",
|
||||
"show_fewer_tags": "Minder tonen",
|
||||
"folders": "Mappen",
|
||||
"show_more": "Meer",
|
||||
"show_fewer": "Minder",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navigatie",
|
||||
"add_app": "Apps",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Beantwoord",
|
||||
"forwarded": "Doorgestuurd",
|
||||
"has_attachment": "Met bijlage",
|
||||
"conversations_range": "{from}–{to, number} van {total, number}",
|
||||
"conversations_count": "{count} van {total} gesprekken",
|
||||
"conversations_count_plus": "{count}+ gesprekken",
|
||||
"conversations_count_simple": "{count} gesprekken",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimaliseren",
|
||||
"maximize": "Volledig scherm",
|
||||
"restore": "Herstellen",
|
||||
"read_receipt_on": "Leesbevestiging aangevraagd (klik om uit te schakelen)",
|
||||
"read_receipt_off": "Leesbevestiging aanvragen",
|
||||
"dsn_on": "Bezorgmelding aangevraagd (klik om uit te schakelen)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Normaal",
|
||||
"comfortable": "Comfortabel"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Interface-indeling",
|
||||
"description": "Waar zoeken, navigatie en werkbalken staan",
|
||||
"default": "Standaard",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Animaties inschakelen",
|
||||
"description": "Vloeiende overgangen en effecten tonen"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Volgende e-mail",
|
||||
"previous_email": "Vorige e-mail",
|
||||
"open_email": "E-mail openen",
|
||||
"close_email": "Sluiten / Deselecteren"
|
||||
"close_email": "Sluiten / Deselecteren",
|
||||
"go_to_folder": "Ga naar map"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Beantwoorden",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Alles doorzoeken"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Gesprek uitklappen/inklappen"
|
||||
"expand_collapse": "Gesprek uitklappen/inklappen",
|
||||
"select_mountain_view": "Gesprek selecteren"
|
||||
},
|
||||
"composer": {
|
||||
"send": "E-mail verzenden",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Pokaż wszystkie ({count})",
|
||||
"show_fewer_tags": "Pokaż mniej",
|
||||
"folders": "Foldery",
|
||||
"show_more": "Więcej",
|
||||
"show_fewer": "Mniej",
|
||||
"mail": "Poczta",
|
||||
"nav_label": "Nawigacja",
|
||||
"add_app": "Aplikacje",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Odpowiedziano",
|
||||
"forwarded": "Przekazano",
|
||||
"has_attachment": "Z załącznikiem",
|
||||
"conversations_range": "{from}–{to, number} z {total, number}",
|
||||
"conversations_count": "{count} z {total} konwersacji",
|
||||
"conversations_count_plus": "{count}+ konwersacji",
|
||||
"conversations_count_simple": "{count} konwersacji",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimalizuj",
|
||||
"maximize": "Pełny ekran",
|
||||
"restore": "Przywróć",
|
||||
"read_receipt_on": "Zażądano potwierdzenia przeczytania (kliknij, aby wyłączyć)",
|
||||
"read_receipt_off": "Zażądaj potwierdzenia przeczytania",
|
||||
"dsn_on": "Zażądano powiadomienia o dostarczeniu (kliknij, aby wyłączyć)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Zwykły",
|
||||
"comfortable": "Wygodny"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Układ interfejsu",
|
||||
"description": "Gdzie znajdują się wyszukiwarka, nawigacja i paski narzędzi",
|
||||
"default": "Domyślny",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Włącz animacje",
|
||||
"description": "Pokazuj płynne przejścia i efekty"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Następna wiadomość",
|
||||
"previous_email": "Poprzednia wiadomość",
|
||||
"open_email": "Otwórz wiadomość",
|
||||
"close_email": "Zamknij / Odznacz"
|
||||
"close_email": "Zamknij / Odznacz",
|
||||
"go_to_folder": "Przejdź do folderu"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Odpowiedz",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Szukaj wszędzie"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Rozwiń/zwiń wątek"
|
||||
"expand_collapse": "Rozwiń/zwiń wątek",
|
||||
"select_mountain_view": "Zaznacz konwersację"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Wyślij e-mail",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Mostrar tudo ({count})",
|
||||
"show_fewer_tags": "Mostrar menos",
|
||||
"folders": "Pastas",
|
||||
"show_more": "Mais",
|
||||
"show_fewer": "Menos",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navegação",
|
||||
"add_app": "Apps",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Respondido",
|
||||
"forwarded": "Encaminhado",
|
||||
"has_attachment": "Com anexo",
|
||||
"conversations_range": "{from}–{to, number} de {total, number}",
|
||||
"conversations_count": "{count} de {total} conversas",
|
||||
"conversations_count_plus": "{count}+ conversas",
|
||||
"conversations_count_simple": "{count} conversas",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Ecrã inteiro",
|
||||
"restore": "Restaurar",
|
||||
"read_receipt_on": "Confirmação de leitura solicitada (clique para desativar)",
|
||||
"read_receipt_off": "Solicitar confirmação de leitura",
|
||||
"dsn_on": "Notificação de entrega pedida (clique para desativar)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Normal",
|
||||
"comfortable": "Confortável"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Esquema da interface",
|
||||
"description": "Onde ficam a pesquisa, a navegação e as barras de ferramentas",
|
||||
"default": "Predefinido",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Habilitar Animações",
|
||||
"description": "Mostrar transições e efeitos suaves"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Próximo e-mail",
|
||||
"previous_email": "E-mail anterior",
|
||||
"open_email": "Abrir e-mail",
|
||||
"close_email": "Fechar / Desselecionar"
|
||||
"close_email": "Fechar / Desselecionar",
|
||||
"go_to_folder": "Ir para a pasta"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Responder",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Pesquisar em tudo"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Expandir/recolher conversa"
|
||||
"expand_collapse": "Expandir/recolher conversa",
|
||||
"select_mountain_view": "Selecionar a conversa"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Enviar e-mail",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Afișează tot ({count})",
|
||||
"show_fewer_tags": "Afișează mai puține",
|
||||
"folders": "Dosare",
|
||||
"show_more": "Mai multe",
|
||||
"show_fewer": "Mai puține",
|
||||
"shared": "Partajat",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navigare",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Răspuns",
|
||||
"forwarded": "Redirecționat",
|
||||
"has_attachment": "Cu atașament",
|
||||
"conversations_range": "{from}–{to, number} din {total, number}",
|
||||
"conversations_count": "{count} din conversațiile de pe {total}",
|
||||
"conversations_count_plus": "{count}+ conversations",
|
||||
"conversations_count_simple": "{count} conversații",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimizează",
|
||||
"maximize": "Ecran complet",
|
||||
"restore": "Restabilește",
|
||||
"read_receipt_on": "Solicitare confirmare de citire (faceți clic pentru a dezactiva)",
|
||||
"read_receipt_off": "Solicitați o confirmare de citire",
|
||||
"dsn_on": "Notificare de livrare solicitată (clic pentru a dezactiva)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "Regulat",
|
||||
"comfortable": "Confortabil"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Aspectul interfeței",
|
||||
"description": "Unde se află căutarea, navigarea și barele de instrumente",
|
||||
"default": "Implicit",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Activați animațiile",
|
||||
"description": "Prezentați tranziții și efecte fluide"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Următorul e-mail",
|
||||
"previous_email": "E-mailul anterior",
|
||||
"open_email": "Deschide e-mailul",
|
||||
"close_email": "Închide / Deselectează"
|
||||
"close_email": "Închide / Deselectează",
|
||||
"go_to_folder": "Mergi la dosar"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Răspunde",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Caută în tot"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Extindeți/reduceți firul de discuție"
|
||||
"expand_collapse": "Extindeți/reduceți firul de discuție",
|
||||
"select_mountain_view": "Selectează conversația"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Trimite e-mail",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Показать все ({count})",
|
||||
"show_fewer_tags": "Показать меньше",
|
||||
"folders": "Папки",
|
||||
"show_more": "Ещё",
|
||||
"show_fewer": "Свернуть",
|
||||
"mail": "Почта",
|
||||
"nav_label": "Навигация",
|
||||
"add_app": "Приложения",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Отвечено",
|
||||
"forwarded": "Переслано",
|
||||
"has_attachment": "С вложением",
|
||||
"conversations_range": "{from}–{to, number} из {total, number}",
|
||||
"conversations_count": "{count} из {total} бесед",
|
||||
"conversations_count_plus": "{count}+ бесед",
|
||||
"conversations_count_simple": "{count} бесед",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Свернуть",
|
||||
"maximize": "Во весь экран",
|
||||
"restore": "Восстановить",
|
||||
"read_receipt_on": "Запрошено уведомление о прочтении (нажмите, чтобы отключить)",
|
||||
"read_receipt_off": "Запросить уведомление о прочтении",
|
||||
"dsn_on": "Запрошено уведомление о доставке (нажмите, чтобы отключить)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Обычный",
|
||||
"comfortable": "Просторный"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Макет интерфейса",
|
||||
"description": "Где находятся поиск, навигация и панели инструментов",
|
||||
"default": "По умолчанию",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Включить анимации",
|
||||
"description": "Показывать плавные переходы и эффекты"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Следующее письмо",
|
||||
"previous_email": "Предыдущее письмо",
|
||||
"open_email": "Открыть письмо",
|
||||
"close_email": "Закрыть / Отменить выбор"
|
||||
"close_email": "Закрыть / Отменить выбор",
|
||||
"go_to_folder": "Перейти к папке"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Ответить",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Искать везде"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Развернуть/свернуть цепочку"
|
||||
"expand_collapse": "Развернуть/свернуть цепочку",
|
||||
"select_mountain_view": "Выбрать переписку"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Отправить письмо",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Zobraziť všetko ({count})",
|
||||
"show_fewer_tags": "Zobraziť menej",
|
||||
"folders": "Priečinky",
|
||||
"show_more": "Viac",
|
||||
"show_fewer": "Menej",
|
||||
"shared": "Zdieľané",
|
||||
"mail": "Pošta",
|
||||
"nav_label": "Navigácia",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Zodpovedané",
|
||||
"forwarded": "Preposlané",
|
||||
"has_attachment": "Obsahuje prílohu",
|
||||
"conversations_range": "{from}–{to, number} z {total, number}",
|
||||
"conversations_count": "{count} z {total} konverzácií",
|
||||
"conversations_count_plus": "{count}+ konverzácií",
|
||||
"conversations_count_simple": "{count} konverzácií",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimalizovať",
|
||||
"maximize": "Celá obrazovka",
|
||||
"restore": "Obnoviť",
|
||||
"read_receipt_on": "Potvrdenie o prečítaní požiadané (kliknutím vypnete)",
|
||||
"read_receipt_off": "Požiadať o potvrdenie o prečítaní",
|
||||
"dsn_on": "Oznámenie o doručení vyžiadané (kliknutím vypnete)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "Bežné",
|
||||
"comfortable": "Pohodlné"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Rozloženie rozhrania",
|
||||
"description": "Kde sa nachádza hľadanie, navigácia a panely nástrojov",
|
||||
"default": "Predvolené",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Povoliť animácie",
|
||||
"description": "Zobrazovať plynulé prechody a efekty"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Ďalší e-mail",
|
||||
"previous_email": "Predchádzajúci e-mail",
|
||||
"open_email": "Otvoriť e-mail",
|
||||
"close_email": "Zavrieť / Zrušiť výber"
|
||||
"close_email": "Zavrieť / Zrušiť výber",
|
||||
"go_to_folder": "Prejsť do priečinka"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Odpovedať",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Hľadať všade"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Rozbaliť/zbaliť vlákno"
|
||||
"expand_collapse": "Rozbaliť/zbaliť vlákno",
|
||||
"select_mountain_view": "Vybrať konverzáciu"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Odoslať e-mail",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Tümünü göster ({count})",
|
||||
"show_fewer_tags": "Daha az göster",
|
||||
"folders": "Klasörler",
|
||||
"show_more": "Diğer",
|
||||
"show_fewer": "Daha az",
|
||||
"shared": "Paylaşılan",
|
||||
"mail": "Posta",
|
||||
"nav_label": "Gezinme",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Yanıtlandı",
|
||||
"forwarded": "İletildi",
|
||||
"has_attachment": "Ek içeriyor",
|
||||
"conversations_range": "{total, number} içinde {from}–{to, number}",
|
||||
"conversations_count": "{total} konuşmadan {count} tanesi",
|
||||
"conversations_count_plus": "{count}+ konuşma",
|
||||
"conversations_count_simple": "{count} konuşma",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Simge durumuna küçült",
|
||||
"maximize": "Tam ekran",
|
||||
"restore": "Geri yükle",
|
||||
"read_receipt_on": "Okundu bilgisi istendi (devre dışı bırakmak için tıklayın)",
|
||||
"read_receipt_off": "Okundu bilgisi iste",
|
||||
"dsn_on": "Teslimat bildirimi istendi (kapatmak için tıklayın)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Normal",
|
||||
"comfortable": "Rahat"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Arayüz düzeni",
|
||||
"description": "Arama, gezinme ve araç çubuklarının konumu",
|
||||
"default": "Varsayılan",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Animasyonları Etkinleştir",
|
||||
"description": "Akıcı geçişler ve efektler göster"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Sonraki e-posta",
|
||||
"previous_email": "Önceki e-posta",
|
||||
"open_email": "E-postayı aç",
|
||||
"close_email": "Kapat / Seçimi kaldır"
|
||||
"close_email": "Kapat / Seçimi kaldır",
|
||||
"go_to_folder": "Klasöre git"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Yanıtla",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Her yerde ara"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "İleti dizisini genişlet/daralt"
|
||||
"expand_collapse": "İleti dizisini genişlet/daralt",
|
||||
"select_mountain_view": "Konuşmayı seç"
|
||||
},
|
||||
"composer": {
|
||||
"send": "E-posta gönder",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "Показати всі ({count})",
|
||||
"show_fewer_tags": "Показати менше",
|
||||
"folders": "Папки",
|
||||
"show_more": "Більше",
|
||||
"show_fewer": "Менше",
|
||||
"mail": "Пошта",
|
||||
"nav_label": "Навігація",
|
||||
"add_app": "програми",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "Відповіли",
|
||||
"forwarded": "Переслано",
|
||||
"has_attachment": "З вкладенням",
|
||||
"conversations_range": "{from}–{to, number} з {total, number}",
|
||||
"conversations_count": "{count} з {total} розмов",
|
||||
"conversations_count_plus": "{count}+ розмов",
|
||||
"conversations_count_simple": "{count} розмов",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Згорнути",
|
||||
"maximize": "На весь екран",
|
||||
"restore": "Відновити",
|
||||
"read_receipt_on": "Запитано сповіщення про прочитання (натисніть, щоб вимкнути)",
|
||||
"read_receipt_off": "Запитати сповіщення про прочитання",
|
||||
"dsn_on": "Запитано сповіщення про доставку (натисніть, щоб вимкнути)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "Регулярний",
|
||||
"comfortable": "Комфортно"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Макет інтерфейсу",
|
||||
"description": "Де розташовані пошук, навігація та панелі інструментів",
|
||||
"default": "За замовчуванням",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Увімкнути анімацію",
|
||||
"description": "Показувати плавні переходи та ефекти"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "Наступний електронний лист",
|
||||
"previous_email": "Попередній електронний лист",
|
||||
"open_email": "Відкрийте електронну пошту",
|
||||
"close_email": "Закрити / Скасувати вибір"
|
||||
"close_email": "Закрити / Скасувати вибір",
|
||||
"go_to_folder": "Перейти до теки"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Відповісти",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "Шукати скрізь"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Розгорнути/згорнути ланцюжок"
|
||||
"expand_collapse": "Розгорнути/згорнути ланцюжок",
|
||||
"select_mountain_view": "Вибрати листування"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Надіслати лист",
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "顯示全部({count})",
|
||||
"show_fewer_tags": "顯示較少",
|
||||
"folders": "信件匣",
|
||||
"show_more": "更多",
|
||||
"show_fewer": "收合",
|
||||
"shared": "共用",
|
||||
"mail": "郵件",
|
||||
"nav_label": "導覽",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "已回覆",
|
||||
"forwarded": "已轉寄",
|
||||
"has_attachment": "有附件",
|
||||
"conversations_range": "第 {from}–{to, number} 封,共 {total, number} 封",
|
||||
"conversations_count": "顯示 {count} 個對話串,共 {total} 個",
|
||||
"conversations_count_plus": "{count}+ 個對話串",
|
||||
"conversations_count_simple": "{count} 個對話串",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "最小化",
|
||||
"maximize": "全螢幕",
|
||||
"restore": "還原",
|
||||
"read_receipt_on": "已要求傳送已讀回執(點擊以停用)",
|
||||
"read_receipt_off": "請求已讀回執",
|
||||
"dsn_on": "已要求遞送通知(點擊停用)",
|
||||
@@ -1071,6 +1077,12 @@
|
||||
"regular": "預設",
|
||||
"comfortable": "寬鬆"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "介面版面配置",
|
||||
"description": "搜尋、導覽與工具列的位置",
|
||||
"default": "預設",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "啟用動畫",
|
||||
"description": "顯示平順的轉場與效果"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "下一封郵件",
|
||||
"previous_email": "上一封郵件",
|
||||
"open_email": "開啟郵件",
|
||||
"close_email": "關閉/取消選取"
|
||||
"close_email": "關閉/取消選取",
|
||||
"go_to_folder": "跳至資料夾"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "回覆",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "全域搜尋"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "展開/收合對話串"
|
||||
"expand_collapse": "展開/收合對話串",
|
||||
"select_mountain_view": "選取會話"
|
||||
},
|
||||
"composer": {
|
||||
"send": "傳送郵件",
|
||||
|
||||
+16
-2
@@ -142,6 +142,8 @@
|
||||
"show_all_tags": "显示全部({count})",
|
||||
"show_fewer_tags": "收起",
|
||||
"folders": "文件夹",
|
||||
"show_more": "更多",
|
||||
"show_fewer": "收起",
|
||||
"mail": "邮件",
|
||||
"nav_label": "导航",
|
||||
"add_app": "应用",
|
||||
@@ -237,6 +239,7 @@
|
||||
"replied": "已回复",
|
||||
"forwarded": "已转发",
|
||||
"has_attachment": "有附件",
|
||||
"conversations_range": "第 {from}–{to, number} 个,共 {total, number} 个",
|
||||
"conversations_count": "共 {total} 个会话,当前显示 {count} 个",
|
||||
"conversations_count_plus": "{count}+ 个会话",
|
||||
"conversations_count_simple": "{count} 个会话",
|
||||
@@ -597,6 +600,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "最小化",
|
||||
"maximize": "全屏",
|
||||
"restore": "还原",
|
||||
"read_receipt_on": "已请求已读回执(点击以关闭)",
|
||||
"read_receipt_off": "请求已读回执",
|
||||
"dsn_on": "已请求投递通知(点击禁用)",
|
||||
@@ -1068,6 +1074,12 @@
|
||||
"regular": "默认",
|
||||
"comfortable": "宽松"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "界面布局",
|
||||
"description": "搜索、导航和工具栏的位置",
|
||||
"default": "默认",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "启用动画",
|
||||
"description": "显示平滑的过渡和效果"
|
||||
@@ -2444,7 +2456,8 @@
|
||||
"next_email": "下一封邮件",
|
||||
"previous_email": "上一封邮件",
|
||||
"open_email": "打开邮件",
|
||||
"close_email": "关闭/取消选择"
|
||||
"close_email": "关闭/取消选择",
|
||||
"go_to_folder": "跳转到文件夹"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "回复",
|
||||
@@ -2466,7 +2479,8 @@
|
||||
"global_search": "全局搜索"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "展开/折叠会话"
|
||||
"expand_collapse": "展开/折叠会话",
|
||||
"select_mountain_view": "选择会话"
|
||||
},
|
||||
"composer": {
|
||||
"send": "发送邮件",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Smoke test for Mountain View, run against a live instance.
|
||||
*
|
||||
* The integration suite (integration/) needs Docker for its Stalwart stack;
|
||||
* this one only needs a running webmail and one mailbox with some mail in it:
|
||||
*
|
||||
* MV_URL=http://127.0.0.1:3000 MV_USER=me@example.com MV_PASS=... \
|
||||
* node scripts/mountain-view-smoke/mountain-view-smoke.mjs
|
||||
*
|
||||
* MV_SERVER optionally picks the login form's server entry by id. The
|
||||
* password is read from the environment only and never printed. It turns
|
||||
* Mountain View on in a fresh browser profile, then checks the layout's own
|
||||
* structure - not the mail in the box - on a desktop and a phone viewport.
|
||||
* It exits non-zero if any check failed.
|
||||
*/
|
||||
/* global document */
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const url = (process.env.MV_URL || '').replace(/\/+$/, '');
|
||||
const user = process.env.MV_USER;
|
||||
const pass = process.env.MV_PASS;
|
||||
if (!url || !user || !pass) {
|
||||
console.error('Set MV_URL, MV_USER and MV_PASS.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const check = (name, ok, detail = '') => {
|
||||
results.push({ name, ok });
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` - ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
async function signIn(context) {
|
||||
// The layout is a stored setting: put it in place before the app reads its
|
||||
// storage. The version matches the settings store's, so nothing migrates it.
|
||||
await context.addInitScript(() => {
|
||||
if (!localStorage.getItem('settings-storage')) {
|
||||
localStorage.setItem('settings-storage', JSON.stringify({ state: { interfaceLayout: 'mountain-view' }, version: 7 }));
|
||||
}
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto(`${url}/en/login`, { waitUntil: 'networkidle' });
|
||||
if (process.env.MV_SERVER && await page.locator('#jmap-server-select').count()) {
|
||||
await page.selectOption('#jmap-server-select', process.env.MV_SERVER);
|
||||
}
|
||||
await page.fill('input[type=email], input[name=username], #username', user);
|
||||
await page.fill('input[type=password]', pass);
|
||||
await page.click('button[type=submit]');
|
||||
await page.waitForURL(/\/mail/, { timeout: 60_000 });
|
||||
await page.locator('[data-email-id]').first().waitFor({ timeout: 60_000 });
|
||||
return { page, errors };
|
||||
}
|
||||
|
||||
async function desktop(browser) {
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const { page, errors } = await signIn(context);
|
||||
|
||||
check('desktop: Mountain View is applied', await page.evaluate(() => document.documentElement.dataset.interfaceLayout === 'mountain-view'));
|
||||
check('desktop: Starred sits right under the Inbox', await page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll('[data-folder-name], [data-folder-role]')]
|
||||
.map((r) => r.getAttribute('data-folder-name') || r.getAttribute('data-folder-role'));
|
||||
const inbox = rows.indexOf('inbox') >= 0 ? rows.indexOf('inbox') : rows.findIndex((r) => /inbox/i.test(r || ''));
|
||||
return inbox >= 0 && rows[inbox + 1] === 'mv-starred';
|
||||
}));
|
||||
const range = await page.getByText(/^\s*1–\d/).first().textContent().catch(() => null);
|
||||
check('desktop: list toolbar shows a 1–N range', !!range && /1–\d/.test(range), range?.trim() ?? '');
|
||||
check('desktop: search options button sits inside the field', await page.evaluate(() => {
|
||||
const field = document.querySelector('.mv-topbar input[data-search-input]');
|
||||
const btn = document.querySelector('.mv-topbar-search > button');
|
||||
if (!field || !btn) return false;
|
||||
const f = field.getBoundingClientRect();
|
||||
const b = btn.getBoundingClientRect();
|
||||
return b.left >= f.left && b.right <= f.right + 1;
|
||||
}));
|
||||
|
||||
await page.locator('[data-email-id]').first().click();
|
||||
await page.locator('[data-mv-sender-date]').first().waitFor({ timeout: 30_000 }).catch(() => {});
|
||||
check('desktop: message date sits on the sender line', await page.locator('[data-mv-sender-date]').count() > 0);
|
||||
await page.locator('[data-mv-reply-row]').waitFor({ timeout: 30_000 }).catch(() => {});
|
||||
check('desktop: Reply / Reply all / Forward under the message', await page.locator('[data-mv-reply-row] button').count() === 3);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.locator('[data-mv-compose]').first().click();
|
||||
const win = page.locator('[data-mv-compose-window]');
|
||||
await win.waitFor({ timeout: 20_000 }).catch(() => {});
|
||||
check('desktop: compose opens a docked window', await win.count() === 1);
|
||||
if (await win.count()) {
|
||||
const box = await win.boundingBox();
|
||||
check('desktop: compose window is docked bottom-right', !!box && box.x > 600 && box.y + box.height >= 899 - 2);
|
||||
await win.locator('button[aria-label]').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
check('desktop: compose window folds to its title bar', (await win.getAttribute('data-state')) === 'minimized');
|
||||
}
|
||||
check('desktop: no page errors', errors.length === 0, errors[0] ?? '');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function phone(browser) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 412, height: 915 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36',
|
||||
});
|
||||
const { page, errors } = await signIn(context);
|
||||
check('phone: rows are Mountain View phone rows', await page.locator('[data-mv-mobile-row]').count() > 0);
|
||||
check('phone: one star per row, on the preview line', await page.evaluate(() => {
|
||||
const row = document.querySelector('[data-mv-mobile-row]');
|
||||
if (!row) return false;
|
||||
const stars = row.querySelectorAll('button[aria-pressed]');
|
||||
return stars.length === 1 && !!stars[0].parentElement?.querySelector('p');
|
||||
}));
|
||||
await page.locator('[data-email-id]').first().click();
|
||||
await page.locator('[data-mv-reply-row]').waitFor({ timeout: 30_000 }).catch(() => {});
|
||||
check('phone: the message ends with Reply / Reply all / Forward', await page.locator('[data-mv-reply-row] button').count() === 3);
|
||||
check('phone: no bottom bar over the message', await page.locator('nav.fixed.bottom-0').count() === 0);
|
||||
check('phone: no page errors', errors.length === 0, errors[0] ?? '');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
await desktop(browser);
|
||||
await phone(browser);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
console.log(`\n${results.length - failed}/${results.length} checks passed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { densityVarsFor, type Density, type InterfaceLayout } from '@/stores/settings-store';
|
||||
|
||||
// Mountain View has three row rhythms of its own and the app has four
|
||||
// densities. The four are laid over its scale rather than left meaning
|
||||
// whatever they meant in the default layout.
|
||||
|
||||
const px = (value: string) => Number.parseInt(value, 10);
|
||||
const ROW = (density: Density, layout: InterfaceLayout) =>
|
||||
px(densityVarsFor(density, layout)['--list-item-height']);
|
||||
|
||||
describe('densityVarsFor', () => {
|
||||
it('lands the three shared names on the 32 / 40 / 48 pixel rows', () => {
|
||||
expect(ROW('compact', 'mountain-view')).toBe(32);
|
||||
expect(ROW('regular', 'mountain-view')).toBe(40);
|
||||
expect(ROW('comfortable', 'mountain-view')).toBe(48);
|
||||
});
|
||||
|
||||
it('continues the series below compact rather than dropping a choice', () => {
|
||||
expect(ROW('extra-compact', 'mountain-view')).toBeLessThan(ROW('compact', 'mountain-view'));
|
||||
});
|
||||
|
||||
it('keeps every step distinct, so all four settings still do something', () => {
|
||||
const heights = (['extra-compact', 'compact', 'regular', 'comfortable'] as Density[])
|
||||
.map((density) => ROW(density, 'mountain-view'));
|
||||
expect(new Set(heights).size).toBe(4);
|
||||
expect([...heights]).toEqual([...heights].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it('leaves the default layout exactly as it was', () => {
|
||||
expect(densityVarsFor('regular', 'default')['--list-item-height']).toBe('48px');
|
||||
expect(densityVarsFor('compact', 'default')['--list-item-height']).toBe('auto');
|
||||
expect(densityVarsFor('extra-compact', 'default')['--density-item-py']).toBe('2px');
|
||||
});
|
||||
|
||||
it('restates only the vertical rhythm, not the rest of the spacing', () => {
|
||||
const mountainView = densityVarsFor('regular', 'mountain-view');
|
||||
const standard = densityVarsFor('regular', 'default');
|
||||
expect(mountainView['--density-card-p']).toBe(standard['--density-card-p']);
|
||||
expect(mountainView['--density-sidebar-py']).toBe(standard['--density-sidebar-py']);
|
||||
expect(mountainView['--density-item-py']).not.toBe(standard['--density-item-py']);
|
||||
});
|
||||
});
|
||||
+116
-44
@@ -97,6 +97,13 @@ async function syncSettingsJob(job: SettingsSyncJob, retries = 1): Promise<void>
|
||||
|
||||
export type FontSize = 'small' | 'medium' | 'large';
|
||||
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
/**
|
||||
* Which interface layout drives the shell's geometry. `default` is the app's
|
||||
* own layout; `mountain-view` is an optional alternative arrangement of the
|
||||
* chrome (search-first top bar, flat 256px navigation rail, pill Compose,
|
||||
* docked compose window) that keeps the theme's icons, type and palette.
|
||||
*/
|
||||
export type InterfaceLayout = 'default' | 'mountain-view';
|
||||
/** @deprecated Use Density instead */
|
||||
export type ListDensity = Density;
|
||||
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
|
||||
@@ -125,6 +132,23 @@ export type ToolbarPosition = 'top' | 'below-subject';
|
||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||
export type MailLayout = 'split' | 'focus' | 'horizontal';
|
||||
|
||||
/**
|
||||
* The mail layout the mail view actually uses.
|
||||
*
|
||||
* Mountain View shows the list at full width and opens a conversation in its
|
||||
* place, which is the `focus` layout. Rather than writing `focus` into the
|
||||
* user's saved `mailLayout`, the layout in force is derived: `focus` under
|
||||
* Mountain View, the saved value otherwise. Switching back to the default
|
||||
* layout therefore restores whatever the user had chosen.
|
||||
*
|
||||
* Read the layout through this (or `useEffectiveMailLayout`) wherever it
|
||||
* decides what is drawn; read `mailLayout` itself only where the saved
|
||||
* preference is being shown or edited.
|
||||
*/
|
||||
export function effectiveMailLayout(mailLayout: MailLayout, interfaceLayout: InterfaceLayout): MailLayout {
|
||||
return interfaceLayout === 'mountain-view' ? 'focus' : mailLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spacing around a message body in the reader.
|
||||
* - 'auto' : add a gutter unless the email paints its own full-bleed background
|
||||
@@ -304,6 +328,7 @@ interface SettingsState {
|
||||
// Appearance
|
||||
fontSize: FontSize;
|
||||
density: Density;
|
||||
interfaceLayout: InterfaceLayout;
|
||||
animationsEnabled: boolean;
|
||||
// Message-list ordering (#718): prioritised sort levels mapped onto the JMAP
|
||||
// Email/query sort; empty = chronological. Scope: Inbox only or every folder.
|
||||
@@ -564,6 +589,7 @@ const DEFAULT_SETTINGS = {
|
||||
// Appearance
|
||||
fontSize: 'medium' as FontSize,
|
||||
density: 'regular' as Density,
|
||||
interfaceLayout: 'default' as InterfaceLayout,
|
||||
animationsEnabled: true,
|
||||
messageListOrder: [] as SortLevel[],
|
||||
messageListOrderScope: 'inbox' as MessageListOrderScope,
|
||||
@@ -785,7 +811,13 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
|
||||
// Apply density to document root
|
||||
if (key === 'density') {
|
||||
applyDensity(value as Density);
|
||||
applyDensity(value as Density, get().interfaceLayout);
|
||||
}
|
||||
|
||||
// Swap the shell geometry. Mountain View's list-then-conversation
|
||||
// layout is not written into `mailLayout`: see effectiveMailLayout().
|
||||
if (key === 'interfaceLayout') {
|
||||
applyInterfaceLayout(value as InterfaceLayout, get().density);
|
||||
}
|
||||
|
||||
// Apply animations to document root
|
||||
@@ -797,7 +829,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
resetToDefaults: () => {
|
||||
set(DEFAULT_SETTINGS);
|
||||
applyFontSize(DEFAULT_SETTINGS.fontSize);
|
||||
applyDensity(DEFAULT_SETTINGS.density);
|
||||
applyDensity(DEFAULT_SETTINGS.density, DEFAULT_SETTINGS.interfaceLayout);
|
||||
applyInterfaceLayout(DEFAULT_SETTINGS.interfaceLayout, DEFAULT_SETTINGS.density);
|
||||
applyAnimations(DEFAULT_SETTINGS.animationsEnabled);
|
||||
},
|
||||
|
||||
@@ -807,6 +840,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
const settings = {
|
||||
fontSize: state.fontSize,
|
||||
density: state.density,
|
||||
interfaceLayout: state.interfaceLayout,
|
||||
animationsEnabled: state.animationsEnabled,
|
||||
messageListOrder: state.messageListOrder,
|
||||
messageListOrderScope: state.messageListOrderScope,
|
||||
@@ -1004,7 +1038,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
|
||||
// Apply visual settings
|
||||
applyFontSize(get().fontSize);
|
||||
applyDensity(get().density);
|
||||
applyDensity(get().density, get().interfaceLayout);
|
||||
applyInterfaceLayout(get().interfaceLayout, get().density);
|
||||
applyAnimations(get().animationsEnabled);
|
||||
|
||||
// Apply cross-store settings
|
||||
@@ -1234,7 +1269,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
state.messageListOrderScope = 'inbox';
|
||||
}
|
||||
applyFontSize(state.fontSize);
|
||||
applyDensity(state.density);
|
||||
applyDensity(state.density, state.interfaceLayout);
|
||||
applyInterfaceLayout(state.interfaceLayout, state.density);
|
||||
applyAnimations(state.animationsEnabled);
|
||||
}
|
||||
};
|
||||
@@ -1322,52 +1358,87 @@ function applyFontSize(size: FontSize) {
|
||||
root.style.setProperty('--font-size-base', sizeMap[size]);
|
||||
}
|
||||
|
||||
function applyDensity(density: Density) {
|
||||
type DensityVars = Record<string, string>;
|
||||
|
||||
const DEFAULT_DENSITY: Record<Density, DensityVars> = {
|
||||
'extra-compact': {
|
||||
'--list-item-height': 'auto',
|
||||
'--density-item-py': '2px',
|
||||
'--density-item-gap': '6px',
|
||||
'--density-header-py': '4px',
|
||||
'--density-card-p': '8px',
|
||||
'--density-sidebar-py': '0px',
|
||||
},
|
||||
compact: {
|
||||
'--list-item-height': 'auto',
|
||||
'--density-item-py': '4px',
|
||||
'--density-item-gap': '8px',
|
||||
'--density-header-py': '6px',
|
||||
'--density-card-p': '10px',
|
||||
'--density-sidebar-py': '1px',
|
||||
},
|
||||
regular: {
|
||||
'--list-item-height': '48px',
|
||||
'--density-item-py': '12px',
|
||||
'--density-item-gap': '12px',
|
||||
'--density-header-py': '12px',
|
||||
'--density-card-p': '16px',
|
||||
'--density-sidebar-py': '4px',
|
||||
},
|
||||
comfortable: {
|
||||
'--list-item-height': '64px',
|
||||
'--density-item-py': '16px',
|
||||
'--density-item-gap': '16px',
|
||||
'--density-header-py': '16px',
|
||||
'--density-card-p': '20px',
|
||||
'--density-sidebar-py': '6px',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mountain View's rows come in three rhythms - 32, 40 and 48 pixels - and the
|
||||
* app offers four densities. The four are laid over that scale: `compact`,
|
||||
* `regular` and `comfortable` land on the three exactly, and `extra-compact`
|
||||
* continues the series a step below, so every density still does something.
|
||||
*
|
||||
* Only the vertical rhythm is restated. Everything else a density touches -
|
||||
* gaps, card padding, the sidebar's own row padding - keeps its default value;
|
||||
* a navigation row is already pinned to 32px by the layout's own token.
|
||||
*/
|
||||
const MOUNTAIN_VIEW_DENSITY: Record<Density, DensityVars> = {
|
||||
'extra-compact': { ...DEFAULT_DENSITY['extra-compact'], '--list-item-height': '28px', '--density-item-py': '4px' },
|
||||
compact: { ...DEFAULT_DENSITY.compact, '--list-item-height': '32px', '--density-item-py': '6px' },
|
||||
regular: { ...DEFAULT_DENSITY.regular, '--list-item-height': '40px', '--density-item-py': '10px' },
|
||||
comfortable: { ...DEFAULT_DENSITY.comfortable, '--list-item-height': '48px', '--density-item-py': '14px' },
|
||||
};
|
||||
|
||||
export function densityVarsFor(density: Density, layout: InterfaceLayout): DensityVars {
|
||||
return (layout === 'mountain-view' ? MOUNTAIN_VIEW_DENSITY : DEFAULT_DENSITY)[density];
|
||||
}
|
||||
|
||||
function applyDensity(density: Density, layout: InterfaceLayout) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
const densityValues = {
|
||||
'extra-compact': {
|
||||
'--list-item-height': 'auto',
|
||||
'--density-item-py': '2px',
|
||||
'--density-item-gap': '6px',
|
||||
'--density-header-py': '4px',
|
||||
'--density-card-p': '8px',
|
||||
'--density-sidebar-py': '0px',
|
||||
},
|
||||
compact: {
|
||||
'--list-item-height': 'auto',
|
||||
'--density-item-py': '4px',
|
||||
'--density-item-gap': '8px',
|
||||
'--density-header-py': '6px',
|
||||
'--density-card-p': '10px',
|
||||
'--density-sidebar-py': '1px',
|
||||
},
|
||||
regular: {
|
||||
'--list-item-height': '48px',
|
||||
'--density-item-py': '12px',
|
||||
'--density-item-gap': '12px',
|
||||
'--density-header-py': '12px',
|
||||
'--density-card-p': '16px',
|
||||
'--density-sidebar-py': '4px',
|
||||
},
|
||||
comfortable: {
|
||||
'--list-item-height': '64px',
|
||||
'--density-item-py': '16px',
|
||||
'--density-item-gap': '16px',
|
||||
'--density-header-py': '16px',
|
||||
'--density-card-p': '20px',
|
||||
'--density-sidebar-py': '6px',
|
||||
},
|
||||
};
|
||||
|
||||
const values = densityValues[density];
|
||||
for (const [prop, val] of Object.entries(values)) {
|
||||
for (const [prop, val] of Object.entries(densityVarsFor(density, layout))) {
|
||||
root.style.setProperty(prop, val);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The interface layout is a single attribute on <html>; every geometry
|
||||
* override lives in the `[data-interface-layout="mountain-view"]` block in
|
||||
* globals.css, so nothing has to re-render for the shell to change shape.
|
||||
*/
|
||||
function applyInterfaceLayout(layout: InterfaceLayout, density: Density) {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.dataset.interfaceLayout = layout;
|
||||
// The row rhythm depends on the layout as much as on the density, and it is
|
||||
// written as an inline custom property, which no stylesheet rule could
|
||||
// override. Re-applying it here is what keeps the two in step.
|
||||
applyDensity(density, layout);
|
||||
}
|
||||
|
||||
function applyAnimations(enabled: boolean) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
@@ -1383,7 +1454,8 @@ function applyAnimations(enabled: boolean) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const store = useSettingsStore.getState();
|
||||
applyFontSize(store.fontSize);
|
||||
applyDensity(store.density);
|
||||
applyDensity(store.density, store.interfaceLayout);
|
||||
applyInterfaceLayout(store.interfaceLayout, store.density);
|
||||
applyAnimations(store.animationsEnabled);
|
||||
|
||||
const triggerSync = () => {
|
||||
|
||||
Reference in New Issue
Block a user