Merge remote-tracking branch 'origin/main'

This commit is contained in:
Linus Rath
2026-10-10 23:20:25 +02:00
122 changed files with 7437 additions and 652 deletions
+2 -1
View File
@@ -168,7 +168,8 @@ function LoginPageContent() {
: undefined;
const [formData, setFormData] = useState({
username: "",
// An account restored from the list backup opens this form with its address.
username: isAddAccountMode ? searchParams.get("username") ?? "" : "",
password: "",
});
const [jmapEndpoint, setJmapEndpoint] = useState("");
+2
View File
@@ -38,11 +38,13 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. No mailbox passwords are stored. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' },
};
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'] },
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { configManager } from '@/lib/admin/config-manager';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { assertBasicAuthMatchesUsername, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { archiveIdentity, parseArchiveEnvelope, ARCHIVE_MAX_BYTES, type ArchiveOwner } from '@/lib/account-archive';
import { deleteArchive, loadArchive, saveArchive } from '@/lib/account-archive-storage';
export const runtime = 'nodejs';
const reply = (data: unknown, status = 200) => NextResponse.json(data, {
status, headers: { 'Cache-Control': 'no-store' },
});
/**
* The owner whose archive this request may touch, or the response refusing it.
*
* A session cookie alone is not enough: cookies for admin-configured servers
* can outlive the credential inside them. So the credential in the matching
* slot is checked against the mail server on every call, and listing is held
* to the same rule as writing, since the ciphertext is what an offline
* password guess would need.
*/
async function verifiedOwner(request: NextRequest): Promise<ArchiveOwner | NextResponse> {
await configManager.ensureLoaded();
if (!configManager.getPolicy().features.accountArchiveEnabled || !hasSessionSecret()) return reply({ error: 'disabled' }, 404);
const denied = reply({ error: 'owner_signin_required' }, 403);
let claimed: string;
try {
claimed = archiveIdentity({
username: request.headers.get('x-archive-username') ?? '',
serverUrl: request.headers.get('x-archive-server') ?? '',
});
} catch { return denied; }
const store = await cookies();
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
const ctx = readStalwartAuthContextFromStore(store, slot);
if (!ctx) continue;
try { if (archiveIdentity(ctx) !== claimed) continue; } catch { continue; }
try {
// The cookie's own username, not the header's: the two may differ in case.
assertBasicAuthMatchesUsername(ctx.authHeader, ctx.username);
const trusted = resolveTrustedJmapUrl(ctx.serverUrl,
configManager.get<string>('jmapServerUrl', ''),
parseJmapServers(configManager.get<unknown>('jmapServers', [])));
await verifyJmapAuth(ctx.serverUrl, ctx.authHeader, { trusted: !!trusted });
} catch { return denied; }
return { username: ctx.accountName ?? ctx.username, serverUrl: ctx.serverUrl };
}
return denied;
}
export async function GET(request: NextRequest) {
const owner = await verifiedOwner(request);
if (owner instanceof NextResponse) return owner;
try { return reply({ envelope: await loadArchive(owner) }); }
catch { return reply({ error: 'storage_failed' }, 500); }
}
export async function PUT(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const owner = await verifiedOwner(request);
if (owner instanceof NextResponse) return owner;
if (Number(request.headers.get('content-length') ?? 0) > ARCHIVE_MAX_BYTES) return reply({ error: 'too_large' }, 413);
let envelope;
try {
const text = await request.text();
if (text.length > ARCHIVE_MAX_BYTES) return reply({ error: 'too_large' }, 413);
envelope = parseArchiveEnvelope((JSON.parse(text) as { envelope?: unknown })?.envelope);
} catch { return reply({ error: 'invalid_archive' }, 400); }
try { await saveArchive(owner, envelope); return reply({ ok: true }); }
catch { return reply({ error: 'storage_failed' }, 500); }
}
export async function DELETE(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const owner = await verifiedOwner(request);
if (owner instanceof NextResponse) return owner;
try { await deleteArchive(owner); return reply({ ok: true }); }
catch { return reply({ error: 'storage_failed' }, 500); }
}
+107
View File
@@ -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);
}
}
@@ -17,7 +17,12 @@ const mocks = vi.hoisted(() => ({
username: "alice@example.com",
isAuthenticated: true,
isDemoMode: false,
connectedAccountsRevision: 0,
// Login id -> client, as the auth store keeps it for the account switcher.
connected: new Map<string, { getAccountId: () => string }>(),
getAllConnectedClients: () => new Map(mocks.authState.connected),
},
accounts: [] as { id: string; username: string }[],
settingsState: { emailNotificationsEnabled: true, pushRelayUrl: "" },
policyState: {
loaded: true,
@@ -34,8 +39,15 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/stores/auth-store", () => ({
useAuthStore: (selector: (state: typeof mocks.authState) => unknown) =>
selector(mocks.authState),
useAuthStore: Object.assign(
(selector: (state: typeof mocks.authState) => unknown) =>
selector(mocks.authState),
{ getState: () => mocks.authState },
),
}));
vi.mock("@/stores/account-store", () => ({
useAccountStore: { getState: () => ({ accounts: mocks.accounts }) },
}));
vi.mock("@/stores/settings-store", () => ({
@@ -82,6 +94,9 @@ describe("PushNotificationPrompt", () => {
mocks.supported = true;
mocks.enabled = false;
mocks.authState.client = { getAccountId: () => "account-1" };
mocks.authState.connectedAccountsRevision = 0;
mocks.authState.connected = new Map([["alice@mail.example.com", mocks.authState.client]]);
mocks.accounts = [{ id: "alice@mail.example.com", username: "alice@example.com" }];
mocks.authState.username = "alice@example.com";
mocks.authState.isAuthenticated = true;
mocks.authState.isDemoMode = false;
@@ -188,6 +203,63 @@ describe("PushNotificationPrompt", () => {
expect(mocks.enableWebPush).not.toHaveBeenCalled();
});
it("renews push for every connected login, not only the active one", async () => {
// Push for bob was enabled from the all-accounts panel; he never becomes
// the active account, so only this background pass keeps his
// registration from hitting the server's 7-day expiry.
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
mocks.accounts.push({ id: "bob@mail.example.com", username: "bob@example.com" });
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(2);
expect(mocks.resyncWebPush.mock.calls[0][0]).toMatchObject({
client: mocks.authState.client,
accountLabel: "alice@example.com",
});
expect(mocks.resyncWebPush.mock.calls[1][0]).toMatchObject({
client: bob,
accountLabel: "bob@example.com",
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
});
});
it("renews the other logins again when the tab returns to the foreground", async () => {
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
render(<PushNotificationPrompt />);
await advancePromptDelay();
mocks.resyncWebPush.mockClear();
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toEqual([
mocks.authState.client,
bob,
]);
});
it("reaches logins that finish connecting after the first pass", async () => {
const { rerender } = render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(1);
// The background restore connects bob and bumps the revision.
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
mocks.authState.connectedAccountsRevision = 1;
rerender(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toContain(bob);
});
it("does not touch push in demo mode", async () => {
mocks.authState.isDemoMode = true;
render(<PushNotificationPrompt />);
@@ -0,0 +1,53 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { EventModal } from '../event-modal';
import { calendarFormHooks, removeAllPluginHooks } from '@/lib/plugin-hooks';
import type { Calendar } from '@/lib/jmap/types';
// The form-save hook runs before the event is saved. A plugin handler can take
// a while (minting a meeting room), and the save must count as in flight for
// that whole time, or a second click creates the new event twice.
const calendars = [
{ id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
] as unknown as Calendar[];
afterEach(() => {
removeAllPluginHooks('test-plugin');
});
describe('EventModal form-save hook', () => {
it('saves once when Save is pressed again while a plugin handler is running', async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
calendarFormHooks.onCalendarEventFormSave.register('test-plugin', async () => {
await gate;
return { virtualLocation: 'https://meet.example.com/room-1' };
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(
<EventModal
calendars={calendars}
defaultDate={new Date('2026-10-12T10:00:00')}
defaultCalendarId="cal-1"
onSave={onSave}
onClose={vi.fn()}
/>,
);
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Sync' } });
const save = screen.getByText('form.save');
fireEvent.click(save);
fireEvent.click(save);
fireEvent.keyDown(window, { key: 'Enter', ctrlKey: true });
await act(async () => { release(); });
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
// Give a stray second save the chance to show up.
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
expect(onSave).toHaveBeenCalledTimes(1);
const data = onSave.mock.calls[0][0];
expect(Object.values(data.virtualLocations)[0]).toMatchObject({ uri: 'https://meet.example.com/room-1' });
});
});
+14 -1
View File
@@ -94,6 +94,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 }
@@ -121,6 +123,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;
@@ -1727,9 +1731,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
+59 -4
View File
@@ -32,7 +32,11 @@ import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useIsPaneScoped } from "@/hooks/use-pane-context";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { calendarHooks } from "@/lib/plugin-hooks";
import {
calendarFormHooks,
calendarHooks,
type CalendarEventFormSavePatch,
} from "@/lib/plugin-hooks";
import type { ConflictWarning } from "@/lib/plugin-types";
export interface PendingEventPreview {
@@ -412,6 +416,8 @@ export function EventModal({
}, []);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Set synchronously, so a second save in the same tick is refused too.
const savingRef = useRef(false);
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
if (!event?.participants) return [];
@@ -466,12 +472,14 @@ export function EventModal({
location,
virtualLocation,
calendarId,
uid: event?.uid,
attendees: attendees.map((a) => a.email),
},
});
if (!cancelled) setPluginConflictWarnings(warnings);
}, 250);
return () => { cancelled = true; clearTimeout(t); };
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId, event?.uid, attendees]);
// Report live preview to parent for grid outline
useEffect(() => {
@@ -500,7 +508,7 @@ export function EventModal({
const handleSave = useCallback(async () => {
const trimmedTitle = title.trim();
if (!trimmedTitle || isSaving) return;
if (!trimmedTitle || isSaving || savingRef.current) return;
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) {
// This guard returned silently, so a rejected save was indistinguishable from a dead
// button. The fields also enforce their limits via maxLength, so this is a backstop —
@@ -647,11 +655,56 @@ export function EventModal({
data.organizerCalendarAddress = null;
}
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
// Plugin form-save hook: a handler may refresh form-derived values
// (currently the virtual-location link) in the same save that sends
// scheduling messages, so invites carry what plugins set here rather
// than a stale value from before the save.
// The handlers can take seconds (a plugin minting a meeting room), so the
// save counts as in flight from here: a second click or Ctrl+Enter must
// not start another save, which for a new event would create a second
// copy (fresh uid) and send its invitations twice.
savingRef.current = true;
setIsSaving(true);
try {
const pluginPatch = await calendarFormHooks.onCalendarEventFormSave.transform<
CalendarEventFormSavePatch
>(
{},
{
title: trimmedTitle,
description: trimmedDescription,
start: startStr,
end: allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
uid: data.uid ?? event?.uid,
attendees: effectiveAttendees.map((a) => a.email),
},
);
if (pluginPatch && typeof pluginPatch.virtualLocation === "string") {
if (pluginPatch.virtualLocation.trim()) {
data.virtualLocations = {
vl1: {
"@type": "VirtualLocation",
name: null,
description: null,
uri: pluginPatch.virtualLocation.trim(),
features: null,
},
};
} else if (event) {
data.virtualLocations = null;
} else {
delete data.virtualLocations;
}
}
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
await onSave(data, shouldSendScheduling);
} finally {
savingRef.current = false;
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving, t]);
@@ -1112,6 +1165,8 @@ export function EventModal({
location,
virtualLocation,
calendarId,
uid: event?.uid,
attendees: attendees.map((a) => a.email),
},
setVirtualLocation,
}}
+14 -1
View File
@@ -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,206 @@
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',
selectedEmailKeys: 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,
selectedEmailKeys: 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({ selectedEmailKeys: 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',
selectedEmailKeys: 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();
});
});
const { toast } = await import('@/stores/toast-store');
const { ArchiveMailboxNotFoundError } = await import('@/stores/email-store');
describe('MvListToolbar failure feedback', () => {
beforeEach(() => {
vi.restoreAllMocks();
useAuthStore.setState({ client: {} as never } as never);
useSettingsStore.setState({
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
nestedTags: false,
} as never);
useEmailStore.setState({
emails: [message('a'), message('b')],
mailboxes,
selectedMailbox: 'inbox',
selectedEmailKeys: new Set(['a', 'b']),
isUnifiedView: false,
unifiedRole: null,
error: null,
} as never);
});
it('says why archiving failed instead of failing silently', async () => {
const error = vi.spyOn(toast, 'error').mockImplementation(() => '' as never);
vi.spyOn(console, 'error').mockImplementation(() => {});
useEmailStore.setState({
batchArchive: vi.fn().mockRejectedValue(new ArchiveMailboxNotFoundError()),
} as never);
render(<MvListToolbar loadedCount={2} onRefresh={() => {}} />);
fireEvent.click(screen.getByLabelText('archive'));
await waitFor(() => expect(error).toHaveBeenCalledWith('error_archiving', 'archive_mailbox_not_found'));
});
it('reports a tag write that the server refused', async () => {
const error = vi.spyOn(toast, 'error').mockImplementation(() => '' as never);
vi.spyOn(console, 'error').mockImplementation(() => {});
useEmailStore.setState({ batchSetTag: vi.fn().mockRejectedValue(new Error('forbidden')) } as never);
render(<MvListToolbar loadedCount={2} onRefresh={() => {}} />);
fireEvent.click(screen.getByLabelText('tag'));
fireEvent.click(screen.getByText('Work'));
await waitFor(() => expect(error).toHaveBeenCalledWith('error_updating'));
});
});
@@ -1,9 +1,10 @@
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 { groupEmailsByThread } from '@/lib/thread-utils';
import { useUIStore } from '@/stores/ui-store';
import { emailKeyFor, groupEmailsByThread } from '@/lib/thread-utils';
import type { Email } from '@/lib/jmap/types';
vi.mock('@/hooks/use-email-drag', () => ({
@@ -60,15 +61,20 @@ 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>(),
selectedEmailKeys: new Set<string>(),
selectedMailbox: 'inbox',
});
});
@@ -139,9 +145,10 @@ describe('ThreadListItem multi-message thread', () => {
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
interfaceLayout: 'default',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedEmailKeys: new Set<string>(),
selectedMailbox: 'inbox',
});
});
@@ -200,9 +207,10 @@ describe('ThreadListItem row content', () => {
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
interfaceLayout: 'default',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedEmailKeys: new Set<string>(),
selectedMailbox: 'inbox',
});
});
@@ -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',
});
});
@@ -241,8 +295,8 @@ describe('ThreadListItem shift-range avatar selection', () => {
// Selection mode active, with the anchor on e1.
useEmailStore.setState({
emails: [e1, e2, e3],
selectedEmailIds: new Set(['e1']),
lastSelectedEmailId: 'e1',
selectedEmailKeys: new Set(['e1']),
lastSelectedEmailKey: 'e1',
selectedMailbox: 'inbox',
});
@@ -252,7 +306,7 @@ describe('ThreadListItem shift-range avatar selection', () => {
avatar.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
});
const selected = useEmailStore.getState().selectedEmailIds;
const selected = useEmailStore.getState().selectedEmailKeys;
expect(selected.has('e1')).toBe(true);
expect(selected.has('e2')).toBe(true); // the row in between got filled in
expect(selected.has('e3')).toBe(true);
@@ -268,10 +322,11 @@ describe('ThreadListItem row tint', () => {
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
interfaceLayout: 'default',
tintListRowsByTag: true,
});
useEmailStore.setState({
selectedEmailIds: new Set(['email-1']),
selectedEmailKeys: new Set(['email-1']),
selectedMailbox: 'inbox',
});
});
@@ -311,11 +366,12 @@ describe('ThreadListItem account row tint', () => {
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
interfaceLayout: 'default',
tintListRowsByTag: true,
tintListRowsByAccount: false,
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedEmailKeys: new Set<string>(),
selectedMailbox: 'inbox',
isUnifiedView: false,
});
@@ -383,3 +439,67 @@ describe('ThreadListItem account row tint', () => {
expect(row(container)!.className).toContain('bg-teal-100');
});
});
describe('ThreadListItem selection in a merged view', () => {
// Unified and tag views stamp each email with its owning account, and the
// selection holds `emailKeyFor` keys. A bare-id check never matches them.
const stamp = { sourceClientAccountId: 'account-b', sourceAccountId: 'account-b' };
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
density: 'regular',
});
useEmailStore.setState({
isUnifiedView: true,
selectedEmailKeys: new Set<string>(),
lastSelectedEmailKey: null,
selectedMailbox: '',
});
});
it('shows a ticked stamped row as checked', () => {
const email = makeEmail({ ...stamp });
useEmailStore.setState({ emails: [email], selectedEmailKeys: new Set([emailKeyFor(email)]) });
renderRow(email);
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
});
it('does not show a namesake row from another account as checked', () => {
const mine = makeEmail({ sourceClientAccountId: 'account-a', sourceAccountId: 'account-a' });
const theirs = makeEmail({ ...stamp });
useEmailStore.setState({ emails: [mine, theirs], selectedEmailKeys: new Set([emailKeyFor(mine)]) });
renderRow(theirs);
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
});
it('ticks a whole thread by key and shows it checked', () => {
const e1 = makeEmail({ id: 'e1', threadId: 't1', receivedAt: '2024-01-15T10:00:00Z', ...stamp });
const e2 = makeEmail({ id: 'e2', threadId: 't1', receivedAt: '2024-01-15T11:00:00Z', ...stamp });
useEmailStore.setState({ emails: [e1, e2] });
const [thread] = groupEmailsByThread([e1, e2]);
const ui = (
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} />
);
const { rerender } = render(ui);
act(() => {
screen.getByRole('checkbox').dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const { selectedEmailKeys, lastSelectedEmailKey } = useEmailStore.getState();
expect([...selectedEmailKeys].sort()).toEqual([emailKeyFor(e1), emailKeyFor(e2)].sort());
expect(lastSelectedEmailKey).toBe(emailKeyFor(e2));
rerender(ui);
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
act(() => {
screen.getByRole('checkbox').dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
});
});
+35 -26
View File
@@ -13,13 +13,14 @@ import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, Calenda
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useEmailStore, ArchiveMailboxNotFoundError } from "@/stores/email-store";
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails } 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";
import { emailKeyFor, groupEmailsByThread, sortThreadGroups, threadKeyFor, getEmailTagIds } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
@@ -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,
@@ -102,7 +109,7 @@ export function EmailList({
const tViewer = useTranslations('email_viewer');
const { client } = useAuthStore();
const {
selectedEmailIds,
selectedEmailKeys,
selectAllEmails: _selectAllEmails,
clearSelection,
batchMarkAsRead,
@@ -181,8 +188,8 @@ export function EmailList({
*/
const ruleEmails = useMemo(() => {
if (!contextMenuEmail) return undefined;
if (selectedEmailIds.has(contextMenuEmail.id) && selectedEmailIds.size > 1) {
return emails.filter((email) => selectedEmailIds.has(email.id));
if (selectedEmailKeys.has(emailKeyFor(contextMenuEmail)) && selectedEmailKeys.size > 1) {
return emails.filter((email) => selectedEmailKeys.has(emailKeyFor(email)));
}
const own = getOwnAddresses();
const isOwn = (email: Email) =>
@@ -194,7 +201,7 @@ export function EmailList({
.filter((email) => !isOwn(email))
.sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt))[0];
return newestOther ? [newestOther] : undefined;
}, [contextMenuEmail, selectedEmailIds, emails, threadGroups, threadEmailsCache]);
}, [contextMenuEmail, selectedEmailKeys, emails, threadGroups, threadEmailsCache]);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
// "Copy to" lists the folders of the other connected accounts. Only with
@@ -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);
@@ -351,7 +358,7 @@ export function EmailList({
</div>
);
const hasSelection = selectedEmailIds.size > 0;
const hasSelection = selectedEmailKeys.size > 0;
const handleBatchMarkAsRead = async (read: boolean) => {
if (!client || isProcessing) return;
@@ -366,7 +373,7 @@ export function EmailList({
// What the tag picker shows for the selection: a tag on every selected
// message is checked, one on only some of them is drawn as partial.
const selectionTags = useMemo(() => {
const selected = emails.filter((email) => selectedEmailIds.has(email.id));
const selected = emails.filter((email) => selectedEmailKeys.has(emailKeyFor(email)));
const counts = new Map<string, number>();
for (const email of selected) {
for (const tagId of getEmailTagIds(email.keywords)) counts.set(tagId, (counts.get(tagId) ?? 0) + 1);
@@ -375,7 +382,7 @@ export function EmailList({
const some: string[] = [];
for (const [tagId, count] of counts) (count === selected.length ? all : some).push(tagId);
return { all, some };
}, [emails, selectedEmailIds]);
}, [emails, selectedEmailKeys]);
// A tag every selected message has comes off; any other goes onto all of
// them. The selection stays, so several tags can be changed in a row.
@@ -393,7 +400,7 @@ export function EmailList({
if (!client || isProcessing) return;
setIsProcessing(true);
try {
const emailIds = Array.from(selectedEmailIds);
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
await batchUndoSpam(client, emailIds);
toast.success(tSpam('toast_not_spam_batch', { count: emailIds.length }));
} catch {
@@ -414,8 +421,8 @@ export function EmailList({
? t('permanent_delete_confirm_title')
: t('batch_actions.delete_confirm_title'),
message: isInTrash
? t('permanent_delete_confirm_batch_message', { count: selectedEmailIds.size })
: t('batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
? t('permanent_delete_confirm_batch_message', { count: selectedEmailKeys.size })
: t('batch_actions.delete_confirm_message', { count: selectedEmailKeys.size }),
confirmText: isInTrash
? t('permanent_delete')
: t('batch_actions.delete'),
@@ -424,7 +431,7 @@ export function EmailList({
if (!confirmed) return;
setIsProcessing(true);
const count = selectedEmailIds.size;
const count = selectedEmailKeys.size;
try {
await runBatchEmailAction(() => batchDelete(client, isInTrash), {
success: tNotifications('emails_deleted', { count }),
@@ -529,13 +536,13 @@ 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">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground">
{t('batch_actions.selected_messages', { count: selectedEmailIds.size })}
{t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
@@ -784,8 +791,8 @@ export function EmailList({
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={effectiveMailboxRole}
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
selectedCount={selectedEmailIds.size}
isMultiSelect={selectedEmailKeys.has(emailKeyFor(contextMenuEmail))}
selectedCount={selectedEmailKeys.size}
ruleEmails={ruleEmails}
onReply={() => onReply?.(contextMenuEmail!)}
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
@@ -805,9 +812,11 @@ export function EmailList({
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
copyTargets={copyTargets}
onCopyToAccount={async (accountId, mailboxId) => {
const ids = selectedEmailIds.has(contextMenuEmail!.id) && selectedEmailIds.size > 1
? Array.from(selectedEmailIds)
: [contextMenuEmail!.id];
// Pass the emails, not their ids: an id alone cannot say which
// account's namesake to copy.
const ids = selectedEmailKeys.has(emailKeyFor(contextMenuEmail!)) && selectedEmailKeys.size > 1
? resolveSelectedEmails(useEmailStore.getState())
: [contextMenuEmail!];
const target = accountMailboxes[accountId]?.find((mb) => mb.id === mailboxId);
await runBatchEmailAction(() => copyEmailsToAccount(ids, accountId, target?.originalId ?? mailboxId), {
success: tNotifications('emails_copied', { count: ids.length }),
@@ -820,7 +829,7 @@ export function EmailList({
onBatchToggleTag={handleBatchToggleTag}
onBatchDelete={async () => {
if (!client) return;
const count = selectedEmailIds.size;
const count = selectedEmailKeys.size;
await runBatchEmailAction(() => batchDelete(client), {
success: tNotifications('emails_deleted', { count }),
error: tNotifications('error_deleting'),
@@ -828,7 +837,7 @@ export function EmailList({
}}
onBatchArchive={async () => {
if (!client) return;
const count = selectedEmailIds.size;
const count = selectedEmailKeys.size;
await runBatchEmailAction(() => batchArchive(client), {
success: tNotifications('emails_archived', { count }),
error: tNotifications('error_archiving'),
@@ -839,7 +848,7 @@ export function EmailList({
}}
onBatchMoveToMailbox={async (mailboxId) => {
if (!client) return;
const count = selectedEmailIds.size;
const count = selectedEmailKeys.size;
await runBatchEmailAction(() => batchMoveToMailbox(client, mailboxId), {
success: tNotifications('emails_moved', { count }),
error: tNotifications('move_failed'),
@@ -847,7 +856,7 @@ export function EmailList({
}}
onBatchMarkAsSpam={async () => {
if (client) {
const emailIds = Array.from(selectedEmailIds);
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
try {
await batchMarkAsSpam(client, emailIds);
toast.success(
@@ -860,7 +869,7 @@ export function EmailList({
}}
onBatchUndoSpam={async () => {
if (client) {
const emailIds = Array.from(selectedEmailIds);
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
try {
await batchUndoSpam(client, emailIds);
toast.success(
+88 -17
View File
@@ -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">&lt;{sender.email}&gt;</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",
+76
View File
@@ -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>
</>
);
}
+572
View File
@@ -0,0 +1,572 @@
"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, ArchiveMailboxNotFoundError, resolveSelectedEmails } from "@/stores/email-store";
import { runBatchEmailAction } from "@/lib/email-action-toast";
import { toast } from "@/stores/toast-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 tNotifications = useTranslations("notifications");
const tSpam = useTranslations("email_viewer.spam");
const client = useAuthStore((s) => s.client);
const {
emails,
mailboxes,
selectedMailbox,
selectedEmailKeys,
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 = selectedEmailKeys.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 ?? "");
// Every action reports its outcome the way the default toolbar does: a
// rejected promise or a store error turns into an error toast instead of
// an unhandled rejection with nothing on screen.
const run = async (
action: () => Promise<void>,
messages?: Parameters<typeof runBatchEmailAction>[1],
) => {
if (!client || isProcessing) return;
setIsProcessing(true);
try {
if (messages) {
await runBatchEmailAction(action, messages);
} else {
try {
await action();
} catch (error) {
console.error("Mountain View toolbar action failed:", error);
toast.error(tNotifications("error_updating"));
}
}
} 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 runBatchEmailAction(() => batchDelete(client, isInTrash), {
success: tNotifications("emails_deleted", { count: selectionCount }),
error: tNotifications("error_deleting"),
});
});
const selectedEmails = resolveSelectedEmails(useEmailStore.getState());
// 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);
const count = selectionCount;
void run(async () => {
if (client) await batchMoveToMailbox(client, mailboxId);
}, {
success: tNotifications("emails_moved", { count }),
error: tNotifications("move_failed"),
});
};
// 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 = resolveSelectedEmails(useEmailStore.getState()).map((email) => email.id);
try {
if (isInJunk) {
await batchUndoSpam(client, ids);
toast.success(tSpam("toast_not_spam_batch", { count: ids.length }));
} else {
await batchMarkAsSpam(client, ids);
toast.success(tSpam("toast_batch", { count: ids.length }));
}
} catch {
toast.error(isInJunk ? tSpam("error_not_spam") : tSpam("error"));
}
});
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); }, {
success: tNotifications("emails_archived", { count: selectionCount }),
error: tNotifications("error_archiving"),
describeError: (error) => error instanceof ArchiveMailboxNotFoundError
? tViewer("archive_mailbox_not_found")
: undefined,
})}
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>
);
}
+6 -7
View File
@@ -426,13 +426,12 @@ export function RichTextEditor({
return (
<div className={cn("flex flex-col", hasError && "ring-2 ring-red-500 dark:ring-red-400 rounded", className)}>
{/* Toolbar - sticky within the composer's scroll container so it stays
visible while editing long bodies. The background stays at main's
bg-muted/30 in every state - backdrop-blur has no visual effect while
the toolbar rests on the solid page background, and blurs body text
sliding underneath only once pinned, so pinned legibility needs no
extra opaque overlay (an always-on stronger overlay would read darker
than main even at rest). */}
<div className="sticky top-0 z-20 flex flex-wrap items-center gap-0.5 px-3 py-1.5 border-b border-border/50 bg-muted/30 backdrop-blur-sm">
visible while editing long bodies. Its background is the colour
bg-muted/30 gives over the page, made opaque: the same at rest, and
body text scrolling underneath stays hidden instead of showing
through, which a backdrop blur alone did not guarantee (browsers
that skip backdrop-filter drew the text sharp under the buttons). */}
<div className="sticky top-0 z-20 flex flex-wrap items-center gap-0.5 px-3 py-1.5 border-b border-border/50 bg-[color-mix(in_oklab,var(--color-muted)_30%,var(--color-background))]">
{/* Font size - two baseline-aligned A's read as "size" at a glance;
once a size is chosen the button shows that number instead. */}
<div ref={fontSizeWrapperRef} className="relative">
+8 -8
View File
@@ -12,7 +12,7 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { getEmailTagIds } from "@/lib/thread-utils";
import { emailKeyFor, getEmailTagIds } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge } from "./tag-badge";
@@ -45,9 +45,9 @@ export function ThreadEmailItem({
// header pools them, so without this they disappear on the way in.
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const density = useSettingsStore((state) => state.density);
const isChecked = selectedEmailIds.has(email.id);
const isChecked = selectedEmailKeys.has(emailKeyFor(email));
const { dragHandlers, isDragging } = useEmailDrag({
email,
@@ -73,18 +73,18 @@ export function ThreadEmailItem({
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
toggleEmailSelection(email);
};
const handleClick = (e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleEmailSelection(email.id);
toggleEmailSelection(email);
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email.id);
selectRangeEmails(email);
} else {
if (selectedEmailIds.size > 0) clearSelection();
if (selectedEmailKeys.size > 0) clearSelection();
onClick?.();
}
};
@@ -118,7 +118,7 @@ export function ThreadEmailItem({
>
<div className="flex items-start gap-3">
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
{selectedEmailKeys.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
+246 -52
View File
@@ -12,11 +12,12 @@ 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";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
import { emailKeyFor, getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
@@ -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,13 +274,14 @@ 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;
const isPinned = email.keywords?.['$pinned'] === true;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always
// "me"). In aggregate role-views the selected mailbox is virtual → fall back
// to the unified role so junk-contextual UI and avatar hiding work.
@@ -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;
@@ -208,10 +306,16 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const account = email.accountId ? getAccountById(email.accountId) : undefined;
const accountColor = account?.avatarColor;
const accountDescription = describeAccount(email.accountLabel, account);
const isChecked = selectedEmailIds.has(email.id);
const isChecked = selectedEmailKeys.has(emailKeyFor(email));
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
@@ -303,9 +407,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (e.shiftKey) {
selectRangeEmails(email.id);
selectRangeEmails(email);
} else {
toggleEmailSelection(email.id);
toggleEmailSelection(email);
}
};
@@ -317,12 +421,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
if (swipeEnabled && consumeTap()) { return; }
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleEmailSelection(email.id);
toggleEmailSelection(email);
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email.id);
selectRangeEmails(email);
} else {
if (selectedEmailIds.size > 0) clearSelection();
if (selectedEmailKeys.size > 0) clearSelection();
onClick();
}
};
@@ -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' && selectedEmailKeys.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
@@ -675,7 +824,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
: null;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined;
@@ -730,7 +879,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
const isChecked = thread.emails.some(e => selectedEmailIds.has(e.id));
const isChecked = thread.emails.some(e => selectedEmailKeys.has(emailKeyFor(e)));
if (emailCount === 1) {
return (
@@ -760,22 +909,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
// Toggle selection for all emails in this thread.
const toggleThreadSelection = () => {
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
const newSelection = new Set(selectedEmailIds);
const allSelected = thread.emails.every(em => selectedEmailKeys.has(emailKeyFor(em)));
const newSelection = new Set(selectedEmailKeys);
thread.emails.forEach(em => {
if (allSelected) {
newSelection.delete(em.id);
newSelection.delete(emailKeyFor(em));
} else {
newSelection.add(em.id);
newSelection.add(emailKeyFor(em));
}
});
useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id });
useEmailStore.setState({ selectedEmailKeys: newSelection, lastSelectedEmailKey: emailKeyFor(latestEmail) });
};
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (e.shiftKey) {
selectRangeEmails(latestEmail.id);
selectRangeEmails(latestEmail);
return;
}
toggleThreadSelection();
@@ -785,12 +934,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
// Ctrl+Click: toggle selection for all thread emails
thread.emails.forEach(em => toggleEmailSelection(em.id));
thread.emails.forEach(em => toggleEmailSelection(em));
return;
}
if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(latestEmail.id);
selectRangeEmails(latestEmail);
return;
}
@@ -803,7 +952,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
if (target.closest('[data-expand-toggle]')) {
onToggleExpand();
} else {
if (selectedEmailIds.size > 0) clearSelection();
if (selectedEmailKeys.size > 0) clearSelection();
if (!isExpanded) {
onCollapseAllThreads?.();
onToggleExpand();
@@ -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' && selectedEmailKeys.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}
+14 -1
View File
@@ -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
+1
View File
@@ -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,
+11 -6
View File
@@ -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');
});
});
+147 -16
View File
@@ -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"
+24 -3
View File
@@ -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>
);
+378
View File
@@ -0,0 +1,378 @@
"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);
// The navigation rail starts the update check, and this bar replaces it on
// desktop; without this the admin shield's update dot never lights up.
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
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 -32
View File
@@ -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
View File
@@ -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")}
@@ -110,7 +110,7 @@ beforeEach(() => {
mailboxes: [primaryMailbox, sharedMailbox], viewingAccountId: null, accountMailboxes: {},
isUnifiedView: false, unifiedRole: null, isScheduledView: false, isLoadingEmail: false,
isLoading: false, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '',
selectedKeyword: null, selectedEmailIds: new Set(),
selectedKeyword: null, selectedEmailKeys: new Set(),
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+275 -122
View File
@@ -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,13 +26,13 @@ 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";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash } from "@/stores/email-store";
import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash } from "@/stores/email-store";
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
import { toast } from "@/stores/toast-store";
import { formatRejectedRecipients, type JMAPClient } from "@/lib/jmap/client";
@@ -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
@@ -373,7 +391,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
newEmailNotification,
selectEmail,
selectMailbox,
selectedEmailIds,
selectedEmailKeys,
selectAllEmails,
clearSelection,
toggleEmailSelection,
@@ -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");
@@ -744,8 +778,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
onArchive: async () => {
if (isScheduledView) return;
if (selectedEmailIds.size > 0 && client) {
const count = selectedEmailIds.size;
if (selectedEmailKeys.size > 0 && client) {
const count = selectedEmailKeys.size;
await runBatchEmailAction(() => batchArchive(client), {
success: t('notifications.emails_archived', { count }),
error: t('notifications.error_archiving'),
@@ -759,7 +793,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
onDelete: async () => {
if (isScheduledView) return;
if (selectedEmailIds.size > 0 && client) {
if (selectedEmailKeys.size > 0 && client) {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const isInJunk = currentMailbox?.role === 'junk';
@@ -770,15 +804,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
? t('email_list.permanent_delete_confirm_title')
: t('email_list.batch_actions.delete_confirm_title'),
message: permanent
? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailIds.size })
: t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailKeys.size })
: t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailKeys.size }),
confirmText: permanent
? t('email_list.permanent_delete')
: t('email_list.batch_actions.delete'),
variant: "destructive",
});
if (!confirmed) return;
const count = selectedEmailIds.size;
const count = selectedEmailKeys.size;
await runBatchEmailAction(() => batchDelete(client, permanent), {
success: t('notifications.emails_deleted', { count }),
error: t('notifications.error_deleting'),
@@ -790,7 +824,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMarkAsUnread: async () => {
if (isScheduledView) return;
if (!client) return;
if (selectedEmailIds.size > 0) {
if (selectedEmailKeys.size > 0) {
await batchMarkAsRead(client, false);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, false);
@@ -799,7 +833,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMarkAsRead: async () => {
if (isScheduledView) return;
if (!client) return;
if (selectedEmailIds.size > 0) {
if (selectedEmailKeys.size > 0) {
await batchMarkAsRead(client, true);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, true);
@@ -813,8 +847,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// is a no-op there too.
if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return;
const isInJunk = currentMailbox?.role === 'junk';
if (selectedEmailIds.size > 0 && client) {
const ids = Array.from(selectedEmailIds);
if (selectedEmailKeys.size > 0 && client) {
// The spam actions take bare ids of the viewed account: resolve the
// selection keys through the loaded rows and thread members.
const ids = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
try {
if (isInJunk) {
await batchUndoSpam(client, ids);
@@ -868,6 +904,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);
},
// 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,14 +953,15 @@ 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, selectedEmailKeys, mailboxes, isScheduledView, mvShell, showCrossStarred]);
// Initialize keyboard shortcuts
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer,
emails: activeEmails,
selectedEmailId: selectedEmail?.id,
selectionCount: selectedEmailIds.size,
selectionCount: selectedEmailKeys.size,
mountainViewKeys: mvShell,
handlers: keyboardHandlers,
});
@@ -1672,6 +1741,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 +1777,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 +3203,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 +3548,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 +3562,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 +3754,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 +3857,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 +3959,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 +3973,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 +4000,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}}
onSidebarClose={() => setSidebarOpen(false)}
mvShell={mvShell}
multiAccountMode={isEmbedded}
accountMailboxes={accountMailboxes}
viewingAccountId={viewingAccountId}
@@ -3861,6 +4062,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,36 +4092,50 @@ 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 */}
<button
type="button"
onClick={() => {
if (selectedEmailIds.size > 0) {
if (selectedEmailIds.size === activeEmails.length) {
if (selectedEmailKeys.size > 0) {
if (selectedEmailKeys.size === activeEmails.length) {
clearSelection();
} else {
selectAllEmails();
}
} else if (activeEmails.length > 0) {
const currentId = selectedEmail?.id;
const target = currentId && activeEmails.some((e) => e.id === currentId)
? currentId
: activeEmails[0].id;
// The open message when it is in this list, else the
// first row - as an email, so the selection is keyed by
// its owning account and not by a colliding id.
const target = (selectedEmail && activeEmails.some((e) => e.id === selectedEmail.id)
? activeEmails.find((e) => e.id === selectedEmail.id)
: activeEmails[0])!;
toggleEmailSelection(target);
}
}}
disabled={isScheduledView}
className={cn(
"flex-shrink-0 p-2 rounded-md transition-colors",
selectedEmailIds.size > 0
selectedEmailKeys.size > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailIds.size > 0 ? (selectedEmailIds.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailKeys.size > 0 ? (selectedEmailKeys.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
>
{selectedEmailIds.size > 0 ? (
{selectedEmailKeys.size > 0 ? (
<CheckSquare className="w-4 h-4" />
) : (
<Square className="w-4 h-4" />
@@ -3971,10 +4190,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 +4377,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</div>
</div>
)}
</div>
</FilterPanelHost>
)}
</div>
)}
@@ -4182,6 +4404,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
<ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList
hideBatchToolbar={mvShell}
emails={activeEmails}
selectedEmailId={selectedEmail?.id}
isLoading={activeIsLoading}
@@ -4283,8 +4506,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 +4516,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 +4573,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 +4774,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
<ShareNotificationToaster client={client} />
<CalendarEventNotificationToaster client={client} />
<TotpReauthDialog />
{floatingComposer && <MvComposeWindow>{composerNode}</MvComposeWindow>}
</div>
</DragDropProvider>
);
+57 -11
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Bell, Loader2, X } from "@/components/icons";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -26,6 +27,37 @@ function dismissedKey(accountId: string): string {
return `${DISMISSED_KEY_PREFIX}${accountId}`;
}
interface ResyncTarget {
client: NonNullable<ReturnType<typeof useAuthStore.getState>["client"]>;
accountLabel?: string;
}
/**
* Every login whose push registration this tab should keep alive: the active
* one first, then each other connected login. Push can be enabled for a login
* that is not on screen (the all-accounts panel in settings), and Stalwart
* expires a PushSubscription after 7 days, so renewing only the active login
* would let the others lapse. The other clients are the ones the auth store
* already keeps for the account switcher, so this opens no new connection;
* resyncWebPush itself skips logins without push in a local check.
*/
function resyncTargets(
activeClient: ResyncTarget["client"],
activeLabel: string | undefined,
): ResyncTarget[] {
const targets: ResyncTarget[] = [{ client: activeClient, accountLabel: activeLabel }];
const auth = useAuthStore.getState();
const others = auth.getAllConnectedClients?.() ?? new Map();
if (others.size === 0) return targets;
const accounts = useAccountStore.getState().accounts;
for (const [loginId, other] of others) {
if (!other || other === activeClient) continue;
const account = accounts.find((a) => a.id === loginId);
targets.push({ client: other, accountLabel: account?.username });
}
return targets;
}
function isExcludedPath(pathname: string): boolean {
return /(^|\/)settings(?:\/|$)/.test(pathname)
|| /(^|\/)setup(?:\/|$)/.test(pathname)
@@ -58,6 +90,11 @@ export function PushNotificationPrompt() {
const username = useAuthStore((state) => state.username);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isDemoMode = useAuthStore((state) => state.isDemoMode);
// Bumped when the background logins finish connecting: the renewal below
// re-runs then so it reaches them too.
const connectedAccountsRevision = useAuthStore(
(state) => state.connectedAccountsRevision,
);
const emailNotificationsEnabled = useSettingsStore(
(state) => state.emailNotificationsEnabled,
);
@@ -121,21 +158,30 @@ export function PushNotificationPrompt() {
// live registration stale.
useEffect(() => {
if (!policyLoaded || !isAuthenticated || !client || !accountId || isDemoMode) return;
const resync = () => {
void resyncWebPush({
client,
relayBaseUrl,
accountLabel: username ?? undefined,
inboxOnly: pushNotifyInboxOnly,
});
let cancelled = false;
const resync = async () => {
// One login at a time: they share the browser's single
// PushSubscription, and a parallel re-create would race on it.
for (const target of resyncTargets(client, username ?? undefined)) {
if (cancelled) return;
await resyncWebPush({
client: target.client,
relayBaseUrl,
accountLabel: target.accountLabel,
inboxOnly: pushNotifyInboxOnly,
});
}
};
resync();
void resync();
// A long-open tab comes back to the foreground: renew the subscription
// before the server's 7-day expiry (resyncWebPush runs at most daily).
const onVisible = () => { if (document.visibilityState === 'visible') resync(); };
const onVisible = () => { if (document.visibilityState === 'visible') void resync(); };
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [accountId, client, isAuthenticated, isDemoMode, policyLoaded, pushNotifyInboxOnly, relayBaseUrl, username]);
return () => {
cancelled = true;
document.removeEventListener('visibilitychange', onVisible);
};
}, [accountId, client, connectedAccountsRevision, isAuthenticated, isDemoMode, policyLoaded, pushNotifyInboxOnly, relayBaseUrl, username]);
useEffect(() => {
let cancelled = false;
@@ -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();
});
});
+83
View File
@@ -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;
}
@@ -0,0 +1,77 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AccountArchiveSettings } from '../account-archive-settings';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), stored: null as unknown }));
vi.mock('next-intl', () => {
const t = (key: string, values?: Record<string, unknown>) => values ? `${key} ${JSON.stringify(values)}` : key;
return { useTranslations: () => t, useLocale: () => 'en' };
});
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/browser-navigation')>()),
apiFetch: mocks.apiFetch,
}));
const owner = { username: 'owner@x.test', serverUrl: 'https://mail.x.test' };
const account = {
id: 'owner@x.test@mail.x.test', label: 'Work', ...owner, authMode: 'basic' as const, rememberMe: true, cookieSlot: 0,
displayName: 'Work', email: owner.username, isConnected: true, hasError: false, lastLoginAt: 1, avatarColor: '#2563eb', isDefault: true,
};
beforeEach(() => {
mocks.stored = null;
mocks.apiFetch.mockReset().mockImplementation(async (_url: string, init: RequestInit = {}) => {
if (init.method === 'PUT') mocks.stored = JSON.parse(String(init.body)).envelope;
if (init.method === 'DELETE') mocks.stored = null;
return new Response(JSON.stringify(init.method ? { ok: true } : { envelope: mocks.stored }), { status: 200 });
});
useAuthStore.setState(owner);
useAccountStore.setState({ accounts: [account] as never, activeAccountId: account.id, defaultAccountId: account.id });
});
const fill = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } });
describe('AccountArchiveSettings', () => {
it('saves the list encrypted, then restores it on a browser that lacks the accounts', async () => {
render(<AccountArchiveSettings />);
await screen.findByText(/^status_none/);
fireEvent.click(screen.getByRole('button', { name: 'save' }));
fill('password', 'a long backup password');
fill('confirm', 'a different password!');
fireEvent.submit(screen.getByLabelText('confirm').closest('form')!);
expect(await screen.findByRole('alert')).toHaveTextContent('errors.mismatch');
expect(mocks.apiFetch).not.toHaveBeenCalledWith('/api/account-archive', expect.objectContaining({ method: 'PUT' }));
fill('confirm', 'a long backup password');
fireEvent.submit(screen.getByLabelText('confirm').closest('form')!);
expect(await screen.findByRole('status', {}, { timeout: 5000 })).toHaveTextContent('saved {"count":1}');
const [, init] = mocks.apiFetch.mock.calls.find(([, i]) => i?.method === 'PUT')!;
expect(init.headers).toMatchObject({ 'x-archive-username': owner.username, 'x-archive-server': owner.serverUrl });
expect(String(init.body)).not.toContain('x.test');
expect(String(init.body)).not.toContain('Work');
// Another browser: same owner signed in, the other account not there yet.
useAccountStore.setState({ accounts: [{ ...account, label: 'Renamed', avatarColor: '#000000' }] as never });
fireEvent.click(screen.getByRole('button', { name: 'restore' }));
fill('password', 'a long backup password');
fireEvent.submit(screen.getByLabelText('password').closest('form')!);
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('restored'), { timeout: 5000 });
expect(useAccountStore.getState().accounts[0]).toMatchObject({ label: 'Work', avatarColor: '#2563eb' });
});
it('reports a wrong restore password', async () => {
render(<AccountArchiveSettings />);
fireEvent.click(await screen.findByRole('button', { name: 'save' }));
fill('password', 'a long backup password');
fill('confirm', 'a long backup password');
fireEvent.submit(screen.getByLabelText('confirm').closest('form')!);
await screen.findByRole('status', {}, { timeout: 5000 });
fireEvent.click(screen.getByRole('button', { name: 'restore' }));
fill('password', 'not the password');
fireEvent.submit(screen.getByLabelText('password').closest('form')!);
expect(await screen.findByRole('alert', {}, { timeout: 5000 })).toHaveTextContent('errors.unlock_failed');
});
});
@@ -0,0 +1,82 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MultiAccountPushSettings } from '../multi-account-push';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import * as webPush from '@/lib/web-push';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key} ${JSON.stringify(values)}` : key,
}));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
// Push state is stored under the JMAP account id the server assigned: only
// "jmap-a" has a registration here.
vi.mock('@/lib/web-push', () => ({
isWebPushSupported: () => true,
isWebPushEnabled: vi.fn(async (id: string) => id === 'jmap-a'),
enableWebPushForAccounts: vi.fn(),
disableWebPush: vi.fn(),
}));
describe('MultiAccountPushSettings', () => {
it('reads each login\'s push state under its JMAP account id', async () => {
const clients: Record<string, { getAccountId: () => string }> = {
'a@mail.example.com': { getAccountId: () => 'jmap-a' },
'b@mail.example.com': { getAccountId: () => 'jmap-b' },
};
useAccountStore.setState({
accounts: [
{ id: 'a@mail.example.com', username: 'a', email: 'a@example.com', isConnected: true },
{ id: 'b@mail.example.com', username: 'b', email: 'b@example.com', isConnected: true },
] as never,
});
useAuthStore.setState({ getClientForAccount: ((id: string) => clients[id]) as never });
const { container } = render(<MultiAccountPushSettings />);
await waitFor(() => expect(container.textContent).toContain('a@example.com · state_on'));
expect(container.textContent).toContain('b@example.com · state_off');
expect(webPush.isWebPushEnabled).toHaveBeenCalledWith('jmap-a');
expect(webPush.isWebPushEnabled).not.toHaveBeenCalledWith('a@mail.example.com');
});
it('names each account the bulk enable left off, with the reason', async () => {
const clients: Record<string, { getAccountId: () => string }> = {
'a@mail.example.com': { getAccountId: () => 'jmap-a' },
'b@mail.example.com': { getAccountId: () => 'jmap-b' },
'c@mail.example.com': { getAccountId: () => 'jmap-c' },
};
useAccountStore.setState({
accounts: [
{ id: 'a@mail.example.com', username: 'a', email: 'a@example.com', displayName: 'Alice', isConnected: true },
{ id: 'b@mail.example.com', username: 'b', email: 'b@example.com', displayName: 'Bob', isConnected: true },
{ id: 'c@mail.example.com', username: 'c', email: 'c@example.com', displayName: 'Carol', isConnected: true },
] as never,
});
useAuthStore.setState({ getClientForAccount: ((id: string) => clients[id]) as never });
vi.mocked(webPush.enableWebPushForAccounts).mockResolvedValueOnce({
enabled: ['a@mail.example.com'],
failed: [
{ accountId: 'b@mail.example.com', error: new Error('relay unreachable') },
{ accountId: 'c@mail.example.com', error: 'timeout' },
],
});
render(<MultiAccountPushSettings />);
await waitFor(() => expect(screen.getByText('select_all')).toBeInTheDocument());
fireEvent.click(screen.getByLabelText('select_all'));
fireEvent.click(screen.getByText('enable_selected'));
const alert = await screen.findByRole('alert');
expect(screen.getByText(/result_partial/).textContent).toContain('"enabled":1,"failed":2');
expect(alert.textContent).toContain('Bob (b@example.com): relay unreachable');
expect(alert.textContent).toContain('Carol (c@example.com): timeout');
expect(alert.textContent).not.toContain('Alice');
});
});
@@ -0,0 +1,123 @@
"use client";
import { useEffect, useId, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAuthStore } from '@/stores/auth-store';
import { decryptArchive, encryptArchive, ARCHIVE_MIN_PASSWORD, type ArchiveOwner } from '@/lib/account-archive';
import { collectAccounts, fetchArchive, putArchive, removeArchive, restoreAccounts } from '@/lib/account-archive-client';
import { toUnicodeEmail } from '@/lib/idn';
import { SettingsSection } from './settings-section';
const KNOWN_ERRORS = ['mismatch', 'password_length', 'unlock_failed', 'owner_signin_required', 'https_required'];
/** Save this browser's account list, encrypted, under the active account; restore it on another device. */
export function AccountArchiveSettings() {
const t = useTranslations('settings.account.backup');
const id = useId();
const username = useAuthStore(s => s.username);
const serverUrl = useAuthStore(s => s.serverUrl);
const [saved, setSaved] = useState<boolean | null>(null);
const [mode, setMode] = useState<'save' | 'restore' | null>(null);
const [password, setPassword] = useState('');
const [confirmation, setConfirmation] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ error: boolean; text: string } | null>(null);
const owner: ArchiveOwner | null = username && serverUrl ? { username, serverUrl } : null;
useEffect(() => {
setSaved(null);
if (!username || !serverUrl) return;
let cancelled = false;
fetchArchive({ username, serverUrl })
.then(envelope => { if (!cancelled) setSaved(envelope !== null); })
.catch(() => { if (!cancelled) setSaved(false); });
return () => { cancelled = true; };
}, [username, serverUrl]);
if (!owner) return null;
const open = (next: 'save' | 'restore' | null) => {
setMode(next); setPassword(''); setConfirmation(''); setMessage(null);
};
const run = async (action: () => Promise<string>) => {
setBusy(true); setMessage(null);
try {
const text = await action();
open(null);
setMessage({ error: false, text });
}
catch (err) {
const code = err instanceof Error ? err.message : '';
setMessage({ error: true, text: t(`errors.${KNOWN_ERRORS.includes(code) ? code : 'storage_failed'}`) });
} finally { setBusy(false); }
};
const save = () => run(async () => {
if (password !== confirmation) throw new Error('mismatch');
const accounts = collectAccounts();
await putArchive(owner, await encryptArchive(owner, accounts, password));
setSaved(true);
return t('saved', { count: accounts.length });
});
const restore = () => run(async () => {
const envelope = await fetchArchive(owner);
if (!envelope) { setSaved(false); throw new Error('storage_failed'); }
const result = restoreAccounts(await decryptArchive(owner, envelope, password));
return [t('restored', result), result.skipped ? t('skipped', { count: result.skipped }) : ''].join(' ').trim();
});
const remove = () => {
if (!window.confirm(t('delete_confirm'))) return;
void run(async () => { await removeArchive(owner); setSaved(false); return t('deleted'); });
};
return (
<SettingsSection title={t('title')} description={t('description')}>
<p className="text-sm text-muted-foreground">
{saved === null ? '…' : t(saved ? 'status_saved' : 'status_none', { account: toUnicodeEmail(owner.username) })}
</p>
{mode === null ? (
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => open('save')}>{t('save')}</Button>
{saved && <Button type="button" size="sm" variant="outline" onClick={() => open('restore')}>{t('restore')}</Button>}
{saved && <Button type="button" size="sm" variant="ghost" disabled={busy} onClick={remove}>{t('delete')}</Button>}
</div>
) : (
<form
className="space-y-3 max-w-md"
onSubmit={e => { e.preventDefault(); void (mode === 'save' ? save() : restore()); }}
>
<label className="block text-sm font-medium" htmlFor={`${id}-password`}>{t('password')}</label>
<Input
id={`${id}-password`} type="password" value={password} required autoFocus
minLength={mode === 'save' ? ARCHIVE_MIN_PASSWORD : undefined}
autoComplete={mode === 'save' ? 'new-password' : 'current-password'}
aria-describedby={`${id}-hint`} onChange={e => setPassword(e.target.value)}
/>
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
{t(mode === 'save' ? 'password_hint' : 'restore_hint')}
</p>
{mode === 'save' && (
<>
<label className="block text-sm font-medium" htmlFor={`${id}-confirm`}>{t('confirm')}</label>
<Input
id={`${id}-confirm`} type="password" value={confirmation} required autoComplete="new-password"
onChange={e => setConfirmation(e.target.value)}
/>
</>
)}
<div className="flex flex-wrap gap-2">
<Button type="submit" size="sm" disabled={busy}>{t(mode === 'save' ? 'save' : 'restore')}</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => open(null)}>{t('cancel')}</Button>
</div>
</form>
)}
{message && (
<p role={message.error ? 'alert' : 'status'} className={message.error ? 'text-sm text-destructive' : 'text-sm text-foreground'}>
{message.text}
</p>
)}
</SettingsSection>
);
}
+6
View File
@@ -15,6 +15,9 @@ import { useRouter } from '@/i18n/navigation';
import { getMaxAccounts } from '@/lib/account-utils';
import { formatFileSize, cn } from '@/lib/utils';
import { toUnicodeDomain, toUnicodeEmail } from '@/lib/idn';
import { IS_LITE } from '@/lib/lite';
import { usePolicyStore } from '@/stores/policy-store';
import { AccountArchiveSettings } from './account-archive-settings';
function hostnameOf(serverUrl: string): string {
try { return toUnicodeDomain(new URL(serverUrl).hostname); } catch { return serverUrl; }
@@ -38,6 +41,7 @@ export function AccountSettings() {
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
const archiveEnabled = usePolicyStore((s) => s.isFeatureEnabled('accountArchiveEnabled')) && !IS_LITE && !isDemoMode;
// Shared/group accounts delegated to this session (excludes the user's own
// primary account). These can be drilled into for scoped settings editing.
@@ -235,6 +239,8 @@ export function AccountSettings() {
</SettingsSection>
)}
{archiveEnabled && <AccountArchiveSettings />}
{/* Shared / group accounts delegated to this session. Clicking one drills
into a scoped settings view (filters, vacation, calendars, contacts). */}
{sharedAccounts.length > 0 && (
+24 -3
View File
@@ -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
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Loader2 } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { SettingsSection } from './settings-section';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { disableWebPush, enableWebPushForAccounts, isWebPushEnabled, isWebPushSupported } from '@/lib/web-push';
interface FailedAccount {
id: string;
name: string;
email: string;
reason: string;
}
function failureReason(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return String(error);
}
/**
* Turn push on or off for several accounts at once.
*
* The per-account panel above only ever addresses the account on screen, so
* arming a fresh device used to mean switching account N times. One browser
* subscription already serves every account (see `enableWebPushForAccounts`);
* what was missing was a place to register them all against it in one go.
*/
export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: string }) {
const t = useTranslations('settings.notifications.push_all');
const accounts = useAccountStore(s => s.accounts);
// Re-read the client map whenever a connection appears or drops.
const revision = useAuthStore(s => s.connectedAccountsRevision);
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [chosen, setChosen] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState('');
// Which accounts the last bulk enable left off, and why: a bare count
// leaves the user guessing which login to retry.
const [failures, setFailures] = useState<FailedAccount[]>([]);
const connected = accounts.filter(a => a.isConnected && useAuthStore.getState().getClientForAccount(a.id));
const refresh = useCallback(async () => {
// Push state is stored under the JMAP account id the server assigned (see
// enableWebPush), not under the login's own id: read it back the same way.
const entries = await Promise.all(
useAccountStore.getState().accounts.map(async (a) => {
const jmapAccountId = useAuthStore.getState().getClientForAccount(a.id)?.getAccountId();
return [a.id, jmapAccountId ? await isWebPushEnabled(jmapAccountId) : false] as const;
}),
);
setEnabled(Object.fromEntries(entries));
}, []);
useEffect(() => { void refresh(); }, [refresh, revision]);
if (!isWebPushSupported() || connected.length < 2) return null;
const toggle = (id: string, on: boolean) => setChosen(prev => {
const next = new Set(prev);
if (on) next.add(id); else next.delete(id);
return next;
});
const run = async (action: 'enable' | 'disable') => {
const auth = useAuthStore.getState();
const targets = connected.filter(a => chosen.has(a.id));
if (!targets.length) return;
setBusy(true); setNotice(''); setFailures([]);
try {
if (action === 'enable') {
const outcome = await enableWebPushForAccounts(
targets.map(a => ({ accountId: a.id, client: auth.getClientForAccount(a.id)!, accountLabel: a.username })),
{ relayBaseUrl, inboxOnly: useSettingsStore.getState().pushNotifyInboxOnly },
);
setNotice(outcome.failed.length
? t('result_partial', { enabled: outcome.enabled.length, failed: outcome.failed.length })
: t('result_enabled', { count: outcome.enabled.length }));
setFailures(outcome.failed.map(({ accountId, error }) => {
const account = targets.find(a => a.id === accountId);
return {
id: accountId,
name: account ? (account.displayName || account.label || account.username) : accountId,
email: account ? (account.email || account.username) : '',
reason: failureReason(error),
};
}));
} else {
// Teardown swallows its own failures, so every account ends up local-off.
for (const account of targets) {
await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl });
}
setNotice(t('result_disabled', { count: targets.length }));
}
setChosen(new Set());
} finally {
setBusy(false);
await refresh();
}
};
const allChosen = connected.length > 0 && connected.every(a => chosen.has(a.id));
return <SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" disabled={busy} checked={allChosen}
onChange={e => setChosen(e.target.checked ? new Set(connected.map(a => a.id)) : new Set())} />
{t('select_all')}
</label>
<ul className="space-y-2">
{connected.map(account => <li key={account.id}>
<label className="flex items-start gap-2 ps-6 text-sm">
<input type="checkbox" className="mt-1" disabled={busy} checked={chosen.has(account.id)}
onChange={e => toggle(account.id, e.target.checked)} />
<span>
<span className="block">{account.displayName || account.label || account.username}</span>
<span className="block text-xs text-muted-foreground">
{account.email || account.username} · {enabled[account.id] ? t('state_on') : t('state_off')}
</span>
</span>
</label>
</li>)}
</ul>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={() => void run('enable')} disabled={busy || !chosen.size}>
{busy && <Loader2 className="me-2 h-4 w-4 animate-spin" />}{t('enable_selected')}
</Button>
<Button type="button" variant="outline" onClick={() => void run('disable')} disabled={busy || !chosen.size}>
{t('disable_selected')}
</Button>
</div>
{notice && <p className="text-sm text-muted-foreground">{notice}</p>}
{failures.length > 0 && <ul role="alert" className="space-y-1 text-sm text-destructive">
{failures.map(f => <li key={f.id}>
<span className="font-medium">{f.name}</span>
{f.email && f.email !== f.name && <span> ({f.email})</span>}
<span>: {f.reason}</span>
</li>)}
</ul>}
</div>
</SettingsSection>;
}
@@ -11,6 +11,7 @@ import { Loader2, RefreshCw, Volume2, XCircle } from '@/components/icons';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { MultiAccountPushSettings } from './multi-account-push';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import {
WebPushUnsupportedError,
@@ -319,6 +320,8 @@ export function NotificationSettings() {
)}
</SettingsSection>
<MultiAccountPushSettings relayBaseUrl={activeRelayUrl} />
{client && (
<SettingsSection title={t('push.devices_title')} description={t('push.devices_desc')}>
<div className="flex items-center justify-between gap-2">
+2 -2
View File
@@ -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();
+14 -1
View File
@@ -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
@@ -71,6 +71,35 @@ describe('useDocumentTitle', () => {
expect(document.title).toBe('Settings - Acme Mail');
});
it('writes a title with runs of whitespace once instead of over and over', async () => {
// The browser reads the title back with its whitespace collapsed, so it
// never equals what was written. Count the writes, and stop a runaway
// loop instead of hanging the test.
const native = Object.getOwnPropertyDescriptor(Document.prototype, 'title')!;
let writes = 0;
Object.defineProperty(document, 'title', {
configurable: true,
get: () => native.get!.call(document),
set: (value: string) => {
writes += 1;
if (writes > 20) throw new Error('the title is written over and over');
native.set!.call(document, value);
},
});
try {
renderHook(() => useDocumentTitle('[Spam] Offer\tinside '));
expect(document.title).toBe('[Spam] Offer inside - jane@example.com - Acme Mail');
expect(writes).toBe(1);
// Next renders its metadata title again: the title is put back once.
rerenderMetadataTitle();
await settle();
expect(document.title).toBe('[Spam] Offer inside - jane@example.com - Acme Mail');
expect(writes).toBe(2);
} finally {
delete (document as { title?: string }).title;
}
});
it('puts its title back when the metadata title is rendered again', async () => {
renderHook(() => useDocumentTitle('Settings'));
rerenderMetadataTitle();
@@ -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();
});
});
+12 -3
View File
@@ -36,10 +36,19 @@ export function useDocumentTitle(context?: string | null): void {
if (!inView) return;
const self = Symbol('document-title');
owner = self;
// Termination: setting the title is itself a <head> mutation; on that run
// the title is already ours and nothing is written.
// Setting the title is itself a <head> mutation. The browser reads it
// back with its whitespace stripped and collapsed (HTML's "strip and
// collapse ASCII whitespace"), so a subject with two spaces in a row
// never reads back as written. Comparing with what it read back after
// our own write ends the round; without that, every write set off the
// observer again, an endless loop that froze the tab.
let readBack: string | null = null;
const apply = () => {
if (owner === self && document.title !== title) document.title = title;
if (owner !== self) return;
const current = document.title;
if (current === title || current === readBack) return;
document.title = title;
readBack = document.title;
};
apply();
const observer = new MutationObserver(apply);
+8
View File
@@ -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));
}
+9 -8
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
import { Email } from "@/lib/jmap/types";
import { IJMAPClient } from "@/lib/jmap/client-interface";
import { useEmailStore } from "@/stores/email-store";
import { emailKeyFor } from "@/lib/thread-utils";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store";
@@ -141,7 +142,7 @@ function getReadyBundle(emails: Email[]): { url: string; name: string } | null {
}
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
const { selectedEmailIds, emails } = useEmailStore();
const { selectedEmailKeys, emails } = useEmailStore();
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
const isMobile = useUIStore((state) => state.isMobile);
const client = useAuthStore((state) => state.client);
@@ -195,10 +196,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const handlePointerEnter = useCallback(() => {
if (!dragOutEnabled || !client) return;
const isSelected = selectedEmailIds.has(email.id);
const isMulti = isSelected && selectedEmailIds.size > 1;
const isSelected = selectedEmailKeys.has(emailKeyFor(email));
const isMulti = isSelected && selectedEmailKeys.size > 1;
if (isMulti) {
const selected = emails.filter((em) => selectedEmailIds.has(em.id));
const selected = emails.filter((em) => selectedEmailKeys.has(emailKeyFor(em)));
// Only worth bundling when at least one selected email has a blobId.
if (selected.some((em) => em.blobId)) {
prefetchEmailBundle(client, selected, filenameOptions, bundleOptions);
@@ -206,16 +207,16 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
} else {
prefetchSingle();
}
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions, bundleOptions]);
}, [dragOutEnabled, client, selectedEmailKeys, email, emails, prefetchSingle, filenameOptions, bundleOptions]);
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag:
// - If current email is selected, drag all selected
// - If threadEmails provided (thread header), drag all thread emails
// - Otherwise, drag only this email
const isSelected = selectedEmailIds.has(email.id);
const isSelected = selectedEmailKeys.has(emailKeyFor(email));
const emailsToDrag = isSelected
? emails.filter(em => selectedEmailIds.has(em.id))
? emails.filter(em => selectedEmailKeys.has(emailKeyFor(em)))
: threadEmails || [email];
e.dataTransfer.effectAllowed = "copyMove";
@@ -284,7 +285,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
});
startDrag(emailsToDrag, sourceMailboxId);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
}, [email, selectedEmailKeys, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
const handleDragEnd = useCallback(() => {
endDrag();
+81 -3
View File
@@ -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" },
+6 -4
View File
@@ -3,6 +3,7 @@
import { useCallback, useState, DragEvent } from "react";
import { Mailbox, Email } from "@/lib/jmap/types";
import { useEmailStore } from "@/stores/email-store";
import { emailKeyFor } from "@/lib/thread-utils";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { toast } from "@/stores/toast-store";
@@ -65,7 +66,7 @@ interface UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target
@@ -183,8 +184,9 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
await moveEmailsToMailbox(client, emailIds, mailbox.id);
}
// Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) {
// Clear selection if any selected emails were moved. The selection is
// keyed by owning account, so compare the dragged emails, not their ids.
if (draggedEmails.some(em => selectedEmailKeys.has(emailKeyFor(em)))) {
clearSelection();
}
@@ -222,7 +224,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally {
endDrag();
}
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailKeys, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget();
+19
View File
@@ -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;
}
+9 -2
View File
@@ -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]);
}
+39
View File
@@ -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;
}
+100
View File
@@ -0,0 +1,100 @@
// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
const mocks = vi.hoisted(() => ({
enabled: true, contexts: new Map<number, unknown>(), verify: vi.fn(),
}));
vi.mock('@/lib/admin/config-manager', () => ({ configManager: {
ensureLoaded: async () => {},
get: (_key: string, fallback: unknown) => fallback,
getPolicy: () => ({ features: { accountArchiveEnabled: mocks.enabled } }),
} }));
vi.mock('@/lib/auth/session-secret', () => ({ hasSessionSecret: () => true }));
vi.mock('next/headers', () => ({ cookies: async () => ({}) }));
vi.mock('@/lib/stalwart/auth-context', () => ({
readStalwartAuthContextFromStore: (_: unknown, slot: number) => mocks.contexts.get(slot) ?? null,
}));
vi.mock('@/lib/auth/verify-jmap-auth', async importOriginal => ({
...await importOriginal<typeof import('@/lib/auth/verify-jmap-auth')>(), verifyJmapAuth: mocks.verify,
}));
const dataDir = mkdtempSync(path.join(tmpdir(), 'bw-account-archive-'));
process.env.SETTINGS_DATA_DIR = dataDir;
afterAll(() => rmSync(dataDir, { recursive: true, force: true }));
import { DELETE, GET, PUT } from '@/app/api/account-archive/route';
const owner = { username: 'owner@example.com', serverUrl: 'https://mail.example.com' };
const envelope = { version: 1, iterations: 600000, salt: 'A'.repeat(22) + '==', iv: 'A'.repeat(16), ciphertext: 'A'.repeat(43) + '=' };
const basic = (user: string) => 'Basic ' + Buffer.from(`${user}:secret`).toString('base64');
function request(method: string, body?: unknown, headers: Record<string, string> = {}) {
return new NextRequest('https://webmail.example.com/api/account-archive', {
method,
headers: { 'x-archive-username': owner.username, 'x-archive-server': owner.serverUrl, 'content-type': 'application/json', ...headers },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
const signIn = (username = owner.username, slot = 3) => {
mocks.contexts.set(slot, { username, serverUrl: owner.serverUrl, authHeader: basic(username) });
};
beforeEach(() => {
mocks.enabled = true;
mocks.contexts.clear();
mocks.verify.mockReset().mockResolvedValue(owner.serverUrl);
});
describe('account archive API', () => {
it('is off unless the admin enables it', async () => {
mocks.enabled = false;
signIn();
expect((await GET(request('GET'))).status).toBe(404);
expect((await PUT(request('PUT', { envelope }))).status).toBe(404);
expect((await DELETE(request('DELETE'))).status).toBe(404);
});
it('refuses every method without a verified session of the owner', async () => {
for (const call of [() => GET(request('GET')), () => PUT(request('PUT', { envelope })), () => DELETE(request('DELETE'))]) {
expect((await call()).status).toBe(403);
}
// Another user's session, or a credential that does not match its cookie.
mocks.contexts.set(0, { ...owner, username: 'someone@example.com', authHeader: basic('someone@example.com') });
mocks.contexts.set(1, { ...owner, authHeader: basic('someone@example.com') });
expect((await GET(request('GET'))).status).toBe(403);
// The owner's cookie whose credential the mail server no longer accepts.
mocks.contexts.clear();
signIn();
mocks.verify.mockRejectedValue(new Error('401'));
expect((await GET(request('GET'))).status).toBe(403);
expect((await PUT(request('PUT', { envelope }))).status).toBe(403);
});
it('stores, returns and deletes the envelope for the verified owner, in any case', async () => {
signIn('Owner@Example.com');
let res = await GET(request('GET'));
expect(res.status).toBe(200);
expect(res.headers.get('Cache-Control')).toBe('no-store');
expect((await res.json()).envelope).toBeNull();
res = await PUT(request('PUT', { envelope: { ...envelope, password: 'forbidden' }, accounts: ['forbidden'] }));
expect(res.status).toBe(200);
expect(mocks.verify).toHaveBeenCalledWith(owner.serverUrl, basic('Owner@Example.com'), { trusted: false });
expect((await (await GET(request('GET'))).json()).envelope).toEqual(envelope);
expect((await DELETE(request('DELETE'))).status).toBe(200);
expect((await (await GET(request('GET'))).json()).envelope).toBeNull();
});
it('rejects cross-site writes, malformed envelopes and oversized bodies', async () => {
signIn();
expect((await PUT(request('PUT', { envelope }, { 'sec-fetch-site': 'cross-site' }))).status).toBe(403);
expect((await DELETE(request('DELETE', undefined, { 'sec-fetch-site': 'cross-site' }))).status).toBe(403);
expect((await PUT(request('PUT', { envelope: { ...envelope, iterations: 1 } }))).status).toBe(400);
expect((await PUT(request('PUT', { envelope: { ...envelope, ciphertext: 'A'.repeat(70000) } }))).status).toBe(413);
});
});
+57
View File
@@ -0,0 +1,57 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import {
archiveIdentity, decryptArchive, encryptArchive, parseArchiveEnvelope, parseArchivedAccounts, type ArchivedAccount,
} from '@/lib/account-archive';
const owner = { username: 'owner@example.com', serverUrl: 'https://mail.example.com' };
const accounts: ArchivedAccount[] = [
{ username: 'owner@example.com', serverUrl: 'https://mail.example.com', label: 'Work', avatarColor: '#2563eb' },
{ username: 'me@other.example', serverUrl: 'https://jmap.other.example/', label: 'Home', avatarColor: '#16a34a' },
];
const password = 'correct horse battery';
describe('account archive encryption', () => {
it('round-trips the account list and leaves only ciphertext in the envelope', async () => {
const envelope = await encryptArchive(owner, accounts, password);
expect(JSON.stringify(envelope)).not.toContain('example');
expect(JSON.stringify(envelope)).not.toContain('Work');
expect(await decryptArchive(owner, envelope, password)).toEqual([
accounts[0], { ...accounts[1], serverUrl: 'https://jmap.other.example' },
]);
});
it('refuses a wrong password and an envelope moved to another owner', async () => {
const envelope = await encryptArchive(owner, accounts, password);
await expect(decryptArchive(owner, envelope, 'wrong password!')).rejects.toThrow('unlock_failed');
await expect(decryptArchive({ ...owner, username: 'thief@example.com' }, envelope, password)).rejects.toThrow('unlock_failed');
});
it('treats the owner username without regard to case', async () => {
const envelope = await encryptArchive({ ...owner, username: 'Owner@Example.com' }, accounts, password);
expect(await decryptArchive(owner, envelope, password)).toHaveLength(2);
expect(archiveIdentity({ ...owner, username: 'OWNER@example.com' })).toBe(archiveIdentity(owner));
expect(archiveIdentity({ ...owner, serverUrl: 'https://mail.example.com/' })).toBe(archiveIdentity(owner));
});
it('demands a password of at least 10 characters', async () => {
await expect(encryptArchive(owner, accounts, 'short')).rejects.toThrow('password_length');
});
it('never carries a password or any other extra field', async () => {
const withSecrets = accounts.map(a => ({ ...a, password: 'mailbox-secret', token: 't' }));
const envelope = await encryptArchive(owner, withSecrets, password);
const restored = await decryptArchive(owner, envelope, password);
expect(JSON.stringify(restored)).not.toContain('mailbox-secret');
expect(Object.keys(restored[0]).sort()).toEqual(['avatarColor', 'label', 'serverUrl', 'username']);
});
it('rejects malformed lists and envelopes', async () => {
expect(() => parseArchivedAccounts([])).toThrow('invalid_archive');
expect(() => parseArchivedAccounts([{ ...accounts[0], avatarColor: 'red' }])).toThrow('invalid_archive');
const envelope = await encryptArchive(owner, accounts, password);
expect(parseArchiveEnvelope({ ...envelope, extra: 'x' })).toEqual(envelope);
expect(() => parseArchiveEnvelope({ ...envelope, iterations: 1000 })).toThrow('invalid_archive');
expect(() => parseArchiveEnvelope({ ...envelope, iv: 'AAAA' })).toThrow('invalid_archive');
});
});
@@ -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);
});
});
+2 -2
View File
@@ -94,7 +94,7 @@ describe('clearAllStores', () => {
it('resets the email store to fresh empty collections', () => {
useEmailStore.setState({
emails: [makeEmail({ id: 'x' })],
selectedEmailIds: new Set(['x']),
selectedEmailKeys: new Set(['x']),
searchQuery: 'q',
tagCounts: { a: { total: 1, unread: 0 } },
threadEmailsCache: new Map([['t', []]]),
@@ -105,7 +105,7 @@ describe('clearAllStores', () => {
const s = useEmailStore.getState();
expect(s.emails).toEqual([]);
expect(s.searchQuery).toBe('');
expect(s.selectedEmailIds.size).toBe(0);
expect(s.selectedEmailKeys.size).toBe(0);
expect(s.threadEmailsCache.size).toBe(0);
expect(s.tagCounts).toEqual({});
expect(s.searchFilters).toEqual(DEFAULT_SEARCH_FILTERS);
+2 -1
View File
@@ -103,13 +103,14 @@ describe('applyLitePolicy (policy.json for the static build)', () => {
it('honours deployer gates and restrictions but never re-enables plugins', () => {
const policy = applyLitePolicy({
features: { pluginsEnabled: true, sidebarAppsEnabled: true, calendarEnabled: false },
features: { pluginsEnabled: true, sidebarAppsEnabled: true, calendarEnabled: false, accountArchiveEnabled: true },
restrictions: { theme: { locked: true, value: 'dark' } },
forceEnabledPlugins: ['evil'],
defaultSidebarApps: [{ id: 'x' }],
});
expect(policy.features.pluginsEnabled).toBe(false);
expect(policy.features.sidebarAppsEnabled).toBe(false);
expect(policy.features.accountArchiveEnabled).toBe(false);
expect(policy.features.calendarEnabled).toBe(false);
expect(policy.restrictions.theme).toEqual({ locked: true, value: 'dark' });
expect(policy.forceEnabledPlugins).toEqual([]);
+49
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HookBus, pluginErrorTracker, removeAllPluginHooks, clearAllHooks, emailHooks, calendarHooks, isExternalAttachmentResult } from '../plugin-hooks';
import type { CalendarEventFormView } from '../plugin-types';
beforeEach(() => {
pluginErrorTracker.resetAll();
@@ -294,6 +295,54 @@ describe('Hook domain instances', () => {
expect(calendarHooks.onBeforeEventCreate).toBeInstanceOf(HookBus);
expect(calendarHooks.onEventRsvp).toBeInstanceOf(HookBus);
});
it('onCalendarEventFormSave pipes a returned patch through transform with the form view', async () => {
const { calendarFormHooks } = await import('../plugin-hooks');
const views: CalendarEventFormView[] = [];
const disposable = calendarFormHooks.onCalendarEventFormSave.register(
'test-p',
async (_patch, view) => {
views.push(view);
return { virtualLocation: 'https://meet.example.com/rooms/x?role=host' };
},
);
const patch = await calendarFormHooks.onCalendarEventFormSave.transform(
{},
{
title: 'Team Sync',
description: '',
start: '2026-10-09T10:00:00',
end: '2026-10-09T11:00:00',
isAllDay: false,
location: '',
virtualLocation: '',
calendarId: 'cal-1',
uid: 'uid-1',
attendees: ['a@sunbeam.pt'],
},
);
expect(patch).toEqual({ virtualLocation: 'https://meet.example.com/rooms/x?role=host' });
expect(views[0]).toMatchObject({ title: 'Team Sync', uid: 'uid-1', attendees: ['a@sunbeam.pt'] });
disposable.dispose();
});
it('onCalendarEventFormSave keeps the form unchanged when handlers return undefined', async () => {
const { calendarFormHooks } = await import('../plugin-hooks');
const disposable = calendarFormHooks.onCalendarEventFormSave.register('test-p', async () => undefined);
const patch = await calendarFormHooks.onCalendarEventFormSave.transform(
{ virtualLocation: 'keep-me' },
{
title: 'T', description: '', start: '', end: '', isAllDay: false,
location: '', virtualLocation: '', calendarId: 'cal-1', attendees: [],
},
);
expect(patch).toEqual({ virtualLocation: 'keep-me' });
disposable.dispose();
});
});
describe('removeAllPluginHooks', () => {
+38
View File
@@ -5,6 +5,7 @@ import {
buildEmailPushConfig,
disableWebPush,
enableWebPush,
enableWebPushForAccounts,
listPushDevices,
isWebPushEnabled,
resetWebPushResyncState,
@@ -661,3 +662,40 @@ describe('revokePushDevice', () => {
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
});
describe('enableWebPushForAccounts', () => {
it('registers every account against the one browser subscription', async () => {
const first = makeClient([]);
const second = makeClient([]);
installFetch({});
const result = await enableWebPushForAccounts([
{ accountId: 'a', client: first, accountLabel: 'one' },
{ accountId: 'b', client: second, accountLabel: 'two' },
], { relayBaseUrl: RELAY, inboxOnly: false });
expect(result.enabled).toEqual(['a', 'b']);
expect(result.failed).toEqual([]);
expect(first.createPushSubscription).toHaveBeenCalledTimes(1);
expect(second.createPushSubscription).toHaveBeenCalledTimes(1);
});
it('stops at a browser-level refusal instead of prompting once per account', async () => {
const first = makeClient([]);
const second = makeClient([]);
installFetch({});
// Blocked notifications are a property of the browser, not of an account:
// walking on would fail identically for every one of them.
(globalThis as unknown as { Notification: unknown }).Notification = { permission: 'denied' };
(window as unknown as { Notification: unknown }).Notification = { permission: 'denied' };
const result = await enableWebPushForAccounts([
{ accountId: 'a', client: first },
{ accountId: 'b', client: second },
], { relayBaseUrl: RELAY, inboxOnly: false });
expect(result.enabled).toEqual([]);
expect(result.failed.map(f => f.accountId)).toEqual(['a', 'b']);
expect(second.createPushSubscription).not.toHaveBeenCalled();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { apiFetch } from '@/lib/browser-navigation';
import { IS_LITE } from '@/lib/lite';
import { generateAccountId, getMaxAccounts } from '@/lib/account-utils';
import { useAccountStore } from '@/stores/account-store';
import type { ArchiveEnvelope, ArchiveOwner, ArchivedAccount } from './account-archive';
async function request(owner: ArchiveOwner, init: RequestInit = {}): Promise<{ envelope?: ArchiveEnvelope | null }> {
// The archive lives on this app's server; the static Lite build has none.
if (IS_LITE) throw new Error('disabled');
const res = await apiFetch('/api/account-archive', {
cache: 'no-store', ...init,
headers: {
'x-archive-username': owner.username, 'x-archive-server': owner.serverUrl,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
},
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(typeof body.error === 'string' ? body.error : 'storage_failed');
return body;
}
export async function fetchArchive(owner: ArchiveOwner): Promise<ArchiveEnvelope | null> {
return (await request(owner)).envelope ?? null;
}
export async function putArchive(owner: ArchiveOwner, envelope: ArchiveEnvelope): Promise<void> {
await request(owner, { method: 'PUT', body: JSON.stringify({ envelope }) });
}
export async function removeArchive(owner: ArchiveOwner): Promise<void> {
await request(owner, { method: 'DELETE' });
}
/** This browser's accounts as the archive keeps them: no credentials of any kind. */
export function collectAccounts(): ArchivedAccount[] {
return useAccountStore.getState().accounts.map(a => ({
username: a.username, serverUrl: a.serverUrl, label: a.label, avatarColor: a.avatarColor,
}));
}
/**
* Adds the archived accounts this browser lacks, each waiting for its own
* sign-in, and gives the ones it has their archived label and colour.
*/
export function restoreAccounts(archived: ArchivedAccount[]): { added: number; updated: number; skipped: number } {
const store = useAccountStore.getState();
let added = 0, updated = 0, skipped = 0;
for (const entry of archived) {
const id = generateAccountId(entry.username, entry.serverUrl);
if (store.getAccountById(id)) {
store.updateAccount(id, { label: entry.label, avatarColor: entry.avatarColor });
updated++;
continue;
}
if (useAccountStore.getState().accounts.length >= getMaxAccounts()) { skipped++; continue; }
store.addAccount({
username: entry.username, serverUrl: entry.serverUrl, authMode: 'basic', rememberMe: false,
label: entry.label, displayName: entry.label, email: entry.username, lastLoginAt: 0,
isConnected: false, hasError: true, isDefault: false, awaitingSignIn: true,
});
store.updateAccount(id, { avatarColor: entry.avatarColor });
added++;
}
return { added, updated, skipped };
}
+41
View File
@@ -0,0 +1,41 @@
import { createHash, randomBytes } from 'node:crypto';
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
import path from 'node:path';
import { archiveIdentity, parseArchiveEnvelope, type ArchiveEnvelope, type ArchiveOwner } from './account-archive';
/** One file per owner: `SETTINGS_DATA_DIR/account-archives/<sha256 of the owner>.json`. */
function archiveDir(): string {
return path.join(process.env.SETTINGS_DATA_DIR || path.join(process.cwd(), 'data', 'settings'), 'account-archives');
}
function archivePath(owner: ArchiveOwner): string {
return path.join(archiveDir(), createHash('sha256').update(archiveIdentity(owner)).digest('hex') + '.json');
}
export async function loadArchive(owner: ArchiveOwner): Promise<ArchiveEnvelope | null> {
try { return parseArchiveEnvelope(JSON.parse(await readFile(archivePath(owner), 'utf8'))); }
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}
/** Replaces the owner's archive atomically: a reader sees the old file or the new one, never half of it. */
export async function saveArchive(owner: ArchiveOwner, envelope: ArchiveEnvelope): Promise<void> {
const target = archivePath(owner);
const temporary = `${target}.${randomBytes(8).toString('hex')}.tmp`;
await mkdir(archiveDir(), { recursive: true, mode: 0o700 });
try {
const file = await open(temporary, 'wx', 0o600);
try { await file.writeFile(JSON.stringify(parseArchiveEnvelope(envelope))); await file.sync(); }
finally { await file.close(); }
await rename(temporary, target);
} finally {
await unlink(temporary).catch(() => {});
}
}
export async function deleteArchive(owner: ArchiveOwner): Promise<void> {
await unlink(archivePath(owner)).catch(err => {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
});
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Encrypted list of a user's accounts, kept on the server so another browser
* can bring the same accounts back. Encryption happens in the browser with a
* password the user chooses; the server only ever sees the envelope.
*
* The list holds no mailbox passwords: each restored account is signed in to
* by hand.
*/
export interface ArchiveOwner { username: string; serverUrl: string }
export interface ArchivedAccount extends ArchiveOwner { label: string; avatarColor: string }
export interface ArchiveEnvelope {
version: 1;
iterations: typeof ARCHIVE_ITERATIONS;
salt: string;
iv: string;
ciphertext: string;
}
export const ARCHIVE_ITERATIONS = 600000;
export const ARCHIVE_MAX_BYTES = 64 * 1024;
export const ARCHIVE_MAX_ACCOUNTS = 50;
export const ARCHIVE_MIN_PASSWORD = 10;
const trimUrl = (url: string) => url.replace(/\/+$/, '');
/**
* One owner however the username is cased: `Lu@example.com` and
* `lu@example.com` sign in to the same mailbox, so they share the archive.
*/
export function archiveIdentity(owner: ArchiveOwner): string {
if (typeof owner?.username !== 'string' || !owner.username.trim() || owner.username.length > 320
|| typeof owner.serverUrl !== 'string' || !owner.serverUrl || owner.serverUrl.length > 2048) {
throw new Error('invalid_archive');
}
return JSON.stringify([owner.username.trim().toLowerCase(), trimUrl(owner.serverUrl)]);
}
/** Strict allowlist: nothing but the envelope fields is stored or echoed. */
export function parseArchiveEnvelope(value: unknown): ArchiveEnvelope {
const v = value as ArchiveEnvelope | null;
const base64 = (s: unknown, bytes: (n: number) => boolean): s is string => {
if (typeof s !== 'string' || s.length > ARCHIVE_MAX_BYTES || !/^[A-Za-z0-9+/]+={0,2}$/.test(s) || s.length % 4) return false;
try { return bytes(atob(s).length); } catch { return false; }
};
if (!v || typeof v !== 'object' || v.version !== 1 || v.iterations !== ARCHIVE_ITERATIONS
|| !base64(v.salt, n => n === 16) || !base64(v.iv, n => n === 12) || !base64(v.ciphertext, n => n > 16)) {
throw new Error('invalid_archive');
}
return { version: 1, iterations: ARCHIVE_ITERATIONS, salt: v.salt, iv: v.iv, ciphertext: v.ciphertext };
}
export function parseArchivedAccounts(value: unknown): ArchivedAccount[] {
if (!Array.isArray(value) || value.length < 1 || value.length > ARCHIVE_MAX_ACCOUNTS) throw new Error('invalid_archive');
return value.map((a: ArchivedAccount) => {
archiveIdentity(a);
if (typeof a.label !== 'string' || a.label.length > 320
|| typeof a.avatarColor !== 'string' || !/^#[0-9a-f]{6}$/i.test(a.avatarColor)) throw new Error('invalid_archive');
// Rebuilt field by field, so nothing else (a password, say) survives a round trip.
return { username: a.username.trim(), serverUrl: trimUrl(a.serverUrl), label: a.label, avatarColor: a.avatarColor };
});
}
const encode = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes));
const decode = (text: string) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
// The owner is sealed in: an envelope copied to another owner's slot will not open there.
const additionalData = (owner: ArchiveOwner) => new TextEncoder().encode(`bulwark-account-archive:1:${archiveIdentity(owner)}`);
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
if (!globalThis.crypto?.subtle) throw new Error('https_required');
const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ARCHIVE_ITERATIONS },
material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
export async function encryptArchive(owner: ArchiveOwner, accounts: ArchivedAccount[], password: string): Promise<ArchiveEnvelope> {
if (password.length < ARCHIVE_MIN_PASSWORD || password.length > 1024) throw new Error('password_length');
const plaintext = new TextEncoder().encode(JSON.stringify({ accounts: parseArchivedAccounts(accounts) }));
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(password, salt);
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: additionalData(owner) }, key, plaintext);
return parseArchiveEnvelope({ version: 1, iterations: ARCHIVE_ITERATIONS, salt: encode(salt), iv: encode(iv),
ciphertext: encode(new Uint8Array(ciphertext)) });
}
export async function decryptArchive(owner: ArchiveOwner, envelope: ArchiveEnvelope, password: string): Promise<ArchivedAccount[]> {
const clean = parseArchiveEnvelope(envelope);
if (password.length > 1024) throw new Error('unlock_failed');
const key = await deriveKey(password, decode(clean.salt));
let plaintext: ArrayBuffer;
try {
plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: decode(clean.iv), additionalData: additionalData(owner) },
key, decode(clean.ciphertext));
} catch { throw new Error('unlock_failed'); }
return parseArchivedAccounts((JSON.parse(new TextDecoder().decode(plaintext)) as { accounts?: unknown }).accounts);
}
+35
View File
@@ -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);
}
+1 -1
View File
@@ -122,7 +122,7 @@ export function clearAllStores(): void {
isPushConnected: false,
lastPushUpdate: null,
newEmailNotification: null,
selectedEmailIds: new Set<string>(),
selectedEmailKeys: new Set<string>(),
hasMoreEmails: false,
totalEmails: 0,
expandedThreadIds: new Set<string>(),
+3
View File
@@ -67,6 +67,8 @@ export interface FeatureGates {
crossStarredViewEnabled: boolean;
crossAllViewEnabled: boolean;
unifiedCrossAccountEnabled: boolean;
/** Encrypted account list kept on the server, restorable on another device. */
accountArchiveEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -94,6 +96,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
crossStarredViewEnabled: false,
crossAllViewEnabled: false,
unifiedCrossAccountEnabled: false,
accountArchiveEnabled: false,
};
export interface ThemePolicy {
+68
View File
@@ -0,0 +1,68 @@
import type { Email } from '@/lib/jmap/types';
/**
* The last list seen in each folder, so opening a folder paints the rows it
* had a moment ago while the fresh page loads behind them - and keeps them if
* that load fails. On a slow server or a large folder the difference is an
* instant folder against seconds of the previous folder's mail under a
* spinner.
*
* In memory only: mail does not outlive the tab here, unlike the one-list boot
* snapshot in the email store, and the live fetch always runs.
*/
export interface FolderList {
emails: Email[];
totalEmails: number;
hasMoreEmails: boolean;
}
/** Folders remembered per tab; the oldest visited is dropped first. */
const MAX_FOLDERS = 30;
const lists = new Map<string, FolderList>();
/**
* What a remembered list belongs to. Folder ids repeat across accounts and
* servers, and a category tab or another sort
* order is another list of the same folder.
*/
export function folderListKey(parts: {
login: string | null;
viewingAccountId: string | null;
accountId: string;
mailboxId: string;
filter: unknown;
order: unknown;
}): string {
return JSON.stringify([
parts.login ?? '',
parts.viewingAccountId ?? '',
parts.accountId,
parts.mailboxId,
parts.filter ?? null,
parts.order ?? null,
]);
}
export function rememberedFolderList(key: string): FolderList | undefined {
return lists.get(key);
}
export function rememberFolderList(key: string, list: FolderList, pageSize: number): void {
lists.delete(key);
lists.set(key, {
// One page is what a folder opens on; load-more fetches the rest again.
emails: list.emails.slice(0, pageSize),
totalEmails: list.totalEmails,
hasMoreEmails: list.hasMoreEmails || list.emails.length > pageSize,
});
while (lists.size > MAX_FOLDERS) {
const oldest = lists.keys().next().value;
if (oldest === undefined) break;
lists.delete(oldest);
}
}
export function forgetFolderLists(): void {
lists.clear();
}
+2
View File
@@ -162,6 +162,8 @@ export function applyLitePolicy(raw: unknown): SettingsPolicy {
pluginsEnabled: false,
pluginsUploadEnabled: false,
sidebarAppsEnabled: false,
// Kept by this app's server, which Lite does not have.
accountArchiveEnabled: false,
},
forceEnabledPlugins: [],
approvedPlugins: [],
+24 -2
View File
@@ -1,6 +1,6 @@
// Plugin Hook Bus - event bus system for plugin lifecycle hooks
import type { Disposable } from './plugin-types';
import type { CalendarEventFormView, Disposable } from './plugin-types';
// ─── Error Tracker (Circuit Breaker) ─────────────────────────
@@ -369,9 +369,31 @@ export const calendarHooks = {
};
// §7.2b Calendar Form Hooks (UI integration)
/** What onCalendarEventFormSave handlers may set on the event being saved. */
export interface CalendarEventFormSavePatch {
virtualLocation?: string;
}
export const calendarFormHooks = {
onCalendarEventFormOpen: new HookBus(),
onCalendarEventFormSave: new HookBus(),
/**
* Fires while the event form is being saved. Handlers receive the patch
* accumulated so far and the form view (including the event uid and
* attendee emails), and may return a partial patch that the form merges
* into the saved event, so a meeting plugin can refresh its link in the
* same save that sends scheduling messages and attendees receive the
* current URL. Returning undefined leaves the patch unchanged.
*/
onCalendarEventFormSave: new HookBus<
(
patch: CalendarEventFormSavePatch,
view: CalendarEventFormView,
) =>
| Promise<CalendarEventFormSavePatch | undefined>
| CalendarEventFormSavePatch
| undefined
>(),
};
// §7.3 Contact Hooks
+4
View File
@@ -379,6 +379,10 @@ export interface CalendarEventFormView {
location: string;
virtualLocation: string;
calendarId: string;
/** JMAP uid of the event being edited; undefined while composing a new one. */
uid?: string;
/** Attendee email addresses currently on the form. */
attendees: string[];
}
export interface KeyboardShortcut {
+17
View File
@@ -19,6 +19,23 @@ export function threadKeyFor(
return scope ? `${scope}:${email.threadId}` : email.threadId;
}
/**
* Client-side identity of a single message.
*
* Email ids are per-account too, so in a merged view two unrelated messages can
* share an `id` - `eiaaaabc` exists in every account that got that far. Batch
* selection is what makes that dangerous: keyed by the bare id, ticking one row
* also ticks its namesake in another account, and delete, move or archive then
* act on both. Same scoping as `threadKeyFor`; unstamped emails keep the bare
* id, so single-account views are untouched.
*/
export function emailKeyFor(
email: Pick<Email, "id" | "sourceClientAccountId" | "sourceAccountId">,
): string {
const scope = [email.sourceClientAccountId, email.sourceAccountId].filter(Boolean).join("/");
return scope ? `${scope}:${email.id}` : email.id;
}
/**
* The JMAP thread id behind a `threadKeyFor` key. JMAP ids never contain ":"
* (RFC 8620 §1.2 limits them to the URL-safe base64 alphabet), so the last
+53
View File
@@ -777,3 +777,56 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolea
export function resetWebPushResyncState(): void {
lastResyncAt.clear();
}
export interface BulkWebPushTarget {
accountId: string;
client: IJMAPClient;
accountLabel?: string;
}
export interface BulkWebPushResult {
enabled: string[];
/** Accounts left untouched, with the reason the first failure gave. */
failed: Array<{ accountId: string; error: unknown }>;
}
/**
* Enable push for several accounts against one browser subscription.
*
* The browser only ever holds a single PushSubscription: `enableWebPush` reuses
* it and registers one server-side subscription per account, which is why every
* account has to be walked separately. Sequential on purpose - the first call
* is the one that may prompt for permission, and parallel prompts race.
*
* A browser-level refusal (unsupported, permission denied or dismissed) applies
* to every account, so it stops the walk instead of failing them one by one.
*/
export async function enableWebPushForAccounts(
targets: BulkWebPushTarget[],
// inboxOnly mirrors settings-store's pushNotifyInboxOnly, as for a single account.
options: { relayBaseUrl?: string; forceRecreate?: boolean; inboxOnly: boolean },
): Promise<BulkWebPushResult> {
const result: BulkWebPushResult = { enabled: [], failed: [] };
let browserRefusal: unknown;
for (const target of targets) {
if (browserRefusal !== undefined) {
result.failed.push({ accountId: target.accountId, error: browserRefusal });
continue;
}
try {
await enableWebPush({
client: target.client,
relayBaseUrl: options.relayBaseUrl,
accountLabel: target.accountLabel,
forceRecreate: options.forceRecreate,
inboxOnly: options.inboxOnly,
});
result.enabled.push(target.accountId);
} catch (error) {
if (error instanceof WebPushUnsupportedError || !isWebPushSupported()
|| Notification.permission !== 'granted') browserRefusal = error;
result.failed.push({ accountId: target.accountId, error });
}
}
return result;
}
+55 -2
View File
@@ -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": "إظهار الانتقالات والتأثيرات السلسة"
@@ -1256,6 +1268,18 @@
"confirm_revoke_message": "سيتوقّف فورًا عن تلقّي الإشعارات في الخلفية. تفعيل الدفع مجددًا على ذلك الجهاز سيسجّله من جديد.",
"confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية."
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "صوت الإشعار",
"description": "اختر الصوت الذي يُشغَّل للإشعارات",
@@ -1666,6 +1690,33 @@
"drag_handle": "اسحب لإعادة الترتيب",
"add": "إضافة حساب"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "مشترك معي",
"description": "الحسابات الجماعية والمشتركة التي يمكنك إدارتها. اختر واحدًا لتعديل مرشحاته، والرد التلقائي أثناء الغياب، والتقويمات، وجهات الاتصال.",
@@ -2499,7 +2550,8 @@
"next_email": "الرسالة التالية",
"previous_email": "الرسالة السابقة",
"open_email": "فتح الرسالة",
"close_email": "إغلاق / إلغاء التحديد"
"close_email": "إغلاق / إلغاء التحديد",
"go_to_folder": "الانتقال إلى المجلد"
},
"actions": {
"reply": "رد",
@@ -2521,7 +2573,8 @@
"global_search": "البحث في كل شيء"
},
"threads": {
"expand_collapse": "توسيع/طي المحادثة"
"expand_collapse": "توسيع/طي المحادثة",
"select_mountain_view": "تحديد المحادثة"
},
"composer": {
"send": "إرسال الرسالة",
+55 -2
View File
@@ -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"
@@ -1256,6 +1268,18 @@
"confirm_revoke_message": "Deixarà de rebre notificacions en segon pla immediatament. Si hi tornes a activar les notificacions push, es registrarà de nou.",
"confirm_revoke_message_this": "Aquest dispositiu deixarà de rebre notificacions en segon pla immediatament."
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "So de notificació",
"description": "Trieu quin so es reprodueix per a les notificacions",
@@ -1666,6 +1690,33 @@
"drag_handle": "Arrossegueu per reordenar",
"add": "Afegeix un compte"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Compartit amb mi",
"description": "Comptes de grup i compartits que podeu gestionar. Seleccioneu-ne un per editar-ne els filtres, la resposta automàtica, els calendaris i els contactes.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1217,6 +1229,18 @@
"swift": "Rychlý",
"relax": "Relaxační"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Zvuk oznámení",
"description": "Vyberte zvuk, který se přehraje při oznámení",
@@ -1663,6 +1687,33 @@
"drag_handle": "Přetažením změňte pořadí",
"add": "Přidat účet"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Sdíleno se mnou",
"description": "Skupinové a sdílené účty, které můžete spravovat. Vyberte jeden pro úpravu jeho filtrů, odpovědi v nepřítomnosti, kalendářů a kontaktů.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1256,6 +1268,18 @@
"confirm_revoke_message": "Den stopper med at modtage baggrundsnotifikationer med det samme. Slår du push til igen på enheden, registreres den på ny.",
"confirm_revoke_message_this": "Denne enhed stopper med at modtage baggrundsnotifikationer med det samme."
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Notifikationslyd",
"description": "Vælg hvilken lyd der skal afspilles for notifikationer",
@@ -1666,6 +1690,33 @@
"drag_handle": "Træk for at omarrangere",
"add": "Tilføj konto"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Delt med mig",
"description": "Gruppe- og delte konti, du kan administrere. Vælg en for at redigere dens filtre, autosvar ved fravær, kalendere og kontakter.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1217,6 +1229,18 @@
"swift": "Schnelle Geste",
"relax": "Entspannt"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Benachrichtigungston",
"description": "Wählen Sie den Ton für Benachrichtigungen",
@@ -1663,6 +1687,33 @@
"drag_handle": "Zum Sortieren ziehen",
"add": "Konto hinzufügen"
},
"backup": {
"title": "Sicherung der Kontoliste",
"description": "Speichert die Liste der Konten in diesem Browser auf dem Server, verschlüsselt mit einem Passwort, das nur Sie kennen, und stellt sie auf einem anderen Gerät wieder her. Postfachpasswörter werden nicht gespeichert: Sie melden sich bei jedem wiederhergestellten Konto selbst an.",
"status_none": "Für {account} ist keine Liste gespeichert.",
"status_saved": "Für {account} ist eine Liste gespeichert.",
"save": "Liste speichern",
"restore": "Liste wiederherstellen",
"delete": "Gespeicherte Liste löschen",
"cancel": "Abbrechen",
"delete_confirm": "Die gespeicherte Kontoliste vom Server löschen?",
"password": "Sicherungspasswort",
"password_hint": "Mindestens 10 Zeichen. Es kann nicht wiederhergestellt werden: Wenn Sie es vergessen, speichern Sie die Liste erneut mit einem neuen.",
"restore_hint": "Das Passwort, das beim Speichern der Liste gewählt wurde.",
"confirm": "Passwort bestätigen",
"saved": "Liste gespeichert ({count, plural, one {# Konto} other {# Konten}}).",
"restored": "Hinzugefügte Konten: {added}. Bereits vorhandene, aktualisierte Konten: {updated}. Öffnen Sie jedes neue Konto in der Kontoliste, um sich anzumelden.",
"skipped": "{count, plural, one {# Konto wurde} other {# Konten wurden}} nicht hinzugefügt: Die Höchstzahl an Konten ist erreicht.",
"deleted": "Gespeicherte Liste gelöscht.",
"errors": {
"mismatch": "Die Passwörter stimmen nicht überein.",
"password_length": "Verwenden Sie mindestens 10 Zeichen.",
"unlock_failed": "Falsches Passwort, oder die Liste wurde für ein anderes Konto gespeichert.",
"owner_signin_required": "Melden Sie sich erneut bei diesem Konto an und versuchen Sie es dann noch einmal.",
"https_required": "Dafür ist eine sichere (HTTPS-)Verbindung nötig.",
"storage_failed": "Der Server konnte die Liste nicht lesen oder speichern. Versuchen Sie es später erneut."
}
},
"shared_accounts": {
"title": "Mit mir geteilt",
"description": "Gruppen- und freigegebene Konten, die Sie verwalten können. Wählen Sie eines aus, um dessen Filter, Abwesenheitsnotiz, Kalender und Kontakte zu bearbeiten.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1256,6 +1268,18 @@
"confirm_revoke_message": "It will stop receiving background notifications immediately. Enabling push again on that device registers it anew.",
"confirm_revoke_message_this": "This device will stop receiving background notifications immediately."
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Notification Sound",
"description": "Choose which sound to play for notifications",
@@ -1666,6 +1690,33 @@
"drag_handle": "Drag to reorder",
"add": "Add account"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Shared with me",
"description": "Group and shared accounts you can manage. Select one to edit its filters, vacation responder, calendars, and contacts.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1217,6 +1229,18 @@
"swift": "Gesto rápido",
"relax": "Relajado"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Sonido de notificación",
"description": "Elige qué sonido reproducir para las notificaciones",
@@ -1663,6 +1687,33 @@
"drag_handle": "Arrastra para reordenar",
"add": "Añadir cuenta"
},
"backup": {
"title": "Copia de la lista de cuentas",
"description": "Guarda en el servidor la lista de cuentas de este navegador, cifrada con una contraseña que solo tú conoces, y recupérala en otro dispositivo. Las contraseñas de los buzones no se guardan: inicias sesión tú en cada cuenta restaurada.",
"status_none": "No hay ninguna lista guardada para {account}.",
"status_saved": "Hay una lista guardada para {account}.",
"save": "Guardar lista",
"restore": "Restaurar lista",
"delete": "Eliminar lista guardada",
"cancel": "Cancelar",
"delete_confirm": "¿Eliminar del servidor la lista de cuentas guardada?",
"password": "Contraseña de la copia",
"password_hint": "Al menos 10 caracteres. No se puede recuperar: si la olvidas, vuelve a guardar la lista con una nueva.",
"restore_hint": "La contraseña elegida al guardar la lista.",
"confirm": "Confirmar contraseña",
"saved": "Lista guardada ({count, plural, one {# cuenta} other {# cuentas}}).",
"restored": "Cuentas añadidas: {added}. Cuentas ya presentes y actualizadas: {updated}. Abre cada cuenta nueva desde la lista de cuentas para iniciar sesión.",
"skipped": "{count, plural, one {# cuenta no se ha añadido} other {# cuentas no se han añadido}}: se ha alcanzado el límite de cuentas.",
"deleted": "Lista guardada eliminada.",
"errors": {
"mismatch": "Las contraseñas no coinciden.",
"password_length": "Usa al menos 10 caracteres.",
"unlock_failed": "Contraseña incorrecta, o la lista se guardó para otra cuenta.",
"owner_signin_required": "Vuelve a iniciar sesión en esta cuenta y reinténtalo.",
"https_required": "Se necesita una conexión segura (HTTPS).",
"storage_failed": "El servidor no ha podido leer ni guardar la lista. Inténtalo más tarde."
}
},
"shared_accounts": {
"title": "Compartido conmigo",
"description": "Cuentas de grupo y compartidas que puedes gestionar. Selecciona una para editar sus filtros, respuesta de vacaciones, calendarios y contactos.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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 و افکت‌های نرم"
@@ -1256,6 +1268,18 @@
"confirm_revoke_message": "بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند. با روشن‌کردن دوبارهٔ پوش روی آن دستگاه، دوباره ثبت می‌شود.",
"confirm_revoke_message_this": "این دستگاه بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند."
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "صدای اعلان",
"description": "صدای پخش شده برای اعلان‌ها",
@@ -1666,6 +1690,33 @@
"drag_handle": "کشیدن",
"add": "افزودن"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "اشتراکی با من",
"description": "حساب‌های گروهی",
@@ -2499,7 +2550,8 @@
"next_email": "ایمیل بعدی",
"previous_email": "ایمیل قبلی",
"open_email": "باز کردن ایمیل",
"close_email": "بستن / لغو انتخاب"
"close_email": "بستن / لغو انتخاب",
"go_to_folder": "رفتن به پوشه"
},
"actions": {
"reply": "پاسخ",
@@ -2521,7 +2573,8 @@
"global_search": "جستجو در همه جا"
},
"threads": {
"expand_collapse": "باز/بسته کردن رشته"
"expand_collapse": "باز/بسته کردن رشته",
"select_mountain_view": "انتخاب گفت‌وگو"
},
"composer": {
"send": "ارسال ایمیل",
+55 -2
View File
@@ -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"
@@ -1217,6 +1229,18 @@
"swift": "Geste rapide",
"relax": "Détente"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Son de notification",
"description": "Choisissez le son à jouer pour les notifications",
@@ -1663,6 +1687,33 @@
"drag_handle": "Faire glisser pour réorganiser",
"add": "Ajouter un compte"
},
"backup": {
"title": "Sauvegarde de la liste des comptes",
"description": "Conservez sur le serveur la liste des comptes de ce navigateur, chiffrée avec un mot de passe que vous seul connaissez, et retrouvez-la sur un autre appareil. Les mots de passe des boîtes ne sont pas enregistrés : vous vous connectez vous-même à chaque compte restauré.",
"status_none": "Aucune liste enregistrée pour {account}.",
"status_saved": "Une liste est enregistrée pour {account}.",
"save": "Enregistrer la liste",
"restore": "Restaurer la liste",
"delete": "Supprimer la liste enregistrée",
"cancel": "Annuler",
"delete_confirm": "Supprimer du serveur la liste de comptes enregistrée ?",
"password": "Mot de passe de la sauvegarde",
"password_hint": "Au moins 10 caractères. Il ne peut pas être récupéré : si vous l'oubliez, enregistrez à nouveau la liste avec un nouveau mot de passe.",
"restore_hint": "Le mot de passe choisi lors de l'enregistrement de la liste.",
"confirm": "Confirmer le mot de passe",
"saved": "Liste enregistrée ({count, plural, one {# compte} other {# comptes}}).",
"restored": "Comptes ajoutés : {added}. Comptes déjà présents et mis à jour : {updated}. Ouvrez chaque nouveau compte depuis la liste des comptes pour vous connecter.",
"skipped": "{count, plural, one {# compte n'a pas été ajouté} other {# comptes n'ont pas été ajoutés}} : la limite de comptes est atteinte.",
"deleted": "Liste enregistrée supprimée.",
"errors": {
"mismatch": "Les mots de passe ne correspondent pas.",
"password_length": "Utilisez au moins 10 caractères.",
"unlock_failed": "Mot de passe incorrect, ou la liste a été enregistrée pour un autre compte.",
"owner_signin_required": "Reconnectez-vous à ce compte, puis réessayez.",
"https_required": "Une connexion sécurisée (HTTPS) est nécessaire.",
"storage_failed": "Le serveur n'a pas pu lire ou enregistrer la liste. Réessayez plus tard."
}
},
"shared_accounts": {
"title": "Partagé avec moi",
"description": "Comptes de groupe et partagés que vous pouvez gérer. Sélectionnez-en un pour modifier ses filtres, son répondeur d'absence, ses calendriers et ses contacts.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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": "הצג מעברים ואפקטים חלקים"
@@ -1182,6 +1194,18 @@
"swift": "מחווה מהירה",
"relax": "תירגע"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "צליל התראה",
"description": "בחר איזה צליל להפעיל עבור התראות",
@@ -1628,6 +1652,33 @@
"drag_handle": "גרור לסדר",
"add": "הוסף חשבון"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "משותף איתי",
"description": "חשבונות קבוצתיים ומשותפים שאתה יכול לנהל. בחר אחד כדי לערוך את המסננים, המגיב לחופשה, לוחות השנה והאנשים שלו.",
@@ -2423,7 +2474,8 @@
"next_email": "המייל הבא",
"previous_email": "אימייל קודם",
"open_email": "פתח אימייל",
"close_email": "סגור / בטל בחירה"
"close_email": "סגור / בטל בחירה",
"go_to_folder": "מעבר לתיקייה"
},
"actions": {
"reply": "השב",
@@ -2445,7 +2497,8 @@
"global_search": "חיפוש בכל מקום"
},
"threads": {
"expand_collapse": "הרחב/כווץ את השרשור"
"expand_collapse": "הרחב/כווץ את השרשור",
"select_mountain_view": "בחירת השיחה"
},
"composer": {
"template_picker": "פתח את בוחר התבניות",
+55 -2
View File
@@ -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"
@@ -1256,6 +1268,18 @@
"confirm_revoke_message": "Azonnal nem kap több háttérértesítést. Ha az eszközön újra bekapcsolja a push értesítéseket, újra regisztrálódik.",
"confirm_revoke_message_this": "Ez az eszköz azonnal nem kap több háttérértesítést."
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Értesítési hang",
"description": "Válaszd ki az értesítésekhez használt hangot",
@@ -1666,6 +1690,33 @@
"drag_handle": "Húzd át a sorrend módosításához",
"add": "Fiók hozzáadása"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Velem megosztva",
"description": "Csoportos és megosztott fiókok, amelyeket kezelhet. Válasszon egyet a szűrői, távolléti válasza, naptárai és névjegyei szerkesztéséhez.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1217,6 +1229,18 @@
"swift": "Gesto veloce",
"relax": "Rilassante"
},
"push_all": {
"title": "Tutti gli account",
"description": "Attiva o disattiva le notifiche per più account in una volta sola. Una sola iscrizione del browser li serve tutti.",
"select_all": "Seleziona tutti gli account collegati",
"state_on": "notifiche attive",
"state_off": "notifiche non attive",
"enable_selected": "Attiva per i selezionati",
"disable_selected": "Disattiva per i selezionati",
"result_enabled": "Notifiche attivate per {count} account.",
"result_partial": "Attivate per {enabled} account; {failed} non è stato possibile attivarle.",
"result_disabled": "Notifiche disattivate per {count} account."
},
"sound_selection": {
"title": "Suono di notifica",
"description": "Scegli quale suono riprodurre per le notifiche",
@@ -1663,6 +1687,33 @@
"drag_handle": "Trascina per riordinare",
"add": "Aggiungi account"
},
"backup": {
"title": "Backup dell'elenco account",
"description": "Conserva sul server l'elenco degli account di questo browser, cifrato con una password che conosci solo tu, e ripristinalo su un altro dispositivo. Le password delle caselle non vengono salvate: accedi tu a ogni account ripristinato.",
"status_none": "Nessun elenco salvato per {account}.",
"status_saved": "C'è un elenco salvato per {account}.",
"save": "Salva elenco",
"restore": "Ripristina elenco",
"delete": "Elimina elenco salvato",
"cancel": "Annulla",
"delete_confirm": "Eliminare dal server l'elenco account salvato?",
"password": "Password del backup",
"password_hint": "Almeno 10 caratteri. Non si può recuperare: se la dimentichi, salva di nuovo l'elenco con una nuova password.",
"restore_hint": "La password scelta quando l'elenco è stato salvato.",
"confirm": "Conferma password",
"saved": "Elenco salvato ({count, plural, one {# account} other {# account}}).",
"restored": "Account aggiunti: {added}. Account già presenti e aggiornati: {updated}. Apri ogni nuovo account dall'elenco account per accedere.",
"skipped": "{count, plural, one {# account non è stato aggiunto} other {# account non sono stati aggiunti}}: è stato raggiunto il limite di account.",
"deleted": "Elenco salvato eliminato.",
"errors": {
"mismatch": "Le password non coincidono.",
"password_length": "Usa almeno 10 caratteri.",
"unlock_failed": "Password errata, oppure l'elenco è stato salvato per un altro account.",
"owner_signin_required": "Accedi di nuovo a questo account, poi riprova.",
"https_required": "Serve una connessione sicura (HTTPS).",
"storage_failed": "Il server non è riuscito a leggere o salvare l'elenco. Riprova più tardi."
}
},
"shared_accounts": {
"title": "Condivisi con me",
"description": "Account di gruppo e condivisi che puoi gestire. Selezionane uno per modificarne filtri, risponditore automatico, calendari e contatti.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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": "スムーズな遷移とエフェクトを表示"
@@ -1217,6 +1229,18 @@
"swift": "スウィフトジェスチャー",
"relax": "リラックス"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "通知音",
"description": "通知に使用する音を選択",
@@ -1663,6 +1687,33 @@
"drag_handle": "ドラッグして並べ替え",
"add": "アカウントを追加"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "自分と共有",
"description": "管理できるグループアカウントと共有アカウントです。1つ選択して、フィルター、不在応答、カレンダー、連絡先を編集します。",
@@ -2499,7 +2550,8 @@
"next_email": "次のメール",
"previous_email": "前のメール",
"open_email": "メールを開く",
"close_email": "閉じる / 選択解除"
"close_email": "閉じる / 選択解除",
"go_to_folder": "フォルダーに移動"
},
"actions": {
"reply": "返信",
@@ -2521,7 +2573,8 @@
"global_search": "すべてを検索"
},
"threads": {
"expand_collapse": "スレッドの展開/折りたたみ"
"expand_collapse": "スレッドの展開/折りたたみ",
"select_mountain_view": "スレッドを選択"
},
"composer": {
"send": "メールを送信",
+55 -2
View File
@@ -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": "부드러운 전환 효과를 사용해요"
@@ -1217,6 +1229,18 @@
"swift": "빠르게",
"relax": "차분하게"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "알림음",
"description": "알림이 올 때 재생할 소리를 선택해 주세요",
@@ -1663,6 +1687,33 @@
"drag_handle": "끌어서 순서 변경",
"add": "계정 추가"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "나와 공유됨",
"description": "관리할 수 있는 그룹 및 공유 계정입니다. 하나를 선택하여 필터, 부재 중 자동 응답, 캘린더, 연락처를 편집하세요.",
@@ -2499,7 +2550,8 @@
"next_email": "다음 메일",
"previous_email": "이전 메일",
"open_email": "메일 열기",
"close_email": "닫기 / 선택 해제"
"close_email": "닫기 / 선택 해제",
"go_to_folder": "폴더로 이동"
},
"actions": {
"reply": "답장",
@@ -2521,7 +2573,8 @@
"global_search": "전체 검색"
},
"threads": {
"expand_collapse": "대화 펼치기/접기"
"expand_collapse": "대화 펼치기/접기",
"select_mountain_view": "대화 선택"
},
"composer": {
"send": "이메일 보내기",
+55 -2
View File
@@ -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"
@@ -1217,6 +1229,18 @@
"swift": "Ātrs",
"relax": "Relaksējošs"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Paziņojuma skaņa",
"description": "Izvēlieties skaņu paziņojumiem",
@@ -1663,6 +1687,33 @@
"drag_handle": "Velciet, lai pārkārtotu",
"add": "Pievienot kontu"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Kopīgots ar mani",
"description": "Grupas un kopīgotie konti, kurus varat pārvaldīt. Atlasiet vienu, lai rediģētu tā filtrus, prombūtnes atbildētāju, kalendārus un kontaktus.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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": "Гөлгөр шилжилт, эффектийг харуулах"
@@ -1256,6 +1268,18 @@
"confirm_revoke_message": "Тэр даруй араарх мэдэгдэл хүлээн авахаа болино. Тухайн төхөөрөмж дээр push-ыг дахин асаавал дахин бүртгэгднэ.",
"confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино."
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Мэдэгдэлийн дуу",
"description": "Мэдэгдэл авахын тулд ямар дууг тоглуулахаа сонгоно уу",
@@ -1666,6 +1690,33 @@
"drag_handle": "Дахин эрэмбэлэхийн тулд чирнэ үү",
"add": "Бүртгэл нэмэх"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Надтай хуваалцсан",
"description": "Бүлэг болон хуваалцсан акаунтуудыг удирдах боломжтой. Шүүлтүүр, амралтын хариулагч, хуанли, харилцагчдыг засахын тулд нэгийг нь сонгоно уу.",
@@ -2499,7 +2550,8 @@
"next_email": "Дараагийн имэйл",
"previous_email": "Өмнөх имэйл",
"open_email": "Имэйл нээх",
"close_email": "Хаах / Сонголтыг цуцлах"
"close_email": "Хаах / Сонголтыг цуцлах",
"go_to_folder": "Хавтас руу очих"
},
"actions": {
"reply": "Хариулах",
@@ -2521,7 +2573,8 @@
"global_search": "Бүгдийг хайх"
},
"threads": {
"expand_collapse": "Хэлхээсийг дэлгэх/хумих"
"expand_collapse": "Хэлхээсийг дэлгэх/хумих",
"select_mountain_view": "Харилцааг сонгох"
},
"composer": {
"send": "Имэйл илгээх",
+55 -2
View File
@@ -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"
@@ -1256,6 +1268,18 @@
"confirm_revoke_message": "Enheten slutter umiddelbart å motta bakgrunnsvarsler. Hvis pushvarsler aktiveres på nytt på enheten, registreres den på nytt.",
"confirm_revoke_message_this": "Denne enheten slutter umiddelbart å motta bakgrunnsvarsler."
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Varslingslyd",
"description": "Velg hvilken lyd som skal spilles av for varsler",
@@ -1666,6 +1690,33 @@
"drag_handle": "Dra for å omorganisere",
"add": "Legg til konto"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Delt med meg",
"description": "Gruppekontoer og delte kontoer du kan administrere. Velg en konto for å redigere filtre, feriesvar, kalendere og kontakter.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1217,6 +1229,18 @@
"swift": "Snel gebaar",
"relax": "Ontspannen"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Meldingsgeluid",
"description": "Kies welk geluid wordt afgespeeld voor meldingen",
@@ -1663,6 +1687,33 @@
"drag_handle": "Sleep om volgorde aan te passen",
"add": "Account toevoegen"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Gedeeld met mij",
"description": "Groeps- en gedeelde accounts die u kunt beheren. Selecteer er een om de filters, afwezigheidsassistent, agenda's en contacten te bewerken.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1217,6 +1229,18 @@
"swift": "Szybki gest",
"relax": "Relaks"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Dźwięk powiadomienia",
"description": "Wybierz dźwięk odtwarzany dla powiadomień",
@@ -1663,6 +1687,33 @@
"drag_handle": "Przeciągnij, aby zmienić kolejność",
"add": "Dodaj konto"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Udostępnione mnie",
"description": "Konta grupowe i udostępnione, którymi możesz zarządzać. Wybierz jedno, aby edytować jego filtry, odpowiedź wakacyjną, kalendarze i kontakty.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1217,6 +1229,18 @@
"swift": "Gesto rápido",
"relax": "Relaxante"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Som de notificação",
"description": "Escolha qual som reproduzir para notificações",
@@ -1663,6 +1687,33 @@
"drag_handle": "Arraste para reordenar",
"add": "Adicionar conta"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Compartilhado comigo",
"description": "Contas de grupo e compartilhadas que você pode gerenciar. Selecione uma para editar seus filtros, resposta de ausência, calendários e contatos.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1256,6 +1268,18 @@
"confirm_revoke_message": "Va înceta imediat să primească notificări în fundal. Reactivarea push pe acel dispozitiv îl va înregistra din nou.",
"confirm_revoke_message_this": "Acest dispozitiv va înceta imediat să primească notificări în fundal."
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Sunet de notificare",
"description": "Alegeți ce sunet să se redea pentru notificări",
@@ -1666,6 +1690,33 @@
"drag_handle": "Trageți pentru a reordona",
"add": "Adăugare cont"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Partajat cu mine",
"description": "Conturi de grup și conturi partajate pe care le puteți gestiona. Selectați unul pentru a-i edita filtrele, răspunsul automat de vacanță, calendarele și contactele.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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": "Показывать плавные переходы и эффекты"
@@ -1217,6 +1229,18 @@
"swift": "Быстрый жест",
"relax": "Релакс"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Звук уведомления",
"description": "Выберите звук для уведомлений",
@@ -1663,6 +1687,33 @@
"drag_handle": "Перетащите, чтобы изменить порядок",
"add": "Добавить учётную запись"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Доступные мне",
"description": "Групповые и общие учётные записи, которыми вы можете управлять. Выберите одну, чтобы изменить её фильтры, автоответчик, календари и контакты.",
@@ -2499,7 +2550,8 @@
"next_email": "Следующее письмо",
"previous_email": "Предыдущее письмо",
"open_email": "Открыть письмо",
"close_email": "Закрыть / Отменить выбор"
"close_email": "Закрыть / Отменить выбор",
"go_to_folder": "Перейти к папке"
},
"actions": {
"reply": "Ответить",
@@ -2521,7 +2573,8 @@
"global_search": "Искать везде"
},
"threads": {
"expand_collapse": "Развернуть/свернуть цепочку"
"expand_collapse": "Развернуть/свернуть цепочку",
"select_mountain_view": "Выбрать переписку"
},
"composer": {
"send": "Отправить письмо",
+55 -2
View File
@@ -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"
@@ -1220,6 +1232,18 @@
"swift": "Rýchly",
"relax": "Relaxačný"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Zvuk oznámenia",
"description": "Vyberte zvuk, ktorý sa prehrá pri oznámení",
@@ -1666,6 +1690,33 @@
"drag_handle": "Presuňte na zmenu poradia",
"add": "Pridať účet"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Zdieľané so mnou",
"description": "Skupinové a zdieľané účty, ktoré môžete spravovať.",
@@ -2499,7 +2550,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ť",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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"
@@ -1217,6 +1229,18 @@
"swift": "Hızlı Jest",
"relax": "Rahatla"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Bildirim Sesi",
"description": "Bildirimler için hangi sesin çalınacağını seçin",
@@ -1663,6 +1687,33 @@
"drag_handle": "Sıralamak için sürükleyin",
"add": "Hesap ekle"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Benimle paylaşılanlar",
"description": "Yönetebileceğiniz grup ve paylaşılan hesaplar. Filtrelerini, tatil yanıtlayıcısını, takvimlerini ve kişilerini düzenlemek için birini seçin.",
@@ -2499,7 +2550,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",
@@ -2521,7 +2573,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",
+55 -2
View File
@@ -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": "Показувати плавні переходи та ефекти"
@@ -1217,6 +1229,18 @@
"swift": "Швидкий жест",
"relax": "Розслабтеся"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "Звук сповіщення",
"description": "Виберіть звук для сповіщень",
@@ -1663,6 +1687,33 @@
"drag_handle": "Перетягніть, щоб змінити порядок",
"add": "Додати обліковий запис"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "Доступні мені",
"description": "Групові та спільні облікові записи, якими ви можете керувати. Виберіть один, щоб змінити його фільтри, автовідповідач, календарі та контакти.",
@@ -2499,7 +2550,8 @@
"next_email": "Наступний електронний лист",
"previous_email": "Попередній електронний лист",
"open_email": "Відкрийте електронну пошту",
"close_email": "Закрити / Скасувати вибір"
"close_email": "Закрити / Скасувати вибір",
"go_to_folder": "Перейти до теки"
},
"actions": {
"reply": "Відповісти",
@@ -2521,7 +2573,8 @@
"global_search": "Шукати скрізь"
},
"threads": {
"expand_collapse": "Розгорнути/згорнути ланцюжок"
"expand_collapse": "Розгорнути/згорнути ланцюжок",
"select_mountain_view": "Вибрати листування"
},
"composer": {
"send": "Надіслати лист",
+55 -2
View File
@@ -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": "顯示平順的轉場與效果"
@@ -1256,6 +1268,18 @@
"confirm_revoke_message": "此裝置會立即停止接收背景通知。若在該裝置上再次啟用推送,將重新註冊。",
"confirm_revoke_message_this": "此裝置會立即停止接收背景通知。"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "通知聲音",
"description": "選擇通知時播放的聲音",
@@ -1666,6 +1690,33 @@
"drag_handle": "拖曳以重新排序",
"add": "新增帳號"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "與我共用",
"description": "您可以管理的群組與共用帳號。選取一個帳號,即可編輯其篩選規則、休假自動回覆、行事曆與聯絡人。",
@@ -2499,7 +2550,8 @@
"next_email": "下一封郵件",
"previous_email": "上一封郵件",
"open_email": "開啟郵件",
"close_email": "關閉/取消選取"
"close_email": "關閉/取消選取",
"go_to_folder": "跳至資料夾"
},
"actions": {
"reply": "回覆",
@@ -2521,7 +2573,8 @@
"global_search": "全域搜尋"
},
"threads": {
"expand_collapse": "展開/收合對話串"
"expand_collapse": "展開/收合對話串",
"select_mountain_view": "選取會話"
},
"composer": {
"send": "傳送郵件",
+55 -2
View File
@@ -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": "显示平滑的过渡和效果"
@@ -1217,6 +1229,18 @@
"swift": "轻快手势",
"relax": "放松"
},
"push_all": {
"title": "All accounts",
"description": "Turn push on or off for several accounts at once. One browser subscription serves them all.",
"select_all": "Select every connected account",
"state_on": "notifications on",
"state_off": "notifications off",
"enable_selected": "Enable for selected",
"disable_selected": "Disable for selected",
"result_enabled": "Notifications enabled for {count} accounts.",
"result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.",
"result_disabled": "Notifications disabled for {count} accounts."
},
"sound_selection": {
"title": "通知声音",
"description": "选择通知时播放的声音",
@@ -1663,6 +1687,33 @@
"drag_handle": "拖动以重新排序",
"add": "添加账户"
},
"backup": {
"title": "Account list backup",
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
"status_none": "No list is saved for {account}.",
"status_saved": "A list is saved for {account}.",
"save": "Save list",
"restore": "Restore list",
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
"confirm": "Confirm password",
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
"deleted": "Saved list deleted.",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
"unlock_failed": "Wrong password, or the list was saved for another account.",
"owner_signin_required": "Sign in to this account again, then retry.",
"https_required": "This needs a secure (HTTPS) connection.",
"storage_failed": "The server could not read or store the list. Try again later."
}
},
"shared_accounts": {
"title": "与我共享",
"description": "您可以管理的群组和共享账户。选择一个以编辑其筛选器、休假自动回复、日历和联系人。",
@@ -2499,7 +2550,8 @@
"next_email": "下一封邮件",
"previous_email": "上一封邮件",
"open_email": "打开邮件",
"close_email": "关闭/取消选择"
"close_email": "关闭/取消选择",
"go_to_folder": "跳转到文件夹"
},
"actions": {
"reply": "回复",
@@ -2521,7 +2573,8 @@
"global_search": "全局搜索"
},
"threads": {
"expand_collapse": "展开/折叠会话"
"expand_collapse": "展开/折叠会话",
"select_mountain_view": "选择会话"
},
"composer": {
"send": "发送邮件",
+2
View File
@@ -105,6 +105,8 @@ export const LITE_API_STRING_ALLOWLIST = [
// Settings/policy - replaced by config.json / policy.json.
"/api/config",
"/api/admin/policy",
// lib/account-archive-client.ts - the feature is pinned off in Lite.
"/api/account-archive",
// WOPI (#425) - use-wopi-status returns disabled in Lite.
"/api/wopi/status",
"/api/wopi/launch",
@@ -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);

Some files were not shown because too many files have changed in this diff Show More