diff --git a/components/email/__tests__/mail-sort-menu.test.tsx b/components/email/__tests__/mail-sort-menu.test.tsx new file mode 100644 index 000000000..9e6bcf679 --- /dev/null +++ b/components/email/__tests__/mail-sort-menu.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { MailSortMenu, sortChoiceFor } from '../mail-list-menus'; + +// The mail list's Sort menu writes the "Message list order" setting, like the +// Files header's sort menu writes its own. + +const fetchEmails = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + fetchEmails.mockClear(); + useSettingsStore.setState({ messageListOrder: [], messageListOrderScope: 'inbox' } as never); + useAuthStore.setState({ client: { getEmailQuerySortOptions: () => null } } as never); + useEmailStore.setState({ selectedMailbox: 'sent', fetchEmails } as never); +}); + +describe('mail list Sort menu', () => { + it('checks the current order', () => { + render(); + fireEvent.click(screen.getByTestId('mail-sort')); + expect(screen.getByTestId('mail-sort-newest').getAttribute('aria-checked')).toBe('true'); + expect(screen.getByTestId('mail-sort-oldest').getAttribute('aria-checked')).toBe('false'); + }); + + it('writes the setting, makes it reach this folder, and reloads the list', () => { + render(); + fireEvent.click(screen.getByTestId('mail-sort')); + fireEvent.click(screen.getByTestId('mail-sort-oldest')); + const { messageListOrder, messageListOrderScope } = useSettingsStore.getState(); + expect(messageListOrder).toEqual([{ criterion: 'receivedAt', direction: 'asc' }]); + // The order applied to the Inbox only, so in Sent the pick did nothing. + expect(messageListOrderScope).toBe('all'); + expect(fetchEmails).toHaveBeenCalledWith(expect.anything(), 'sent', { background: true }); + }); + + it('leaves out read state and stars when the server cannot order by them', () => { + useAuthStore.setState({ client: { getEmailQuerySortOptions: () => ['receivedAt', 'from'] } } as never); + render(); + fireEvent.click(screen.getByTestId('mail-sort')); + expect(screen.queryByTestId('mail-sort-unread')).toBeNull(); + expect(screen.getByTestId('mail-sort-sender')).toBeTruthy(); + }); + + it('recognises the orders Settings can make', () => { + expect(sortChoiceFor([{ criterion: 'unread', direction: 'desc' }, { criterion: 'receivedAt', direction: 'desc' }])).toBe('unread'); + expect(sortChoiceFor([{ criterion: 'unread', direction: 'desc' }, { criterion: 'from', direction: 'asc' }])).toBeNull(); + }); +}); diff --git a/components/email/mail-list-menus.tsx b/components/email/mail-list-menus.tsx new file mode 100644 index 000000000..9572ef5cd --- /dev/null +++ b/components/email/mail-list-menus.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { ArrowUpDown, Check } from "@/components/icons"; +import { IconButton } from "@/components/ui/icon-button"; +import { ContextMenu, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; +import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store"; +import { usePolicyStore } from "@/stores/policy-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { isDefaultOrder, sanitizeSortLevels, type SortLevel } from "@/lib/message-list-order"; +import { cn } from "@/lib/utils"; + +/** The orders the list header offers; Settings has the full editor. */ +export const SORT_CHOICES = [ + { key: "newest", levels: [] }, + { key: "oldest", levels: [{ criterion: "receivedAt", direction: "asc" }] }, + { key: "unread", levels: [{ criterion: "unread", direction: "desc" }], keyword: true }, + { key: "starred", levels: [{ criterion: "starred", direction: "desc" }], keyword: true }, + { key: "sender", levels: [{ criterion: "from", direction: "asc" }] }, + { key: "subject", levels: [{ criterion: "subject", direction: "asc" }] }, + { key: "largest", levels: [{ criterion: "size", direction: "desc" }] }, +] as const satisfies ReadonlyArray<{ key: string; levels: readonly SortLevel[]; keyword?: boolean }>; + +export type SortChoiceKey = (typeof SORT_CHOICES)[number]["key"]; + +/** The choice `levels` is, or null for an order only Settings can make. */ +export function sortChoiceFor(levels: SortLevel[]): SortChoiceKey | null { + // A trailing newest-first level is implicit (see detectPreset). + const last = levels[levels.length - 1]; + const core = levels.length > 1 && last.criterion === "receivedAt" && last.direction === "desc" + ? levels.slice(0, -1) + : levels; + if (isDefaultOrder(core)) return "newest"; + if (core.length !== 1) return null; + const match = SORT_CHOICES.find((choice) => + choice.levels.length === 1 + && choice.levels[0].criterion === core[0].criterion + && choice.levels[0].direction === core[0].direction); + return match?.key ?? null; +} + +/** + * Picks an order for the list. It writes the "Message list order" setting, so + * Settings and every device agree. That setting applies to the Inbox only + * unless "Apply to all folders" is on; picked in another folder, it turns + * that on, or the pick would do nothing there. + */ +export function applySortChoice(key: SortChoiceKey, folderRole: string | null | undefined): void { + const choice = SORT_CHOICES.find((c) => c.key === key); + if (!choice) return; + const { updateSetting, messageListOrderScope } = useSettingsStore.getState(); + updateSetting("messageListOrder", sanitizeSortLevels(choice.levels.map((level) => ({ ...level })))); + if (messageListOrderScope === "inbox" && folderRole !== "inbox" && choice.levels.length > 0) { + updateSetting("messageListOrderScope", "all"); + } + const client = useAuthStore.getState().client; + const store = useEmailStore.getState(); + if (client && store.selectedMailbox) void store.fetchEmails(client, store.selectedMailbox, { background: true }); +} + +/** The list header's Sort button (MAIL-30), like the one in Files. */ +export function MailSortMenu({ folderRole, disabled, className }: { folderRole: string | null | undefined; disabled?: boolean; className?: string }) { + const t = useTranslations("email_list.sort"); + const menu = useContextMenu(); + const hidden = usePolicyStore((s) => s.isSettingHidden("messageListOrder")); + const locked = usePolicyStore((s) => s.isSettingLocked("messageListOrder")); + // Re-render when the order changes elsewhere (Settings, another device). + useSettingsStore((s) => s.messageListOrder); + useSettingsStore((s) => s.messageListOrderScope); + const client = useAuthStore((s) => s.client); + if (hidden) return null; + + const current = sortChoiceFor(getMessageListOrderFor(folderRole)); + // A server without hasKeyword can't order by read state or stars. + const sortOptions = client?.getEmailQuerySortOptions?.() ?? null; + const keywordSort = sortOptions === null || sortOptions.includes("hasKeyword"); + + return ( + <> + { if (menu.contextMenu.isOpen) e.stopPropagation(); }} + onClick={(e) => { + if (menu.contextMenu.isOpen) menu.closeContextMenu(); + else menu.openContextMenuFrom(e.currentTarget, null, "end"); + }} + > + + + + {SORT_CHOICES.filter((choice) => keywordSort || !("keyword" in choice)).map((choice) => ( + { + menu.closeContextMenu(); + applySortChoice(choice.key, folderRole); + }} + /> + ))} + {current === null && ( + <> + + + + )} + + + ); +} + +/** A checkable entry of the Sort menu, as in Files'. */ +function SortItem({ label, checked, onClick, testId }: { label: string; checked: boolean; onClick: () => void; testId?: string }) { + return ( + + ); +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index e00201af6..fecba531f 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -81,6 +81,7 @@ import { } from "@/components/error"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ScheduleSendDialog } from "@/components/email/schedule-send-dialog"; +import { MailSortMenu } from "@/components/email/mail-list-menus"; import { PromptDialog } from "@/components/ui/prompt-dialog"; import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { DragDropProvider } from "@/contexts/drag-drop-context"; @@ -4939,6 +4940,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { )} +