diff --git a/FEATURES.md b/FEATURES.md index 0a9379d9c..dbe1b00d9 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -22,6 +22,7 @@ - Each tag can be configured to show always, only when there are unread mails or always be hidden - Tag names and colors live in the browser, while the tags themselves ride on your messages as JMAP keywords. A scan finds keywords no local tag explains and adds them back. - Star or unstar, with a configurable mark-as-read delay +- Configurable message-list order: presets (unread first, starred first, tagged first) or up to three custom sort levels, applied server-side through the JMAP sort so the whole folder is ordered, for the Inbox only or every folder - Large mailboxes scroll virtually, and the first page of mail prefetches at login - A refresh button in the list toolbar that spins while the fetch is in flight, alongside the F5 / Ctrl+R / pull-to-refresh gestures it shares a code path with - Quick reply, hover actions, favicon-based sender avatars, recipient popovers diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index c579463a7..1d82a21ce 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -126,11 +126,17 @@ export function EmailList({ ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const disableThreading = useSettingsStore((state) => state.disableThreading); + // The order the current folder view was fetched in (#718), so thread + // grouping mirrors the server order instead of re-sorting the page by date. + // Search results and cross-account views are always chronological. + const fetchedListOrder = useEmailStore((state) => state.listOrder); + const crossView = useEmailStore((state) => state.crossView); const threadGroups = useMemo(() => { + const listOrder = searchQuery || crossView || !isFilterEmpty(searchFilters) ? [] : fetchedListOrder; const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts); - return sortThreadGroups(groups); - }, [emails, disableThreading, isScheduledView, threadEmailCounts]); + return sortThreadGroups(groups, listOrder); + }, [emails, disableThreading, isScheduledView, threadEmailCounts, fetchedListOrder, searchQuery, crossView, searchFilters]); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); /** diff --git a/components/pro/pro-folder-tab-body.tsx b/components/pro/pro-folder-tab-body.tsx index 248dbe609..77dde3360 100644 --- a/components/pro/pro-folder-tab-body.tsx +++ b/components/pro/pro-folder-tab-body.tsx @@ -9,7 +9,7 @@ import { EmailViewer } from "@/components/email/email-viewer"; import { ProEmailView } from "@/components/pro/pro-email-tab-body"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; -import { useSettingsStore } from "@/stores/settings-store"; +import { getMessageListOrderFor, useSettingsStore } from "@/stores/settings-store"; import { useProTabStore, type ProFolderTabData } from "@/stores/pro-tab-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { usePaneId } from "@/hooks/use-pane-context"; @@ -78,12 +78,15 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [folderLabel, tabId, updateTabTitle]); + // The configured list order (#718) depends on the folder's role (Inbox-only + // by default), and every page of the tab must ask for the same order. + const mailboxRole = mailbox?.role; const loadPage = useCallback(async (position: number) => { if (!client) return; const seq = ++fetchSeqRef.current; if (position === 0) setIsLoading(true); else setIsLoadingMore(true); // getEmails never throws - it reports failures as an empty page. - const result = await client.getEmails(jmapMailboxId, jmapAccountId, emailsPerPage, position, undefined, true); + const result = await client.getEmails(jmapMailboxId, jmapAccountId, emailsPerPage, position, undefined, true, undefined, getMessageListOrderFor(mailboxRole)); if (seq !== fetchSeqRef.current) return; setEmails((prev) => { if (position === 0) return result.emails; @@ -93,7 +96,7 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) { setTotal(result.total); setHasMore(result.hasMore); if (position === 0) setIsLoading(false); else setIsLoadingMore(false); - }, [client, jmapMailboxId, jmapAccountId, emailsPerPage]); + }, [client, jmapMailboxId, jmapAccountId, emailsPerPage, mailboxRole]); useEffect(() => { setSelectedEmailId(null); diff --git a/components/settings/__tests__/message-list-order-settings.test.tsx b/components/settings/__tests__/message-list-order-settings.test.tsx new file mode 100644 index 000000000..48416bec3 --- /dev/null +++ b/components/settings/__tests__/message-list-order-settings.test.tsx @@ -0,0 +1,124 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MessageListOrderSettings } from '../message-list-order-settings'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Settings → Appearance → "Message list order" (#718): a preset dropdown over +// the same level list the advanced editor exposes, persisted through the +// synced settings store. + +describe('MessageListOrderSettings', () => { + beforeEach(() => { + useSettingsStore.setState({ + messageListOrder: [], + messageListOrderScope: 'inbox', + emailKeywords: [...DEFAULT_KEYWORDS], + }); + useAuthStore.setState({ client: null }); + }); + + const presetSelect = () => screen.getAllByRole('combobox')[0] as HTMLSelectElement; + + it('shows the chronological preset by default and keeps the editor closed', () => { + render(); + expect(presetSelect().value).toBe('chronological'); + expect(screen.queryByTestId('message-list-order-editor')).toBeNull(); + // No "custom" entry unless the order actually is custom. + expect(screen.queryByText('preset.custom')).toBeNull(); + }); + + it('applies a simple preset as a single sort level', () => { + render(); + fireEvent.change(presetSelect(), { target: { value: 'unread_first' } }); + expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'unread', direction: 'desc' }]); + expect(presetSelect().value).toBe('unread_first'); + + fireEvent.change(presetSelect(), { target: { value: 'chronological' } }); + expect(useSettingsStore.getState().messageListOrder).toEqual([]); + }); + + it('uses the first tag for "tagged first" and lets the tag be changed', () => { + render(); + fireEvent.change(presetSelect(), { target: { value: 'tagged_first' } }); + expect(useSettingsStore.getState().messageListOrder).toEqual([ + { criterion: 'tag', direction: 'desc', tagId: DEFAULT_KEYWORDS[0].id }, + ]); + + const tagSelect = screen.getByLabelText('tag_label') as HTMLSelectElement; + fireEvent.change(tagSelect, { target: { value: DEFAULT_KEYWORDS[1].id } }); + expect(useSettingsStore.getState().messageListOrder[0].tagId).toBe(DEFAULT_KEYWORDS[1].id); + }); + + it('opens the advanced editor on a custom order and reports it as custom', () => { + useSettingsStore.setState({ + messageListOrder: [ + { criterion: 'unread', direction: 'desc' }, + { criterion: 'starred', direction: 'desc' }, + ], + }); + render(); + expect(presetSelect().value).toBe('custom'); + expect(screen.getByTestId('message-list-order-editor')).toBeInTheDocument(); + expect(screen.getByTestId('message-list-order-level-0')).toBeInTheDocument(); + expect(screen.getByTestId('message-list-order-level-1')).toBeInTheDocument(); + }); + + it('adds, edits, reorders and removes levels in the editor', () => { + render(); + fireEvent.click(screen.getByTestId('message-list-order-advanced-toggle')); + + fireEvent.click(screen.getByTestId('message-list-order-add-level')); + expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'unread', direction: 'desc' }]); + + // Switching the criterion resets the direction to that criterion's default. + fireEvent.change(screen.getByLabelText('criterion_label'), { target: { value: 'from' } }); + expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'from', direction: 'asc' }]); + fireEvent.change(screen.getByLabelText('direction_label'), { target: { value: 'desc' } }); + expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'from', direction: 'desc' }]); + + fireEvent.click(screen.getByTestId('message-list-order-add-level')); + fireEvent.click(screen.getByTestId('message-list-order-add-level')); + expect(useSettingsStore.getState().messageListOrder.map(l => l.criterion)).toEqual(['from', 'unread', 'starred']); + // Capped at three levels. + expect(screen.queryByTestId('message-list-order-add-level')).toBeNull(); + + fireEvent.click(screen.getAllByLabelText('move_up')[2]); + expect(useSettingsStore.getState().messageListOrder.map(l => l.criterion)).toEqual(['from', 'starred', 'unread']); + + fireEvent.click(screen.getAllByLabelText('remove_level')[0]); + expect(useSettingsStore.getState().messageListOrder.map(l => l.criterion)).toEqual(['starred', 'unread']); + }); + + it('toggles the all-folders scope', () => { + render(); + fireEvent.click(screen.getByTestId('message-list-order-scope')); + expect(useSettingsStore.getState().messageListOrderScope).toBe('all'); + fireEvent.click(screen.getByTestId('message-list-order-scope')); + expect(useSettingsStore.getState().messageListOrderScope).toBe('inbox'); + }); + + it('warns when the server advertises sort options without hasKeyword', () => { + useSettingsStore.setState({ messageListOrder: [{ criterion: 'unread', direction: 'desc' }] }); + useAuthStore.setState({ + client: { getEmailQuerySortOptions: vi.fn(() => ['receivedAt', 'size']) } as unknown as IJMAPClient, + }); + render(); + expect(screen.getByText('unsupported_note')).toBeInTheDocument(); + }); + + it('stays quiet when the server supports keyword sorting or does not say', () => { + useSettingsStore.setState({ messageListOrder: [{ criterion: 'unread', direction: 'desc' }] }); + useAuthStore.setState({ + client: { getEmailQuerySortOptions: vi.fn(() => ['receivedAt', 'hasKeyword']) } as unknown as IJMAPClient, + }); + const { unmount } = render(); + expect(screen.queryByText('unsupported_note')).toBeNull(); + unmount(); + + useAuthStore.setState({ client: { getEmailQuerySortOptions: vi.fn(() => null) } as unknown as IJMAPClient }); + render(); + expect(screen.queryByText('unsupported_note')).toBeNull(); + }); +}); diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx index 3a589dfed..179450356 100644 --- a/components/settings/appearance-settings.tsx +++ b/components/settings/appearance-settings.tsx @@ -9,6 +9,7 @@ import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; import { PlayCircle } from 'lucide-react'; import { usePolicyStore } from '@/stores/policy-store'; +import { MessageListOrderSettings } from './message-list-order-settings'; const DENSITY_PREVIEW: Record = { 'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false }, @@ -117,6 +118,8 @@ export function AppearanceSettings() { )} + + {!isSettingHidden('animationsEnabled') && ( = { + unread: ['unread_first', 'read_first'], + starred: ['starred_first', 'unstarred_first'], + tag: ['tagged_first', 'untagged_first'], + receivedAt: ['newest_first', 'oldest_first'], + sentAt: ['newest_first', 'oldest_first'], + from: ['z_to_a', 'a_to_z'], + subject: ['z_to_a', 'a_to_z'], + size: ['largest_first', 'smallest_first'], +}; + +/** + * Re-fetches the current folder so a changed order shows up without leaving + * settings and coming back (the Pro interface keeps the mail tab mounted). + */ +function refetchCurrentList() { + const client = useAuthStore.getState().client; + const store = useEmailStore.getState(); + if (client && store.selectedMailbox) { + void store.fetchEmails(client, store.selectedMailbox, { background: true }); + } +} + +/** + * Settings → Appearance → "Message list order" (#718). A preset dropdown for + * the common cases and an advanced editor for up to MAX_SORT_LEVELS ordered + * {criterion, direction} levels, mapped onto the JMAP Email/query sort. + */ +export function MessageListOrderSettings() { + const t = useTranslations('settings.appearance.message_list_order'); + const { messageListOrder, messageListOrderScope, emailKeywords, updateSetting } = useSettingsStore(); + const { isSettingLocked, isSettingHidden } = usePolicyStore(); + const client = useAuthStore((s) => s.client); + + const preset = detectPreset(messageListOrder); + const [advanced, setAdvanced] = useState(preset === 'custom'); + const [dragIndex, setDragIndex] = useState(null); + + // A server that lists its sort options without hasKeyword cannot order by + // read state, stars or tags; say so rather than silently showing a + // chronological list the user believes is prioritised. + const sortOptions = client?.getEmailQuerySortOptions?.() ?? null; + const keywordSortUnsupported = sortOptions !== null && !sortOptions.includes('hasKeyword'); + + if (isSettingHidden('messageListOrder')) return null; + const locked = isSettingLocked('messageListOrder'); + + const apply = (next: SortLevel[]) => { + updateSetting('messageListOrder', sanitizeSortLevels(next)); + refetchCurrentList(); + }; + + const firstTagId = emailKeywords[0]?.id; + const presetTagId = messageListOrder.find((l) => l.criterion === 'tag')?.tagId ?? firstTagId; + + const onPresetChange = (value: string) => { + const next = value as OrderPreset; + if (next === 'custom') return; + apply(presetLevels(next, presetTagId)); + }; + + const updateLevel = (index: number, patch: Partial) => { + apply(messageListOrder.map((level, i) => { + if (i !== index) return level; + const merged = { ...level, ...patch }; + if (patch.criterion && patch.criterion !== level.criterion) { + merged.direction = defaultDirection(patch.criterion); + if (patch.criterion === 'tag') merged.tagId = merged.tagId ?? firstTagId; + else delete merged.tagId; + } + return merged; + })); + }; + + const moveLevel = (from: number, to: number) => { + if (to < 0 || to >= messageListOrder.length || from === to) return; + const next = [...messageListOrder]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + apply(next); + }; + + const removeLevel = (index: number) => apply(messageListOrder.filter((_, i) => i !== index)); + + const usedCriteria = new Set(messageListOrder.map((l) => l.criterion)); + const addLevel = () => { + if (messageListOrder.length >= MAX_SORT_LEVELS) return; + const criterion = SORT_CRITERIA.find((c) => !usedCriteria.has(c) && (c !== 'tag' || firstTagId)); + if (!criterion) return; + const level: SortLevel = { criterion, direction: defaultDirection(criterion) }; + if (criterion === 'tag') level.tagId = firstTagId; + apply([...messageListOrder, level]); + }; + + const presetOptions = [ + ...ORDER_PRESETS + .filter((p) => p !== 'tagged_first' || emailKeywords.length > 0) + .map((p) => ({ value: p, label: t(`preset.${p}`) })), + ...(preset === 'custom' ? [{ value: 'custom', label: t('preset.custom') }] : []), + ]; + + const tagOptions = emailKeywords.map((k) => ({ value: k.id, label: k.label })); + + const criterionOptions = (current: SortCriterion) => + SORT_CRITERIA + .filter((c) => c === current || (!usedCriteria.has(c) && (c !== 'tag' || emailKeywords.length > 0))) + .map((c) => ({ value: c, label: t(`criteria.${c}`) })); + + const directionOptions = (criterion: SortCriterion) => { + const [desc, asc] = DIRECTION_LABELS[criterion]; + return [ + { value: 'desc', label: t(`direction.${desc}`) }, + { value: 'asc', label: t(`direction.${asc}`) }, + ]; + }; + + return ( + <> + +
+ apply(presetLevels('tagged_first', tagId))} + options={tagOptions} + disabled={locked} + ariaLabel={t('tag_label')} + /> + )} + +
+
+ + {advanced && ( +
+
    + {messageListOrder.map((level, index) => ( +
  1. { setDragIndex(index); e.dataTransfer.effectAllowed = 'move'; }} + onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} + onDrop={(e) => { e.preventDefault(); if (dragIndex !== null) moveLevel(dragIndex, index); setDragIndex(null); }} + onDragEnd={() => setDragIndex(null)} + className={cn( + 'flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-2 py-1.5', + dragIndex === index && 'opacity-50', + )} + data-testid={`message-list-order-level-${index}`} + > + + + updateLevel(index, { tagId })} + options={tagOptions} + disabled={locked} + ariaLabel={t('tag_label')} + /> + )} +