diff --git a/components/email/__tests__/email-list-unread-first.test.tsx b/components/email/__tests__/email-list-unread-first.test.tsx new file mode 100644 index 000000000..41c57dba0 --- /dev/null +++ b/components/email/__tests__/email-list-unread-first.test.tsx @@ -0,0 +1,108 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { EmailList } from '../email-list'; +import { useEmailStore } from '@/stores/email-store'; +import { useMessageListTabsStore } from '@/stores/message-list-tabs-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import type { Email } from '@/lib/jmap/types'; + +// Mountain View's "Unread first" inbox: the Unread section's conversations +// first, each section under its header, and a folded section's rows gone. + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/stores/auth-store', async () => { + const { create } = await import('zustand'); + return { + useAuthStore: create(() => ({ client: null, identities: [], username: null, activeAccountId: 'acct-1' })), + }; +}); +vi.mock('@/stores/account-store', () => { + const state = { getAccountById: () => undefined }; + return { + useAccountStore: Object.assign( + (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + { getState: () => state }, + ), + }; +}); + +const mail = (id: string, time: string, read: boolean): Email => ({ + id, + threadId: `thread-${id}`, + mailboxIds: { inbox: true }, + keywords: read ? { $seen: true } : {}, + size: 1000, + receivedAt: `2026-09-30T${time}:00Z`, + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: `Mail ${id}`, + hasAttachment: false, +}); + +// The store's list: the section's rows first, then the read mail by date. +const EMAILS = [ + mail('U2', '12:00', false), + mail('U1', '09:00', false), + mail('R2', '16:00', true), + mail('R1', '10:00', true), +]; + +const initialEmailState = useEmailStore.getState(); +const initialTabsState = useMessageListTabsStore.getState(); +const initialSettings = useSettingsStore.getState(); + +beforeEach(() => { + // Room in the list for every item, or the virtual list draws none of them. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600); + useEmailStore.setState(initialEmailState, true); + useEmailStore.setState({ + selectedMailbox: 'inbox', + mailboxes: [{ id: 'inbox', role: 'inbox' } as never], + emails: EMAILS, + totalEmails: 2, + unreadSection: { mailboxId: 'inbox', ids: new Set(['U2', 'U1']), total: 7 }, + }); + useMessageListTabsStore.setState(initialTabsState, true); + useSettingsStore.setState({ + ...initialSettings, + interfaceLayout: 'mountain-view', + mvInboxType: 'unread-first', + mvUnreadSectionCollapsed: false, + mvEverythingElseCollapsed: false, + }, true); +}); + +afterEach(() => { vi.restoreAllMocks(); }); + +const rowKeys = () => useEmailStore.getState().listRows?.map((row) => row.key); + +describe('the "Unread first" inbox list', () => { + it('puts the Unread section first, under its header, then everything else', () => { + const { container } = render(); + expect(rowKeys()).toEqual(['U2', 'U1', 'R2', 'R1']); + const headers = [...container.querySelectorAll('[data-mv-list-section]')].map((h) => h.textContent); + expect(headers).toHaveLength(2); + expect(headers[0]).toContain('sections.unread'); + expect(headers[1]).toContain('sections.everything_else'); + }); + + it('folds a section away from its header', () => { + const { container } = render(); + const unreadToggle = container.querySelector('[data-mv-list-section] button') as HTMLButtonElement; + act(() => unreadToggle.click()); + expect(useSettingsStore.getState().mvUnreadSectionCollapsed).toBe(true); + expect(rowKeys()).toEqual(['R2', 'R1']); + expect(container.querySelectorAll('[data-mv-list-section]')).toHaveLength(2); + }); + + it('is one list sorted by date in the default inbox', () => { + useSettingsStore.setState({ mvInboxType: 'default' }); + const { container } = render(); + expect(rowKeys()).toEqual(['R2', 'U2', 'R1', 'U1']); + expect(container.querySelector('[data-mv-list-section]')).toBeNull(); + }); +}); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 09b3f6705..e182206b3 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -39,6 +39,9 @@ import { emitThreadExpand } from "@/lib/plugin-thread-expand"; import { normalizeAddress } from "@/lib/filters/quick-rules"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginActions } from "@/components/plugins/plugin-actions"; +import { useUnreadFirstSection } from "@/hooks/use-unread-first-section"; +import { unreadSectionRowCount } from "@/lib/unread-first"; +import { MvListSectionHeader } from "./mv-list-section-header"; interface EmailListProps { emails: Email[]; @@ -83,6 +86,15 @@ interface EmailListProps { hideBatchToolbar?: boolean; } +/** + * What the virtual list draws: a row, or in the "Unread first" inbox a + * section's header and the line an empty Unread section shows. + */ +type ListItem = + | { kind: 'row'; thread: ThreadGroup } + | { kind: 'header'; section: 'unread' | 'rest' } + | { kind: 'unread-empty' }; + /** Below this width the batch toolbar drops its labels (see batchNarrow). */ const BATCH_TOOLBAR_WIDE = 480; @@ -241,12 +253,59 @@ export function EmailList({ const activeAccountId = useAuthStore((state) => state.activeAccountId); const activeTabId = useMessageListTabsStore((state) => state.activeTabId); - const threadGroups = useMemo(() => { + const allThreadGroups = useMemo(() => { const listOrder = searchQuery || crossView || !isFilterEmpty(searchFilters) ? [] : fetchedListOrder; const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts); return sortThreadGroups(groups, listOrder, listHold?.keywords); }, [emails, disableThreading, isScheduledView, threadEmailCounts, fetchedListOrder, searchQuery, crossView, searchFilters, listHold]); + // Mountain View's "Unread first" inbox: the Unread section's conversations + // go first, under a header each section has, and a folded section's rows + // leave the list. A conversation with a message in the section is the + // section's. + const unreadSection = useUnreadFirstSection(); + const unreadCollapsed = useSettingsStore((state) => state.mvUnreadSectionCollapsed); + const restCollapsed = useSettingsStore((state) => state.mvEverythingElseCollapsed); + const updateSetting = useSettingsStore((state) => state.updateSetting); + const sections = useMemo(() => { + if (!unreadSection) return null; + const unread: ThreadGroup[] = []; + const rest: ThreadGroup[] = []; + for (const group of allThreadGroups) { + (group.emails.some((email) => unreadSection.ids.has(email.id)) ? unread : rest).push(group); + } + return { unread, rest }; + }, [allThreadGroups, unreadSection]); + const threadGroups = useMemo(() => ( + sections + ? [...(unreadCollapsed ? [] : sections.unread), ...(restCollapsed ? [] : sections.rest)] + : allThreadGroups + ), [sections, unreadCollapsed, restCollapsed, allThreadGroups]); + const { items, itemOfThread } = useMemo(() => { + const items: ListItem[] = []; + const itemOfThread: number[] = []; + const addRows = (groups: ThreadGroup[]) => { + for (const thread of groups) { + itemOfThread.push(items.length); + items.push({ kind: 'row', thread }); + } + }; + if (!sections) { + addRows(threadGroups); + return { items, itemOfThread }; + } + items.push({ kind: 'header', section: 'unread' }); + if (!unreadCollapsed) { + if (sections.unread.length === 0) items.push({ kind: 'unread-empty' }); + addRows(sections.unread); + } + items.push({ kind: 'header', section: 'rest' }); + if (!restCollapsed) addRows(sections.rest); + return { items, itemOfThread }; + }, [sections, threadGroups, unreadCollapsed, restCollapsed]); + const sectionRowCount = useMemo(() => unreadSectionRowCount(emails, unreadSection), [emails, unreadSection]); + const totalEmails = useEmailStore((state) => state.totalEmails); + const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); /** * The row the menu was opened on, as the list currently has it. The menu holds @@ -388,13 +447,15 @@ export function EmailList({ // The virtualizer's guesses follow the rows' shape (see useListRowShape). const estimateSize = useCallback((index: number) => { + const item = items[index]; + if (item?.kind === 'header' || item?.kind === 'unread-empty') return 48; if (isFocusedMailLayout) { return { 'extra-compact': 28, compact: 40, regular: 56, comfortable: 64 }[density]; } // Rows the list has not measured yet are placed by this guess, and each // one it gets wrong shifts the list when it is measured on the way back // up after a jump down (scrollbar drag, End). So guess per row. - const latest = threadGroups[index]?.latestEmail; + const latest = item?.kind === 'row' ? item.thread.latestEmail : undefined; let size = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density]; if (showPreview && density !== 'extra-compact') { // A mail without a preview draws a one-line "No preview available", @@ -406,17 +467,22 @@ export function EmailList({ const hasCode = !!latest && showVerificationCodes && !!listVerificationCode(latest); if (latest && (hasCode || (onOpenAttachment && listRowShowsChips(latest, loadAttachments)))) size += 28; return size; - }, [density, isFocusedMailLayout, showPreview, showVerificationCodes, threadGroups, onOpenAttachment, loadAttachments]); + }, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments]); // Stable per list, so the virtualizer does not rebuild every row's // measurement on each scroll render. const getItemKey = useCallback( - (index: number) => threadGroups[index]?.threadKey ?? String(index), - [threadGroups], + (index: number) => { + const item = items[index]; + if (item?.kind === 'row') return item.thread.threadKey; + if (item?.kind === 'header') return `section:${item.section}`; + return item ? 'section:unread-empty' : String(index); + }, + [items], ); const virtualizer = useVirtualizer({ - count: threadGroups.length, + count: items.length, getScrollElement: () => parentRef.current, estimateSize, overscan: 5, @@ -677,9 +743,11 @@ export function EmailList({ const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index; const loadMoreTimerRef = useRef | null>(null); + // A folded "Everything else" has nothing below to load into. + const loadsMore = !(sections && restCollapsed); useEffect(() => { - if (lastVirtualItemIndex === undefined) return; - if (lastVirtualItemIndex >= threadGroups.length - 5) { + if (lastVirtualItemIndex === undefined || !loadsMore) return; + if (lastVirtualItemIndex >= items.length - 5) { // Clear any pending timer so we don't stack calls if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); loadMoreTimerRef.current = setTimeout(() => { @@ -690,7 +758,7 @@ export function EmailList({ return () => { if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); }; - }, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]); + }, [lastVirtualItemIndex, items.length, handleLoadMore, loadsMore]); // Scroll to the thread group containing the selected email useEffect(() => { @@ -703,11 +771,12 @@ export function EmailList({ // A row clicked in the list is already on screen; scrollToIndex would // still measure and schedule a scroll. Judge from the virtualizer's own // numbers, which cost no layout read. - const item = virtualizer.measurementsCache[index]; + const itemIndex = itemOfThread[index]; + const item = virtualizer.measurementsCache[itemIndex]; const top = virtualizer.scrollOffset; const height = virtualizer.scrollRect?.height; if (item && top !== null && height !== undefined && item.start >= top && item.end <= top + height) return; - virtualizer.scrollToIndex(index, { align: 'auto' }); + virtualizer.scrollToIndex(itemIndex, { align: 'auto' }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEmailId]); @@ -716,7 +785,7 @@ export function EmailList({ useEffect(() => { if (!selectionFocusKey) return; const index = threadGroups.findIndex((thread) => thread.emails.some((e) => emailKeyFor(e) === selectionFocusKey)); - if (index >= 0) virtualizer.scrollToIndex(index, { align: 'auto' }); + if (index >= 0) virtualizer.scrollToIndex(itemOfThread[index], { align: 'auto' }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectionFocusKey]); @@ -951,7 +1020,8 @@ export function EmailList({ }} > {virtualizer.getVirtualItems().map((virtualItem) => { - const thread = threadGroups[virtualItem.index]; + const listItem = items[virtualItem.index]; + const thread = listItem?.kind === 'row' ? listItem.thread : null; return (
- updateSetting('mvUnreadSectionCollapsed', !unreadCollapsed)} + shown={sectionRowCount} + total={unreadSection.total} + /> + ) : ( + updateSetting('mvEverythingElseCollapsed', !restCollapsed)} + shown={emails.length - sectionRowCount} + total={totalEmails} + /> + ) + )} + {listItem?.kind === 'unread-empty' && ( +

+ {t('sections.unread_empty')} +

+ )} + {thread && + />}
); })} -
+ {loadsMore &&
{footerIsLoadingMore && footerHasMore && (
@@ -1006,7 +1100,7 @@ export function EmailList({ {t('no_more_emails')}
)} -
+
} )} diff --git a/components/email/mv-list-section-header.tsx b/components/email/mv-list-section-header.tsx new file mode 100644 index 000000000..544b04541 --- /dev/null +++ b/components/email/mv-list-section-header.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { ChevronDown, ChevronUp } from "@/components/icons"; + +interface MvListSectionHeaderProps { + title: string; + collapsed: boolean; + onToggle: () => void; + /** Rows the section shows and how many it has in all, for the range readout. */ + shown: number; + total: number; +} + +/** A section of Mountain View's "Unread first" inbox: "Unread" or "Everything else", folding as in Gmail. */ +export function MvListSectionHeader({ title, collapsed, onToggle, shown, total }: MvListSectionHeaderProps) { + const t = useTranslations("email_list"); + const Chevron = collapsed ? ChevronDown : ChevronUp; + return ( +
+ +
+ {shown > 0 && ( + + {t("conversations_range", { from: 1, to: shown, total: Math.max(total, shown) })} + + )} +
+ ); +} diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index 03cb89e6b..77cfc8b98 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -36,6 +36,7 @@ import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets" import { getEmailTagIds } from "@/lib/thread-utils"; import { cn, type MailboxNode } from "@/lib/utils"; import { PluginActions } from "@/components/plugins/plugin-actions"; +import { useUnreadFirstSection } from "@/hooks/use-unread-first-section"; interface MvListToolbarProps { /** Loaded conversation count, for the right-hand range readout. */ @@ -217,6 +218,8 @@ export function MvListToolbar({ // 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); + // The "Unread first" inbox gives each section its own readout instead. + const sectioned = !!useUnreadFirstSection(); const currentRole = mailboxes.find((mb) => mb.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); @@ -659,7 +662,7 @@ export function MvListToolbar({
- {loadedCount > 0 && ( + {loadedCount > 0 && !sectioned && ( {/* The range readout. The list grows as it scrolls rather than paging, so there are no previous/next arrows to go with it. */} diff --git a/components/settings/message-list-settings.tsx b/components/settings/message-list-settings.tsx index 30f3e544a..2f730c958 100644 --- a/components/settings/message-list-settings.tsx +++ b/components/settings/message-list-settings.tsx @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { useEffectiveMailLayout } from '@/hooks/use-effective-mail-layout'; import { useMediaQuery } from '@/hooks/use-media-query'; -import type { ArchiveMode, HoverAction, SwipeAction } from '@/stores/settings-store'; +import type { ArchiveMode, HoverAction, MvInboxType, SwipeAction } from '@/stores/settings-store'; import { ALL_HOVER_ACTIONS } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; @@ -68,6 +68,7 @@ export function MessageListSettings() { listColumns, mailLayout, interfaceLayout, + mvInboxType, tintListRowsByTag, senderFavicons, showAvatarsInJunk, @@ -177,6 +178,25 @@ export function MessageListSettings() { )} + {/* Gmail's inbox types; "Unread first" puts the newest unread mail in a section above the rest. */} + {mountainView && ( + +