diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 62ec0967a..413ab869a 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -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(""); diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index bab81c8ad..b7cdfa667 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -38,11 +38,13 @@ const FEATURE_GATE_LABELS: Partial 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 { + 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('jmapServerUrl', ''), + parseJmapServers(configManager.get('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); } +} diff --git a/app/globals.css b/app/globals.css index 1e6aeb12a..5df163f6d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -948,3 +948,110 @@ optgroup { [dir="rtl"] .tabler-icon-layout-sidebar-right { transform: scaleX(-1); } + +/* ========================================================================== + Interface layouts + -------------------------------------------------------------------------- + `data-interface-layout` is set on 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); + } +} diff --git a/components/__tests__/push-notification-prompt.test.tsx b/components/__tests__/push-notification-prompt.test.tsx index d09e7d83b..fd71589b1 100644 --- a/components/__tests__/push-notification-prompt.test.tsx +++ b/components/__tests__/push-notification-prompt.test.tsx @@ -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 }>(), + 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(); + 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(); + 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(); + 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(); + 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(); diff --git a/components/calendar/__tests__/event-modal-form-save-hook.test.tsx b/components/calendar/__tests__/event-modal-form-save-hook.test.tsx new file mode 100644 index 000000000..a92453de0 --- /dev/null +++ b/components/calendar/__tests__/event-modal-form-save-hook.test.tsx @@ -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((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( + , + ); + + 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' }); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index e58f36c2b..e7f982bab 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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; 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 =
{refreshIndicator} + {mvShell && ( + + )}
{/* Left Navigation Rail (hidden when embedded in Pro shell) */} - {!isMobile && !isEmbedded && ( + {!isMobile && !isEmbedded && !mvShell && (
(() => { 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, }} diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 011a4afd0..944c6b496 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -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 =
{refreshIndicator} + {mvShell && ( + + )}
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */} - {!isMobile && !isEmbedded && ( + {!isMobile && !isEmbedded && !mvShell && (
+ ({ 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 = {}): 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( {}} />); + + 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( + {}} + onMarkFolderRead={() => {}} + onMarkAllFoldersRead={() => {}} + /> + ); + open(); + + expect(screen.getByText('mark_all_folders_read')).toBeInTheDocument(); + }); + + it('withholds emptying the folder outside spam and the bin', () => { + render( {}} 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( {}} 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( {}} />); + + 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( {}} />); + + fireEvent.click(screen.getByLabelText('tag')); + fireEvent.click(screen.getByText('Work')); + + await waitFor(() => expect(error).toHaveBeenCalledWith('error_updating')); + }); +}); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index ae627403d..aabbd2415 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -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(), + selectedEmailKeys: new Set(), 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(), + selectedEmailKeys: new Set(), selectedMailbox: 'inbox', }); }); @@ -200,9 +207,10 @@ describe('ThreadListItem row content', () => { emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', + interfaceLayout: 'default', }); useEmailStore.setState({ - selectedEmailIds: new Set(), + selectedEmailKeys: new Set(), 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( + {}} + onEmailSelect={() => {}} + onToggleStar={onToggleStar} + />, + ); + + const star = container.querySelector('[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( + {}} + 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(), + selectedEmailKeys: new Set(), 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(), + 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 = ( + {}} 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); + }); +}); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 0701150fd..df676ebb3 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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; onRescheduleScheduled?: (email: Email) => void | Promise; + /** + * 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({
); - 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(); 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" )} >
- {t('batch_actions.selected_messages', { count: selectedEmailIds.size })} + {t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
@@ -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( diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8ef07c360..319cafdef 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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({ )} + {showBackButton && mvMobileViewer && ( + <> + + + + )} {isScheduled && canCancelScheduled && ( <> )} - {!isScheduled && !isDraft && (<> + {!isScheduled && !isDraft && !mvViewer && (<> {moreMenuOpen && !isMobile && (
)}
- {/* Date/time on the right of subject row - hidden on mobile, shown next to sender */} -
+ {/* 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. */} +
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -4028,8 +4066,10 @@ export function EmailViewer({
)} - {/* Email Content Area */} -
+ {/* 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. */} +
{/* === SENDER INFO (Desktop) === */} @@ -4064,6 +4104,9 @@ export function EmailViewer({ ) : ( {t('unknown_sender')} )} + {mvViewer && sender?.email && sender?.name && ( + <{sender.email}> + )} {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( )} + {mvViewer && ( + + {formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} + + )}
- {/* 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 && (
{sender.email}
)}
@@ -4353,8 +4401,8 @@ export function EmailViewer({ /> )}
- {/* Email address under name */} - {sender?.email && sender?.name && ( + {/* Email address under name - Mountain View keeps it in the details */} + {!mvMobileViewer && sender?.email && sender?.name && (
{sender.email}
)} {/* Row 2: Recipients */} @@ -4396,9 +4444,11 @@ export function EmailViewer({ {/* Date/time + size on the right (mobile) */}
- {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' })} - {email.size > 0 && ( + {email.size > 0 && !mvMobileViewer && (
{formatFileSize(email.size)}
@@ -5379,8 +5429,29 @@ export function EmailViewer({ + {/* Mountain View: Reply / Reply all / Forward as buttons under the message. */} + {mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && ( +
+ + + +
+ )} + {/* Quick Reply Section - hidden for drafts and while loading a new email */} - {!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
+ {!mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
("normal"); + const minimized = state === "minimized"; + const maximized = state === "maximized"; + + return ( + <> + {maximized && ( +
setState("normal")} + /> + )} +
+
+ + +
+ {/* Folded down, the title bar is the whole window and opens it again. */} + {minimized && ( +
+ + ); +} diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx new file mode 100644 index 000000000..d5fd11836 --- /dev/null +++ b/components/email/mv-list-toolbar.tsx @@ -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 ( + + ); +} + +/** + * 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(null); + const closeMenu = useCallback(() => setMenuOpen(false), []); + const { menuRef, onKeyDown: onMenuKeyDown } = useMenuNavigation({ + open: menuOpen, + onClose: closeMenu, + triggerRef: menuButtonRef, + }); + const [moveOpen, setMoveOpen] = useState(false); + const moveButtonRef = useRef(null); + const closeMove = useCallback(() => setMoveOpen(false), []); + const { menuRef: moveRef, onKeyDown: onMoveKeyDown } = useMenuNavigation({ + open: moveOpen, + onClose: closeMove, + triggerRef: moveButtonRef, + }); + + const [tagOpen, setTagOpen] = useState(false); + const tagButtonRef = useRef(null); + const closeTag = useCallback(() => setTagOpen(false), []); + const { menuRef: tagRef, onKeyDown: onTagKeyDown } = useMenuNavigation({ + 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, + messages?: Parameters[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 ( +
+ {isTarget ? ( + + ) : ( +
+ + {label} +
+ )} + {node.children.length > 0 && renderMoveNodes(node.children, depth + 1)} +
+ ); + }); + + // 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(); + 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 ( +
+ + + + +
+ + {menuOpen && ( +
+ + {onMarkAllFoldersRead && ( + + )} + {/* Offered only where emptying a folder is the usual thing to + do: spam and the bin. */} + {onEmptyFolder && (isInJunk || isInTrash) && ( + <> +
+ + + )} +
+ )} +
+ + {hasSelection && !disabled && ( + <> +
+ 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 && ( + + )} + +
+ {moveTree.length > 0 && ( +
+ + {moveOpen && ( +
+ {renderMoveNodes(moveTree)} +
+ )} +
+ )} +
+ + {tagOpen && ( +
+ +
+ )} +
+
+ run(async () => { if (client) await batchMarkAsRead(client, true); })} + disabled={isProcessing} + /> + run(async () => { if (client) await batchMarkAsRead(client, false); })} + disabled={isProcessing} + /> + + {tBatch("selected_messages", { count: selectionCount })} + + + )} + +
+ + {loadedCount > 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 })} + + )} + + +
+ ); +} diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index bcfc2e887..5df37e1b5 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -426,13 +426,12 @@ export function RichTextEditor({ return (
{/* 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). */} -
+ 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). */} +
{/* Font size - two baseline-aligned A's read as "size" at a glance; once a size is chosen the button shows that number instead. */}
diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index e85833262..83f54244d 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -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({ >
{/* Checkbox - only visible when in selection mode */} - {selectedEmailIds.size > 0 && ( + {selectedEmailKeys.size > 0 && (
: 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 ( +
+ + {onToggleStar && ( + + )} +
+ ); +} + +function MvMobileStar({ + starred, + onToggle, + label, +}: { + starred: boolean; + onToggle?: () => void; + label: string; +}) { + return ( + + ); +} + function StatusIcon({ icon: Icon, label, className }: { icon: AppIcon; label: string; className: string }) { return ( <> @@ -178,13 +274,14 @@ const SingleEmailItem = React.forwardRef( 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( 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( 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( 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( 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( 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( 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 && ( )} - {density !== 'extra-compact' && ( + {mvRow && ( + + )} + + {!mvRow && density !== 'extra-compact' && (
(
)} - {isUnread && density === 'extra-compact' && ( + {isUnread && !mvRow && density === 'extra-compact' && ( )}
{isFocusedMailLayout ? ( + <>
{isUnifiedView && email.accountId && accountColor && !tintListRowsByAccount && ( @@ -469,7 +589,7 @@ const SingleEmailItem = React.forwardRef(
{isPinned && } - {isStarred && } + {isStarred && !mvRow && } {isAnswered && !isForwarded && } {isForwarded && !isAnswered && } {isAnswered && isForwarded && ( @@ -498,6 +618,10 @@ const SingleEmailItem = React.forwardRef( )}
+ {mvRow && ( + + )} + ) : ( <>
@@ -530,7 +654,7 @@ const SingleEmailItem = React.forwardRef( {isPinned && ( )} - {isStarred && ( + {isStarred && !mvMobileRow && ( )} {isAnswered && !isForwarded && ( @@ -570,6 +694,13 @@ const SingleEmailItem = React.forwardRef( {formatDate(email.receivedAt)} )} + {mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && ( + + )}
@@ -592,14 +723,24 @@ const SingleEmailItem = React.forwardRef(
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( -

- {previewSnippet ? : (trimmedPreview || t('no_preview_available'))} -

+
+

+ {previewSnippet ? : (trimmedPreview || t('no_preview_available'))} +

+ {mvMobileRow && ( + + )} +
)} @@ -654,16 +795,24 @@ export const ThreadListItem = React.forwardRef 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 state.getAccountById); const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined; @@ -730,7 +879,7 @@ export const ThreadListItem = React.forwardRef 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 { - 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 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 0) clearSelection(); + if (selectedEmailKeys.size > 0) clearSelection(); if (!isExpanded) { onCollapseAllThreads?.(); onToggleExpand(); @@ -817,23 +966,31 @@ export const ThreadListItem = React.forwardRef +
{/* 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 && ( +
+ )} + + {/* Account List. Under Mountain View the active account is already the + card above, so with a single login there is no list to draw. */} +
+ {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 && ( )} +

{toUnicodeEmail(account.email || account.username)}

-
+ {/* Server and connection state: Mountain View keeps the line + only for an account in trouble. */} +
{account.hasError ? ( ) : ( @@ -348,8 +452,34 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher })}
+ {/* 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 && ( +
+ {accounts.length < getMaxAccounts() && ( + + )} + +
+ )} + {/* Separator + Add Account */} - {accounts.length < getMaxAccounts() && ( + {!mvPopover && accounts.length < getMaxAccounts() && (
- {accounts.length > 1 && ( + {!mvPopover && accounts.length > 1 && (
); diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx new file mode 100644 index 000000000..383ac5e74 --- /dev/null +++ b/components/layout/mv-top-bar.tsx @@ -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(null); + const closeApps = useCallback(() => setAppsOpen(false), []); + const { menuRef: appsMenuRef, onKeyDown: onAppsKeyDown } = useMenuNavigation({ + 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 ( +
+ {/* Brand block: occupies the sidebar's own column width */} +
+ {showMenuButton && ( + + )} + + {logoUrl ? : null} + {appName} + +
+ + {/* Search: left-anchored and capped, not centred */} + {search && ( +
+ + + + {/* 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 . */} +
+
+ )} + + {/* Spacer keeps the right cluster pinned while search stays left */} +
+ +
+ {onShowShortcuts && ( + + )} + {/* Admin (Stalwart admins), as in the navigation rail this bar replaces. + A hard navigation because /admin lives outside the [locale] tree. */} + {isStalwartAdmin && ( + + + {hasUpdate && ( + + )} + + )} + + + + +
+ + {appsOpen && ( +
+
+ {apps.map((app) => { + const Icon = app.icon; + return ( + { + 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" + )} + > + + {app.label} + + ); + })} + {sidebarApps.map((app) => { + const AppIcon = iconForName(app.icon); + return ( + + ); + })} +
+ {sidebarAppsEnabled && onManageApps && ( + + )} +
+ )} +
+ + +
+
+ ); +} diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index dd8940a5a..2021e30ed 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -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 (