mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge pull request #1168 from lucamzanon/feat/mountain-view-layout
feat: add Mountain View, an optional alternative layout
This commit is contained in:
@@ -44,6 +44,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
@@ -948,3 +948,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ import {
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { findMeetingLink } from "@/lib/event-links";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
|
||||
type PendingScopeAction =
|
||||
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
|
||||
@@ -119,6 +121,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = useIsDesktop();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// Mountain View's desktop chrome: its top bar in place of the left rail.
|
||||
const mvShell = useMountainViewShell(isEmbedded);
|
||||
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
|
||||
// collapses into a burger-toggled overlay instead of taking inline space.
|
||||
const isNarrow = !isDesktop;
|
||||
@@ -1571,9 +1575,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{refreshIndicator}
|
||||
{mvShell && (
|
||||
<MvTopBar
|
||||
showMenuButton={false}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
|
||||
{!isMobile && !isEmbedded && (
|
||||
{!isMobile && !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
|
||||
|
||||
@@ -44,6 +44,8 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -125,6 +127,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = useIsDesktop();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// Mountain View's desktop chrome: its top bar in place of the left rail.
|
||||
const mvShell = useMountainViewShell(isEmbedded);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// One-shot intent flag: only consume the URL params on the first render that
|
||||
@@ -899,9 +903,18 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{refreshIndicator}
|
||||
{mvShell && (
|
||||
<MvTopBar
|
||||
showMenuButton={false}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
|
||||
{!isMobile && !isEmbedded && (
|
||||
{!isMobile && !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
|
||||
|
||||
@@ -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.getByLabelText('more_actions'));
|
||||
|
||||
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";
|
||||
@@ -708,8 +709,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);
|
||||
@@ -761,6 +763,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
|
||||
@@ -3057,6 +3068,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
|
||||
@@ -3101,7 +3138,7 @@ export function EmailViewer({
|
||||
<span className="text-sm">{t('edit_draft')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isScheduled && !isDraft && (<>
|
||||
{!isScheduled && !isDraft && !mvViewer && (<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3379,7 +3416,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
|
||||
@@ -4002,8 +4039,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>
|
||||
@@ -4028,8 +4066,10 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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")}>
|
||||
{/* Email Content Area. Mountain View reads the conversation on one plain
|
||||
sheet, so the reply buttons under the message sit on the page colour
|
||||
rather than on a tinted strip. */}
|
||||
<div className={cn("flex-1 overflow-auto overscroll-contain", mvViewer || mvMobileViewer ? "bg-background" : "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) === */}
|
||||
@@ -4064,6 +4104,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
|
||||
@@ -4078,9 +4121,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>
|
||||
@@ -4353,8 +4401,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 */}
|
||||
@@ -4396,9 +4444,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>
|
||||
@@ -5379,8 +5429,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" size="sm" className={cn("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" size="sm" className={cn("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" size="sm" className={cn("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
|
||||
@@ -5527,7 +5598,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/50"
|
||||
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,531 @@
|
||||
"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 tCommon = useTranslations("common");
|
||||
const tViewer = useTranslations("email_viewer");
|
||||
|
||||
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={tCommon("refresh")}
|
||||
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={tViewer("more_actions")}
|
||||
aria-label={tViewer("more_actions")}
|
||||
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}
|
||||
|
||||
@@ -39,6 +39,8 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
|
||||
export interface FilesAppProps {
|
||||
/** Path segments after `/files` - the folder path, one segment per level. */
|
||||
@@ -114,6 +116,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// Mountain View's desktop chrome: its top bar in place of the left rail.
|
||||
const mvShell = useMountainViewShell(isEmbedded);
|
||||
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
||||
// The account the files client was last initialised for (`undefined` = never).
|
||||
// Tracked so a Pro-shell account switch re-initialises instead of showing the
|
||||
@@ -573,8 +577,17 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{refreshIndicator}
|
||||
{mvShell && (
|
||||
<MvTopBar
|
||||
showMenuButton={false}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{!isMobile && !isEmbedded && (
|
||||
{!isMobile && !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
|
||||
|
||||
@@ -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
|
||||
@@ -211,7 +267,9 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
data-testid="account-switcher"
|
||||
data-active-account-id={activeAccountId ?? undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md transition-colors",
|
||||
// Round under Mountain View, like every other button in its top bar.
|
||||
"flex items-center gap-2 transition-colors",
|
||||
mvPopover ? "rounded-full" : "rounded-md",
|
||||
variant !== "expanded"
|
||||
? "justify-center w-10 h-10 hover:bg-muted"
|
||||
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
|
||||
@@ -250,15 +308,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 +405,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 +452,34 @@ 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; with a single login the second is plain "Sign out". */}
|
||||
{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>
|
||||
)}
|
||||
<button
|
||||
onClick={accounts.length > 1 ? handleLogoutAll : handleLogout}
|
||||
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" />
|
||||
{accounts.length > 1 ? t("sign_out_all") : t("sign_out")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator + Add Account */}
|
||||
{accounts.length < getMaxAccounts() && (
|
||||
{!mvPopover && accounts.length < getMaxAccounts() && (
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={handleAddAccount}
|
||||
@@ -363,12 +493,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator + Actions */}
|
||||
<div className="border-t border-border">
|
||||
{/* Separator + Actions. Under Mountain View a single login signs out
|
||||
from the pill above, so these rows are only for picking among several. */}
|
||||
<div className={cn("border-t border-border", mvPopover && accounts.length < 2 && "hidden")}>
|
||||
{activeAccount && !activeAccount.isDefault && accounts.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(activeAccount.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
className={cn("w-full flex items-center gap-2 py-2 text-sm text-foreground hover:bg-muted transition-colors", mvPopover ? "px-4" : "px-3")}
|
||||
role="menuitem"
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
@@ -377,13 +508,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
className={cn("w-full flex items-center gap-2 py-2 text-sm text-foreground hover:bg-muted transition-colors", mvPopover ? "px-4" : "px-3")}
|
||||
role="menuitem"
|
||||
>
|
||||
<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,374 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
BookUser,
|
||||
Calendar,
|
||||
Filter,
|
||||
Grid3x3,
|
||||
HardDrive,
|
||||
Keyboard,
|
||||
Mail,
|
||||
Menu,
|
||||
Settings,
|
||||
Shield,
|
||||
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 { getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { useStalwartAdmin } from "@/hooks/use-stalwart-admin";
|
||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||
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";
|
||||
|
||||
/** Mail search, which only the mail surface puts into the bar. */
|
||||
interface MvTopBarSearch {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSubmit: (query: string) => void;
|
||||
onClear: () => void;
|
||||
onSelectContact: (contact: ContactSuggestion, field: ContactSearchField) => void;
|
||||
/** Opens the advanced-filter panel that lives under the message list. */
|
||||
onToggleFilters: () => void;
|
||||
filtersOpen: boolean;
|
||||
activeFilterCount: number;
|
||||
disabled?: 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;
|
||||
}
|
||||
|
||||
interface MvTopBarProps {
|
||||
/**
|
||||
* The mail search field. Other surfaces leave it out and keep their own
|
||||
* search where it was; the bar then carries the brand, the app grid and the
|
||||
* account avatar only.
|
||||
*/
|
||||
search?: MvTopBarSearch;
|
||||
/** The hamburger folds the mail navigation, so only mail shows it. */
|
||||
showMenuButton?: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'` (see
|
||||
* useMountainViewShell), on every surface, in place of the left rail; 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({
|
||||
search,
|
||||
showMenuButton = true,
|
||||
onShowShortcuts,
|
||||
onManageApps,
|
||||
onInlineApp,
|
||||
onCloseInlineApp,
|
||||
activeAppId,
|
||||
onSelectAllInboxes,
|
||||
allInboxesSelected = false,
|
||||
}: 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"));
|
||||
// The same gate the navigation rail puts on its "add app" button.
|
||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled("sidebarAppsEnabled"));
|
||||
const sidebarApps = useResolvedSidebarApps();
|
||||
const isStalwartAdmin = useStalwartAdmin();
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
const updateSeverity = useUpdateStore((s) => s.status?.severity);
|
||||
const updateImportant = updateSeverity === "security" || updateSeverity === "deprecated";
|
||||
|
||||
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={cn("flex items-center gap-1 shrink-0", !showMenuButton && "ps-3")}>
|
||||
{showMenuButton && (
|
||||
<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 */}
|
||||
{search && (
|
||||
<div className="mv-topbar-search relative flex items-center gap-1 flex-1 min-w-0 max-w-[720px]">
|
||||
<SearchBox
|
||||
value={search.query}
|
||||
onChange={search.onQueryChange}
|
||||
onSubmit={search.onSubmit}
|
||||
onClear={search.onClear}
|
||||
onSelectContact={search.onSelectContact}
|
||||
disabled={search.disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={search.onToggleFilters}
|
||||
disabled={search.disabled}
|
||||
// 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",
|
||||
search.disabled && "opacity-50 cursor-not-allowed",
|
||||
search.filtersOpen || search.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" />
|
||||
{!search.filtersOpen && search.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">
|
||||
{search.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={search.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>
|
||||
)}
|
||||
{/* Admin (Stalwart admins), as in the navigation rail this bar replaces.
|
||||
A hard navigation because /admin lives outside the [locale] tree. */}
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href={`${getPathPrefix()}/admin`}
|
||||
className="relative grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={t("admin")}
|
||||
aria-label={t("admin")}
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
{hasUpdate && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-3 end-3 w-2 h-2 rounded-full ring-2 ring-background",
|
||||
updateImportant ? "bg-red-500" : "bg-amber-500",
|
||||
)}
|
||||
aria-label={updateImportant ? "Important update available" : "Update available"}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
<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>
|
||||
{sidebarAppsEnabled && 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>
|
||||
);
|
||||
}
|
||||
@@ -17,16 +17,15 @@ import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { isDocumentRTL } from "@/i18n/direction";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useStalwartAdmin } from "@/hooks/use-stalwart-admin";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
|
||||
interface NavItem {
|
||||
@@ -213,6 +212,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'));
|
||||
@@ -221,7 +221,7 @@ export function NavigationRail({
|
||||
// removes the user's, so a pinned set still shows when custom apps are off.
|
||||
const visibleSidebarApps = useResolvedSidebarApps();
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
const isStalwartAdmin = useStalwartAdmin();
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
const updateSeverity = useUpdateStore((s) => s.status?.severity);
|
||||
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
|
||||
@@ -292,30 +292,6 @@ export function NavigationRail({
|
||||
};
|
||||
}, [logoutMenuOpen, logoutPopoverRef]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const headers = getActiveAccountSlotHeaders();
|
||||
// No admin console in the static Lite build.
|
||||
if (IS_LITE || !headers['X-JMAP-Cookie-Slot']) return;
|
||||
apiFetch('/api/admin/auth', { headers })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (cancelled || !data.stalwartAdmin) return;
|
||||
setIsStalwartAdmin(true);
|
||||
// Only "auto" mode may mint the admin session here; in "password"
|
||||
// mode the shield leads to /admin/login instead (#870).
|
||||
if (!data.authenticated && data.stalwartAutoLogin === true) {
|
||||
// Pre-create admin session so /admin works even after full page navigation
|
||||
apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ stalwartAuth: true }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||
@@ -338,7 +314,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 +337,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 +363,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>
|
||||
|
||||
+124
-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,49 @@ export function Sidebar({
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
{node.role === 'drafts' && renderScheduledRow('scheduled')}
|
||||
{/* Mountain View lists Starred right under the Inbox. */}
|
||||
{mvFlat && node.role === 'inbox' && onOpenStarred && (
|
||||
<SidebarRow
|
||||
// Drawn like the "All starred" row it stands in for.
|
||||
icon={<Star className={getIconClass(!selectedKeyword && selectedMailbox === CROSS_VIEW_IDS.starred, false, colorfulSidebarIcons)} />}
|
||||
label={t("mailboxes.starred")}
|
||||
testName="mv-starred"
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && 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 +1530,7 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!useMultiAccount && sharedAccounts.length > 0 && (
|
||||
{!useMultiAccount && showSecondary && sharedAccounts.length > 0 && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("shared")}
|
||||
@@ -1482,7 +1589,7 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emailKeywords.length > 0 && (
|
||||
{showSecondary && emailKeywords.length > 0 && (
|
||||
<div data-tour="keyword-tags">
|
||||
<SidebarSectionHeader
|
||||
label={t("tags")}
|
||||
|
||||
+249
-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 t = useTranslations();
|
||||
const tCommon = useTranslations('common');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -1672,6 +1739,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);
|
||||
@@ -1702,7 +1775,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(() => {
|
||||
@@ -3128,6 +3201,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);
|
||||
@@ -3461,7 +3546,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;
|
||||
@@ -3471,6 +3560,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) {
|
||||
@@ -3662,6 +3752,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;
|
||||
}
|
||||
@@ -3684,9 +3855,35 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
<span>{tCommon('reconnecting')}</span>
|
||||
</div>
|
||||
)}
|
||||
{mvShell && !fullscreenReading && (
|
||||
<MvTopBar
|
||||
search={{
|
||||
query: searchQuery,
|
||||
onQueryChange: setSearchQuery,
|
||||
onSubmit: handleSearch,
|
||||
onClear: handleClearSearch,
|
||||
onSelectContact: handleSelectContactSuggestion,
|
||||
onToggleFilters: toggleAdvancedSearch,
|
||||
filtersOpen: isAdvancedSearchOpen,
|
||||
activeFilterCount: activeFilterCount(searchFilters),
|
||||
disabled: isScheduledView,
|
||||
onFilterAnchorChange: setFilterAnchor,
|
||||
}}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
onSelectAllInboxes={
|
||||
allInboxesReachable ? () => handleMailboxSelect(UNIFIED_MAILBOX_IDS.inbox) : undefined
|
||||
}
|
||||
allInboxesSelected={allInboxesSelected}
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
@@ -3760,7 +3957,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
|
||||
@@ -3774,6 +3971,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
crossAccountActive={crossAccountActive}
|
||||
showCrossUnread={showCrossUnread}
|
||||
showCrossStarred={showCrossStarred}
|
||||
onOpenStarred={mvShell ? goToStarred : undefined}
|
||||
showCrossAll={showCrossAll}
|
||||
crossUnreadCount={crossUnreadCount}
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
@@ -3800,6 +3998,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
}}
|
||||
onSidebarClose={() => setSidebarOpen(false)}
|
||||
mvShell={mvShell}
|
||||
multiAccountMode={isEmbedded}
|
||||
accountMailboxes={accountMailboxes}
|
||||
viewingAccountId={viewingAccountId}
|
||||
@@ -3861,6 +4060,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
|
||||
@@ -3887,6 +4090,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 */}
|
||||
@@ -3971,10 +4186,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">
|
||||
@@ -4155,7 +4373,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FilterPanelHost>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -4182,6 +4400,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
hideBatchToolbar={mvShell}
|
||||
emails={activeEmails}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
isLoading={activeIsLoading}
|
||||
@@ -4283,8 +4502,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');
|
||||
@@ -4292,15 +4512,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) */}
|
||||
@@ -4344,84 +4569,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 && (
|
||||
@@ -4622,6 +4770,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);
|
||||
@@ -166,9 +166,28 @@ export function LayoutSettings() {
|
||||
? t('all_mail.account_hint', { account: activeAccount.displayName || activeAccount.email })
|
||||
: null;
|
||||
|
||||
const mountainView = interfaceLayout === 'mountain-view';
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{!isSettingHidden('mailLayout') && (
|
||||
{/* 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>
|
||||
)}
|
||||
{/* Mountain View always shows the list and then the conversation, and
|
||||
its toolbars are icon-only, so these two would change nothing there.
|
||||
Their saved values stay and apply again under the default layout. */}
|
||||
{!isSettingHidden('mailLayout') && !mountainView && (
|
||||
<SettingItem label={tEmail('mail_layout.label')} description={tEmail('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
|
||||
<div className="w-[22rem] max-w-full">
|
||||
<RadioGroup
|
||||
@@ -196,12 +215,14 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{!mountainView && (
|
||||
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showToolbarLabels}
|
||||
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
|
||||
<ToggleSwitch
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -103,6 +103,8 @@ import {
|
||||
} from '@/lib/settings-search';
|
||||
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
|
||||
import { useDocumentTitle } from '@/hooks/use-document-title';
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
|
||||
type Tab = SettingsSearchTab;
|
||||
|
||||
@@ -222,6 +224,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// Mountain View's desktop chrome: its top bar in place of the left rail.
|
||||
const mvShell = useMountainViewShell(isEmbedded);
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
@@ -867,8 +871,17 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{mvShell && (
|
||||
<MvTopBar
|
||||
showMenuButton={false}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{!isEmbedded && (
|
||||
{!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
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
const apiFetch = vi.hoisted(() => vi.fn());
|
||||
const slotHeaders = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/browser-navigation', () => ({ apiFetch }));
|
||||
vi.mock('@/lib/auth/active-account-slot', () => ({ getActiveAccountSlotHeaders: slotHeaders }));
|
||||
|
||||
import { useStalwartAdmin } from '../use-stalwart-admin';
|
||||
|
||||
const answer = (body: Record<string, unknown>) =>
|
||||
new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
describe('useStalwartAdmin', () => {
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset();
|
||||
slotHeaders.mockReturnValue({ 'X-JMAP-Cookie-Slot': '0' });
|
||||
});
|
||||
|
||||
it('is true for a Stalwart admin and mints the session in auto mode', async () => {
|
||||
apiFetch.mockResolvedValueOnce(answer({ stalwartAdmin: true, authenticated: false, stalwartAutoLogin: true }));
|
||||
apiFetch.mockResolvedValueOnce(answer({}));
|
||||
|
||||
const { result } = renderHook(() => useStalwartAdmin());
|
||||
|
||||
await waitFor(() => expect(result.current).toBe(true));
|
||||
expect(apiFetch).toHaveBeenLastCalledWith('/api/admin/auth', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
it('does not mint a session in password mode', async () => {
|
||||
apiFetch.mockResolvedValueOnce(answer({ stalwartAdmin: true, authenticated: false, stalwartAutoLogin: false }));
|
||||
|
||||
const { result } = renderHook(() => useStalwartAdmin());
|
||||
|
||||
await waitFor(() => expect(result.current).toBe(true));
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stays false for other users and without a login slot', async () => {
|
||||
apiFetch.mockResolvedValueOnce(answer({ stalwartAdmin: false }));
|
||||
const { result } = renderHook(() => useStalwartAdmin());
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
apiFetch.mockReset();
|
||||
slotHeaders.mockReturnValue({});
|
||||
const { result: noSlot } = renderHook(() => useStalwartAdmin());
|
||||
expect(noSlot.current).toBe(false);
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
/**
|
||||
* Whether a surface draws Mountain View's desktop chrome: the top bar with the
|
||||
* app grid and the account avatar, in place of the left navigation rail.
|
||||
*
|
||||
* Mail, calendar, contacts, files and settings all ask the same question, so
|
||||
* moving between them under Mountain View keeps the same frame. Phones and
|
||||
* tablets keep their own navigation, and so does a surface embedded in a Pro
|
||||
* pane, whose shell owns the chrome.
|
||||
*/
|
||||
export function useMountainViewShell(isEmbedded: boolean): boolean {
|
||||
const mountainView = useSettingsStore((s) => s.interfaceLayout === "mountain-view");
|
||||
const isDesktop = useIsDesktop();
|
||||
return mountainView && isDesktop && !isEmbedded;
|
||||
}
|
||||
@@ -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,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
|
||||
/**
|
||||
* Whether the signed-in user may open the Stalwart admin console. In "auto"
|
||||
* mode the admin session is minted here as well, so /admin works even after a
|
||||
* full page navigation; in "password" mode the console asks for it (#870).
|
||||
*/
|
||||
export function useStalwartAdmin(): boolean {
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const headers = getActiveAccountSlotHeaders();
|
||||
// No admin console in the static Lite build.
|
||||
if (IS_LITE || !headers['X-JMAP-Cookie-Slot']) return;
|
||||
apiFetch('/api/admin/auth', { headers })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (cancelled || !data.stalwartAdmin) return;
|
||||
setIsStalwartAdmin(true);
|
||||
if (!data.authenticated && data.stalwartAutoLogin === true) {
|
||||
apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ stalwartAuth: true }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return isStalwartAdmin;
|
||||
}
|
||||
@@ -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} محادثة",
|
||||
@@ -606,6 +609,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "تصغير",
|
||||
"maximize": "ملء الشاشة",
|
||||
"restore": "استعادة",
|
||||
"read_receipt_on": "إيصال القراءة مطلوب (انقر للتعطيل)",
|
||||
"read_receipt_off": "طلب إيصال قراءة",
|
||||
"dsn_on": "تم طلب إشعار التسليم (انقر للتعطيل)",
|
||||
@@ -1087,6 +1093,12 @@
|
||||
"regular": "عادي",
|
||||
"comfortable": "مريح"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "تخطيط الواجهة",
|
||||
"description": "موضع البحث والتنقل وأشرطة الأدوات",
|
||||
"default": "افتراضي",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "تفعيل الحركات",
|
||||
"description": "إظهار الانتقالات والتأثيرات السلسة"
|
||||
@@ -2526,7 +2538,8 @@
|
||||
"next_email": "الرسالة التالية",
|
||||
"previous_email": "الرسالة السابقة",
|
||||
"open_email": "فتح الرسالة",
|
||||
"close_email": "إغلاق / إلغاء التحديد"
|
||||
"close_email": "إغلاق / إلغاء التحديد",
|
||||
"go_to_folder": "الانتقال إلى المجلد"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "رد",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1087,6 +1093,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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í",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1084,6 +1090,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1087,6 +1093,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1084,6 +1090,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1087,6 +1093,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1084,6 +1090,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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} مکالمه",
|
||||
@@ -606,6 +609,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "کوچک کردن",
|
||||
"maximize": "تمامصفحه",
|
||||
"restore": "بازگردانی",
|
||||
"read_receipt_on": "درخواست تأیید خواندن فعال (کلیک برای غیرفعال کردن)",
|
||||
"read_receipt_off": "درخواست تأیید خواندن",
|
||||
"dsn_on": "اعلان تحویل درخواست شد (برای غیرفعال کردن کلیک کنید)",
|
||||
@@ -1087,6 +1093,12 @@
|
||||
"regular": "معمولی",
|
||||
"comfortable": "راحت"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "چیدمان رابط",
|
||||
"description": "جای جستوجو، ناوبری و نوارهای ابزار",
|
||||
"default": "پیشفرض",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "فعال کردن انیمیشنها",
|
||||
"description": "نمایش transitions و افکتهای نرم"
|
||||
@@ -2526,7 +2538,8 @@
|
||||
"next_email": "ایمیل بعدی",
|
||||
"previous_email": "ایمیل قبلی",
|
||||
"open_email": "باز کردن ایمیل",
|
||||
"close_email": "بستن / لغو انتخاب"
|
||||
"close_email": "بستن / لغو انتخاب",
|
||||
"go_to_folder": "رفتن به پوشه"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "پاسخ",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1084,6 +1090,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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}",
|
||||
@@ -571,6 +574,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "מזעור",
|
||||
"maximize": "מסך מלא",
|
||||
"restore": "שחזור",
|
||||
"new_message": "הודעה חדשה",
|
||||
"signature_edit_hint": "לחץ לחיצה כפולה לעריכת החתימה",
|
||||
"reply": "השב",
|
||||
@@ -1049,6 +1055,12 @@
|
||||
"regular": "רגיל",
|
||||
"comfortable": "נוח"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "פריסת הממשק",
|
||||
"description": "היכן ממוקמים החיפוש, הניווט וסרגלי הכלים",
|
||||
"default": "ברירת מחדל",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "הפעל אנימציות",
|
||||
"description": "הצג מעברים ואפקטים חלקים"
|
||||
@@ -2450,7 +2462,8 @@
|
||||
"next_email": "המייל הבא",
|
||||
"previous_email": "אימייל קודם",
|
||||
"open_email": "פתח אימייל",
|
||||
"close_email": "סגור / בטל בחירה"
|
||||
"close_email": "סגור / בטל בחירה",
|
||||
"go_to_folder": "מעבר לתיקייה"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "השב",
|
||||
@@ -2472,7 +2485,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1087,6 +1093,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1084,6 +1090,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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}件の会話",
|
||||
@@ -606,6 +609,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "最小化",
|
||||
"maximize": "全画面表示",
|
||||
"restore": "元に戻す",
|
||||
"read_receipt_on": "開封確認を要求中(クリックで無効化)",
|
||||
"read_receipt_off": "開封確認を要求する",
|
||||
"dsn_on": "配信通知を要求中(クリックで解除)",
|
||||
@@ -1084,6 +1090,12 @@
|
||||
"regular": "標準",
|
||||
"comfortable": "ゆったり"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "インターフェースのレイアウト",
|
||||
"description": "検索・ナビゲーション・ツールバーの配置",
|
||||
"default": "デフォルト",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "アニメーションを有効にする",
|
||||
"description": "スムーズな遷移とエフェクトを表示"
|
||||
@@ -2526,7 +2538,8 @@
|
||||
"next_email": "次のメール",
|
||||
"previous_email": "前のメール",
|
||||
"open_email": "メールを開く",
|
||||
"close_email": "閉じる / 選択解除"
|
||||
"close_email": "閉じる / 選択解除",
|
||||
"go_to_folder": "フォルダーに移動"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "返信",
|
||||
@@ -2548,7 +2561,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}개의 대화",
|
||||
@@ -606,6 +609,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "최소화",
|
||||
"maximize": "전체 화면",
|
||||
"restore": "복원",
|
||||
"read_receipt_on": "읽음 확인 요청됨 (클릭하여 해제)",
|
||||
"read_receipt_off": "읽음 확인 요청",
|
||||
"dsn_on": "배달 알림 요청됨 (클릭하여 해제)",
|
||||
@@ -1084,6 +1090,12 @@
|
||||
"regular": "보통",
|
||||
"comfortable": "넓게"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "인터페이스 레이아웃",
|
||||
"description": "검색, 탐색, 도구 모음의 위치",
|
||||
"default": "기본",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "애니메이션 켜기",
|
||||
"description": "부드러운 전환 효과를 사용해요"
|
||||
@@ -2526,7 +2538,8 @@
|
||||
"next_email": "다음 메일",
|
||||
"previous_email": "이전 메일",
|
||||
"open_email": "메일 열기",
|
||||
"close_email": "닫기 / 선택 해제"
|
||||
"close_email": "닫기 / 선택 해제",
|
||||
"go_to_folder": "폴더로 이동"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "답장",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1084,6 +1090,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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} харилцан яриа",
|
||||
@@ -606,6 +609,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Багасгах",
|
||||
"maximize": "Бүтэн дэлгэц",
|
||||
"restore": "Сэргээх",
|
||||
"read_receipt_on": "Уншсан баримт хүссэн (идэвхгүй болгохын тулд дарна уу)",
|
||||
"read_receipt_off": "Уншсан баримт хүсэх",
|
||||
"dsn_on": "Хүргэлтийн мэдэгдэл хүссэн (идэвхгүй болгох бол дарна уу)",
|
||||
@@ -1087,6 +1093,12 @@
|
||||
"regular": "Тогтмол",
|
||||
"comfortable": "Тав тухтай"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Интерфейсийн байрлал",
|
||||
"description": "Хайлт, навигаци, хэрэгслийн мөр хаана байрлах",
|
||||
"default": "Үндсэн",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Анимацийг идэвхжүүлэх",
|
||||
"description": "Гөлгөр шилжилт, эффектийг харуулах"
|
||||
@@ -2526,7 +2538,8 @@
|
||||
"next_email": "Дараагийн имэйл",
|
||||
"previous_email": "Өмнөх имэйл",
|
||||
"open_email": "Имэйл нээх",
|
||||
"close_email": "Хаах / Сонголтыг цуцлах"
|
||||
"close_email": "Хаах / Сонголтыг цуцлах",
|
||||
"go_to_folder": "Хавтас руу очих"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Хариулах",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1087,6 +1093,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1084,6 +1090,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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ć)",
|
||||
@@ -1084,6 +1090,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1084,6 +1090,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1087,6 +1093,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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} бесед",
|
||||
@@ -606,6 +609,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Свернуть",
|
||||
"maximize": "Во весь экран",
|
||||
"restore": "Восстановить",
|
||||
"read_receipt_on": "Запрошено уведомление о прочтении (нажмите, чтобы отключить)",
|
||||
"read_receipt_off": "Запросить уведомление о прочтении",
|
||||
"dsn_on": "Запрошено уведомление о доставке (нажмите, чтобы отключить)",
|
||||
@@ -1084,6 +1090,12 @@
|
||||
"regular": "Обычный",
|
||||
"comfortable": "Просторный"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Макет интерфейса",
|
||||
"description": "Где находятся поиск, навигация и панели инструментов",
|
||||
"default": "По умолчанию",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Включить анимации",
|
||||
"description": "Показывать плавные переходы и эффекты"
|
||||
@@ -2526,7 +2538,8 @@
|
||||
"next_email": "Следующее письмо",
|
||||
"previous_email": "Предыдущее письмо",
|
||||
"open_email": "Открыть письмо",
|
||||
"close_email": "Закрыть / Отменить выбор"
|
||||
"close_email": "Закрыть / Отменить выбор",
|
||||
"go_to_folder": "Перейти к папке"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Ответить",
|
||||
@@ -2548,7 +2561,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í",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1087,6 +1093,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"
|
||||
@@ -2526,7 +2538,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ť",
|
||||
@@ -2548,7 +2561,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",
|
||||
@@ -606,6 +609,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)",
|
||||
@@ -1084,6 +1090,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"
|
||||
@@ -2526,7 +2538,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",
|
||||
@@ -2548,7 +2561,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} розмов",
|
||||
@@ -606,6 +609,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Згорнути",
|
||||
"maximize": "На весь екран",
|
||||
"restore": "Відновити",
|
||||
"read_receipt_on": "Запитано сповіщення про прочитання (натисніть, щоб вимкнути)",
|
||||
"read_receipt_off": "Запитати сповіщення про прочитання",
|
||||
"dsn_on": "Запитано сповіщення про доставку (натисніть, щоб вимкнути)",
|
||||
@@ -1084,6 +1090,12 @@
|
||||
"regular": "Регулярний",
|
||||
"comfortable": "Комфортно"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "Макет інтерфейсу",
|
||||
"description": "Де розташовані пошук, навігація та панелі інструментів",
|
||||
"default": "За замовчуванням",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Увімкнути анімацію",
|
||||
"description": "Показувати плавні переходи та ефекти"
|
||||
@@ -2526,7 +2538,8 @@
|
||||
"next_email": "Наступний електронний лист",
|
||||
"previous_email": "Попередній електронний лист",
|
||||
"open_email": "Відкрийте електронну пошту",
|
||||
"close_email": "Закрити / Скасувати вибір"
|
||||
"close_email": "Закрити / Скасувати вибір",
|
||||
"go_to_folder": "Перейти до теки"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Відповісти",
|
||||
@@ -2548,7 +2561,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} 個對話串",
|
||||
@@ -606,6 +609,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "最小化",
|
||||
"maximize": "全螢幕",
|
||||
"restore": "還原",
|
||||
"read_receipt_on": "已要求傳送已讀回執(點擊以停用)",
|
||||
"read_receipt_off": "請求已讀回執",
|
||||
"dsn_on": "已要求遞送通知(點擊停用)",
|
||||
@@ -1087,6 +1093,12 @@
|
||||
"regular": "預設",
|
||||
"comfortable": "寬鬆"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "介面版面配置",
|
||||
"description": "搜尋、導覽與工具列的位置",
|
||||
"default": "預設",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "啟用動畫",
|
||||
"description": "顯示平順的轉場與效果"
|
||||
@@ -2526,7 +2538,8 @@
|
||||
"next_email": "下一封郵件",
|
||||
"previous_email": "上一封郵件",
|
||||
"open_email": "開啟郵件",
|
||||
"close_email": "關閉/取消選取"
|
||||
"close_email": "關閉/取消選取",
|
||||
"go_to_folder": "跳至資料夾"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "回覆",
|
||||
@@ -2548,7 +2561,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} 个会话",
|
||||
@@ -606,6 +609,9 @@
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "最小化",
|
||||
"maximize": "全屏",
|
||||
"restore": "还原",
|
||||
"read_receipt_on": "已请求已读回执(点击以关闭)",
|
||||
"read_receipt_off": "请求已读回执",
|
||||
"dsn_on": "已请求投递通知(点击禁用)",
|
||||
@@ -1084,6 +1090,12 @@
|
||||
"regular": "默认",
|
||||
"comfortable": "宽松"
|
||||
},
|
||||
"interface_layout": {
|
||||
"label": "界面布局",
|
||||
"description": "搜索、导航和工具栏的位置",
|
||||
"default": "默认",
|
||||
"mountain_view": "Mountain View"
|
||||
},
|
||||
"animations": {
|
||||
"label": "启用动画",
|
||||
"description": "显示平滑的过渡和效果"
|
||||
@@ -2526,7 +2538,8 @@
|
||||
"next_email": "下一封邮件",
|
||||
"previous_email": "上一封邮件",
|
||||
"open_email": "打开邮件",
|
||||
"close_email": "关闭/取消选择"
|
||||
"close_email": "关闭/取消选择",
|
||||
"go_to_folder": "跳转到文件夹"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "回复",
|
||||
@@ -2548,7 +2561,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']);
|
||||
});
|
||||
});
|
||||
+118
-45
@@ -158,6 +158,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';
|
||||
@@ -186,6 +193,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
|
||||
@@ -382,6 +406,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.
|
||||
@@ -648,6 +673,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,
|
||||
@@ -875,7 +901,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
isLoadingFromServer = wasLoading;
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -892,7 +919,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
|
||||
@@ -904,7 +937,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);
|
||||
},
|
||||
|
||||
@@ -914,6 +948,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,
|
||||
@@ -1114,7 +1149,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
|
||||
@@ -1395,7 +1431,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);
|
||||
}
|
||||
};
|
||||
@@ -1483,52 +1520,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;
|
||||
|
||||
@@ -1544,7 +1616,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