diff --git a/components/email/__tests__/email-list-thread-expand-mark-read.test.tsx b/components/email/__tests__/email-list-thread-expand-mark-read.test.tsx new file mode 100644 index 000000000..c605a2e03 --- /dev/null +++ b/components/email/__tests__/email-list-thread-expand-mark-read.test.tsx @@ -0,0 +1,114 @@ +import { act, fireEvent, render, screen } 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 { useSettingsStore } from '@/stores/settings-store'; +import { useMessageListTabsStore } from '@/stores/message-list-tabs-store'; +import type { Email } from '@/lib/jmap/types'; + +/** + * Expanding a conversation from the list must go through markAsReadDelay: + * with "Never" nothing is marked read. Guards the list's wiring, not only the + * store action. + */ + +const client = vi.hoisted(() => ({ + getAccountId: () => 'acct-1', + getThreadEmails: vi.fn(), + batchMarkAsRead: vi.fn(async () => {}), +})); + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +// jsdom has no layout, so the real virtualizer draws no rows. Draw them all. +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, key: index, start: index * 80, size: 80 })), + getTotalSize: () => count * 80, + measureElement: () => {}, + measure: () => {}, + scrollToOffset: () => {}, + scrollToIndex: () => {}, + measurementsCache: [], + scrollOffset: 0, + scrollRect: null, + }), +})); +vi.mock('@/stores/auth-store', async () => { + const { create } = await import('zustand'); + return { + useAuthStore: create(() => ({ + client, identities: [], username: null, activeAccountId: 'acct-1', + getClientForAccount: () => client, + })), + }; +}); +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, seen: boolean): Email => ({ + id, + threadId: 'thread-t', + mailboxIds: { inbox: true }, + keywords: seen ? { $seen: true } : {}, + size: 1000, + receivedAt: `2026-09-30T${time}:00Z`, + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Conversation', + hasAttachment: false, +}); + +const THREAD = [mail('t2', '16:00', false), mail('t1', '15:00', true)]; + +const initialEmailState = useEmailStore.getState(); +const initialTabsState = useMessageListTabsStore.getState(); + +beforeEach(() => { + vi.useFakeTimers(); + client.batchMarkAsRead.mockClear(); + client.getThreadEmails.mockResolvedValue(THREAD); + useEmailStore.setState(initialEmailState, true); + useEmailStore.setState({ selectedMailbox: 'inbox', emails: THREAD }); + useMessageListTabsStore.setState(initialTabsState, true); +}); + +afterEach(() => { + useEmailStore.getState().collapseAllThreads(); + vi.useRealTimers(); +}); + +async function expandFromList() { + render(); + fireEvent.click(screen.getAllByText('Conversation')[0]); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(useEmailStore.getState().expandedThreadIds.has('thread-t')).toBe(true); +} + +describe('email list conversation expansion follows markAsReadDelay', () => { + it('marks nothing read with "Never" (-1)', async () => { + useSettingsStore.setState({ markAsReadDelay: -1 }); + await expandFromList(); + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + + expect(client.batchMarkAsRead).not.toHaveBeenCalled(); + }); + + it('marks the unread message read after the delay', async () => { + useSettingsStore.setState({ markAsReadDelay: 3000 }); + await expandFromList(); + expect(client.batchMarkAsRead).not.toHaveBeenCalled(); + + await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); + expect(client.batchMarkAsRead).toHaveBeenCalledWith(['t2'], true, undefined); + }); +}); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index ea9e32b55..e81379bd3 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -172,7 +172,7 @@ export function EmailList({ isLoadingThread, toggleThreadExpansion, fetchThreadEmails, - markThreadAsRead, + autoMarkExpandedThreadAsRead, collapseAllThreads, threadEmailCounts, searchFilters, @@ -204,7 +204,7 @@ export function EmailList({ isLoadingThread: state.isLoadingThread, toggleThreadExpansion: state.toggleThreadExpansion, fetchThreadEmails: state.fetchThreadEmails, - markThreadAsRead: state.markThreadAsRead, + autoMarkExpandedThreadAsRead: state.autoMarkExpandedThreadAsRead, collapseAllThreads: state.collapseAllThreads, threadEmailCounts: state.threadEmailCounts, searchFilters: state.searchFilters, @@ -607,13 +607,13 @@ export function EmailList({ toggleThreadExpansion(threadKey); await fetchThreadEmails(client, threadKey); emitThreadExpand(threadKey, threadMembers(threadKey), true); - // Mark all unread emails in this thread as read - void markThreadAsRead(client, threadKey); + // Mark the thread's unread emails read, following markAsReadDelay. + autoMarkExpandedThreadAsRead(client, threadKey); } else { toggleThreadExpansion(threadKey); emitThreadExpand(threadKey, threadMembers(threadKey), !isExpanded); } - }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead, threadMembers]); + }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, autoMarkExpandedThreadAsRead, threadMembers]); const collapseAllThreadsAndNotify = useCallback(() => { const wereExpanded = [...useEmailStore.getState().expandedThreadIds]; diff --git a/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx b/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx new file mode 100644 index 000000000..8f636c660 --- /dev/null +++ b/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; + +// The `x` shortcut expands the selected conversation and marks it read. It has +// to follow markAsReadDelay like the reading pane, and a delayed mark must not +// land once the user has moved on (`j`/`k`) to a message outside it. + +vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null })); +vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, + useIsMobile: () => false, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; +const mail = (id: string, threadId: string, seen: boolean, time: string): Email => ({ + id, threadId, mailboxIds: { inbox: true }, keywords: seen ? { $seen: true } : {}, size: 1, + receivedAt: `2026-10-01T${time}:00Z`, from: [{ email: 'sender@example.com' }], subject: id, + preview: '', hasAttachment: false, +}) as Email; + +// Newest first, as the list shows it: the conversation, then a single message. +const conversation = [mail('c2', 'conv', false, '12:00'), mail('c1', 'conv', true, '11:00')]; +const single = mail('s1', 'single', true, '10:00'); + +let client: { + getAccountId: () => string; getUsername: () => string; getSieveAccountId: () => null; + hasDelayedSend: () => boolean; getSession: () => null; + getEmail: ReturnType; getThreadEmails: ReturnType; + batchMarkAsRead: ReturnType; markAsRead: ReturnType; +}; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + client = { + getAccountId: () => 'acc', getUsername: () => 'me@example.com', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getEmail: vi.fn(async (id: string) => [...conversation, single].find((e) => e.id === id) ?? null), + getThreadEmails: vi.fn(async () => conversation), + batchMarkAsRead: vi.fn(async () => {}), + markAsRead: vi.fn(async () => {}), + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', interfaceLayout: 'default' } as never); + useEmailStore.setState({ + // The conversation's newest message is open (selected with `j`). + emails: [...conversation, single], selectedEmail: conversation[0], selectedMailbox: inbox.id, mailboxes: [inbox], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null, + selectedEmailKeys: new Set(), expandedThreadIds: new Set(), threadEmailsCache: new Map(), + processingReadStatus: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + } as never); +}); +afterEach(() => { + useEmailStore.getState().collapseAllThreads(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const press = async (code: string) => { + fireEvent.keyDown(window, { key: code.slice(3).toLowerCase(), code }); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); +}; + +describe('`x` expansion follows markAsReadDelay', () => { + it('marks nothing read with "Never" (-1)', async () => { + useSettingsStore.setState({ markAsReadDelay: -1 }); + vi.useFakeTimers(); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + await press('KeyX'); + expect(useEmailStore.getState().expandedThreadIds.has('conv')).toBe(true); + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + + expect(client.batchMarkAsRead).not.toHaveBeenCalled(); + expect(client.markAsRead).not.toHaveBeenCalled(); + }); + + it('drops the delayed conversation mark once `j` opens a message outside it', async () => { + // The reading pane already shows c2 marked read here: only the thread + // expansion is under test. + useEmailStore.setState({ selectedEmail: { ...conversation[0], keywords: { $seen: true } } }); + useSettingsStore.setState({ markAsReadDelay: 5000 }); + vi.useFakeTimers(); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + await press('KeyX'); + expect(useEmailStore.getState().expandedThreadIds.has('conv')).toBe(true); + await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); + + // Move past the conversation to the single message. + await press('KeyJ'); + await press('KeyJ'); + expect(useEmailStore.getState().selectedEmail?.id).toBe('s1'); + await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); + + expect(client.batchMarkAsRead).not.toHaveBeenCalled(); + // Moving on does not collapse the conversation. + expect(useEmailStore.getState().expandedThreadIds.has('conv')).toBe(true); + }); + + it('still marks the conversation read after the delay while the user stays in it', async () => { + useEmailStore.setState({ selectedEmail: { ...conversation[0], keywords: { $seen: true } } }); + useSettingsStore.setState({ markAsReadDelay: 5000 }); + vi.useFakeTimers(); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + await press('KeyX'); + await act(async () => { await vi.advanceTimersByTimeAsync(4999); }); + expect(client.batchMarkAsRead).not.toHaveBeenCalled(); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + + expect(client.batchMarkAsRead).toHaveBeenCalledWith(['c2'], true, undefined); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 1b100d4e1..5fa720acf 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -1061,7 +1061,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, // `x` in the help modal: expand/collapse the selected email's thread. Same // steps as EmailList.handleToggleThreadExpansion - expanding pulls the - // thread's messages and marks them read, collapsing only toggles. (#683) + // thread's messages and marks them read (following markAsReadDelay), + // collapsing only toggles. (#683) onToggleThreadExpansion: () => { if (isScheduledView || !client) return; if (!selectedEmail?.threadId) return; @@ -1074,7 +1075,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (!wasExpanded) { void store.fetchThreadEmails(client, threadKey).then(() => { emitThreadExpand(threadKey, members(), true); - void useEmailStore.getState().markThreadAsRead(client, threadKey); + useEmailStore.getState().autoMarkExpandedThreadAsRead(client, threadKey); }); } else { emitThreadExpand(threadKey, members(), false); @@ -4093,6 +4094,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { && (email.sourceAccountId === undefined || e.sourceAccountId === email.sourceAccountId)) ?? (email.sourceClientAccountId ? email : undefined); + // Expanding a conversation may have scheduled a delayed "mark read" for + // it (markAsReadDelay). Moving on to a message outside that conversation + // (j/k, a click) drops it, as the reading pane drops its own pending + // mark; the conversation stays expanded. + const openRef = (listEmail ?? email) as Partial; + if (openRef.threadId) { + const openThreadKey = threadKeyFor(openRef as Email); + const emailStore = useEmailStore.getState(); + for (const key of emailStore.expandedThreadIds) { + if (key !== openThreadKey) emailStore.cancelAutoMarkThreadAsRead(key); + } + } + // Only the newest open may touch the viewer: a slow fetch for a message // the user has already clicked away from must neither replace the one // they are looking at nor end its loading state. diff --git a/components/pro/__tests__/pro-folder-tab-mark-read.test.tsx b/components/pro/__tests__/pro-folder-tab-mark-read.test.tsx new file mode 100644 index 000000000..486b47a10 --- /dev/null +++ b/components/pro/__tests__/pro-folder-tab-mark-read.test.tsx @@ -0,0 +1,112 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { Email } from '@/lib/jmap/types'; +import { useSettingsStore } from '@/stores/settings-store'; + +/** + * A Pro folder tab marked a message read the moment its row was clicked, + * ignoring "Mark as read: Never" / a delay. It must follow markAsReadDelay + * like the reading pane does. + */ + +vi.mock('@/components/pro/pro-email-tab-body', () => ({ + ProEmailView: ({ emailId }: { emailId: string }) => React.createElement('div', { 'data-testid': 'reader' }, emailId), +})); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: () => null, +})); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), +})); + +const client = vi.hoisted(() => ({ + getEmails: vi.fn(), + markAsRead: vi.fn(async () => {}), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { client, getClientForAccount: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state); + hook.getState = () => state; + return { useAuthStore: hook }; +}); + +const { ProFolderTabBody } = await import('../pro-folder-tab-body'); + +const unread = (id: string, subject: string): Email => + ({ + id, + threadId: id, + mailboxIds: { inbox: true }, + keywords: {}, + from: [{ name: 'Sender', email: 'sender@example.com' }], + subject, + receivedAt: '2026-10-01T10:00:00Z', + preview: '', + hasAttachment: false, + size: 1, + }) as unknown as Email; + +async function renderTab() { + render( + , + ); + await act(async () => {}); +} + +describe('Pro folder tab row click follows markAsReadDelay', () => { + beforeEach(() => { + vi.useFakeTimers(); + client.markAsRead.mockClear(); + client.getEmails.mockResolvedValue({ + emails: [unread('e1', 'First'), unread('e2', 'Second')], + total: 2, + hasMore: false, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not mark the message read with "Never" (-1)', async () => { + useSettingsStore.setState({ markAsReadDelay: -1 }); + await renderTab(); + + fireEvent.click(screen.getByText('First')); + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + + expect(screen.getByTestId('reader')).toHaveTextContent('e1'); + expect(client.markAsRead).not.toHaveBeenCalled(); + }); + + it('marks the message read at once with "Instantly" (0)', async () => { + useSettingsStore.setState({ markAsReadDelay: 0 }); + await renderTab(); + + fireEvent.click(screen.getByText('First')); + await act(async () => {}); + + expect(client.markAsRead).toHaveBeenCalledWith('e1', true, undefined); + }); + + it('marks the message read after the delay, and only the row still open', async () => { + useSettingsStore.setState({ markAsReadDelay: 3000 }); + await renderTab(); + + fireEvent.click(screen.getByText('First')); + await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); + // Moving on to another row before the delay ran out drops the first mark. + fireEvent.click(screen.getByText('Second')); + await act(async () => { await vi.advanceTimersByTimeAsync(2999); }); + expect(client.markAsRead).not.toHaveBeenCalled(); + + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + expect(client.markAsRead).toHaveBeenCalledTimes(1); + expect(client.markAsRead).toHaveBeenCalledWith('e2', true, undefined); + }); +}); diff --git a/components/pro/pro-folder-tab-body.tsx b/components/pro/pro-folder-tab-body.tsx index 4479ebbd9..0746b47c6 100644 --- a/components/pro/pro-folder-tab-body.tsx +++ b/components/pro/pro-folder-tab-body.tsx @@ -16,6 +16,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query"; import { usePaneId } from "@/hooks/use-pane-context"; import type { Email, Mailbox } from "@/lib/jmap/types"; import { emailHooks } from "@/lib/plugin-hooks"; +import { scheduleAutoMarkAsRead } from "@/lib/mark-as-read-delay"; interface ProFolderTabBodyProps { tabId: string; @@ -152,8 +153,18 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) { ); }, [data.mailboxId, paneId, t]); + // Opening a row marks it read on the user's markAsReadDelay, like the + // reading pane does; a pending delayed mark is dropped when another row is + // opened or the tab goes away. + const cancelRowReadRef = useRef<(() => void) | null>(null); + useEffect(() => () => cancelRowReadRef.current?.(), []); + const handleRowClick = useCallback((email: Email) => { - void markRowAsRead(email); + cancelRowReadRef.current?.(); + cancelRowReadRef.current = scheduleAutoMarkAsRead( + useSettingsStore.getState().markAsReadDelay, + () => void markRowAsRead(email), + ); if (isMobile) { // No room for a reading pane - promote to a dedicated email tab. openAsEmailTab(email, true); diff --git a/lib/__tests__/mark-as-read-delay.test.ts b/lib/__tests__/mark-as-read-delay.test.ts new file mode 100644 index 000000000..fff1fab58 --- /dev/null +++ b/lib/__tests__/mark-as-read-delay.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { scheduleAutoMarkAsRead } from '../mark-as-read-delay'; + +describe('scheduleAutoMarkAsRead', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('never marks with "Never" (-1)', () => { + const mark = vi.fn(); + scheduleAutoMarkAsRead(-1, mark); + vi.advanceTimersByTime(60_000); + expect(mark).not.toHaveBeenCalled(); + }); + + it('marks synchronously with "Instantly" (0)', () => { + const mark = vi.fn(); + scheduleAutoMarkAsRead(0, mark); + expect(mark).toHaveBeenCalledTimes(1); + }); + + it('marks once the delay has passed', () => { + const mark = vi.fn(); + scheduleAutoMarkAsRead(3000, mark); + vi.advanceTimersByTime(2999); + expect(mark).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(mark).toHaveBeenCalledTimes(1); + }); + + it('drops a pending delayed mark when cancelled', () => { + const mark = vi.fn(); + const cancel = scheduleAutoMarkAsRead(3000, mark); + vi.advanceTimersByTime(1000); + cancel(); + vi.advanceTimersByTime(10_000); + expect(mark).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/mark-as-read-delay.ts b/lib/mark-as-read-delay.ts new file mode 100644 index 000000000..f1a10a359 --- /dev/null +++ b/lib/mark-as-read-delay.ts @@ -0,0 +1,24 @@ +// Automatic "mark as read" timing for surfaces that mark a message read +// because it was shown. Used by thread expansion and Pro folder rows; same +// rules as the reading pane (`mail-app.tsx`) and `useMarkReadWhenShown`. +// +// `markAsReadDelay` is the user's setting in milliseconds: -1 = never, +// 0 = instantly, anything else = after that many milliseconds. Surfaces that +// skipped the setting marked mail read even with "Never" selected. + +const noop = () => {}; + +/** + * Runs `mark` according to `markAsReadDelay` and returns a function that + * cancels a still-pending delayed mark. "Never" (any negative value) does + * nothing; 0 runs `mark` synchronously. + */ +export function scheduleAutoMarkAsRead(markAsReadDelay: number, mark: () => void): () => void { + if (markAsReadDelay < 0) return noop; + if (markAsReadDelay === 0) { + mark(); + return noop; + } + const timeout = setTimeout(mark, markAsReadDelay); + return () => clearTimeout(timeout); +} diff --git a/stores/__tests__/email-store-thread-expand-mark-read.test.ts b/stores/__tests__/email-store-thread-expand-mark-read.test.ts new file mode 100644 index 000000000..f75d68498 --- /dev/null +++ b/stores/__tests__/email-store-thread-expand-mark-read.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import { useSettingsStore } from '../settings-store'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +/** + * Expanding a conversation in the message list (row click, expand chevron or + * the `x` shortcut) marked its unread messages read straight away, even with + * "Mark as read: Never". It must follow markAsReadDelay like the reading pane. + */ + +const makeMailbox = (overrides: Partial = {}): Mailbox => ({ + id: 'inbox', + name: 'Inbox', + role: 'inbox', + sortOrder: 0, + totalEmails: 2, + unreadEmails: 1, + totalThreads: 1, + unreadThreads: 1, + myRights: { + mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, + maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true, + }, + isSubscribed: true, + isShared: false, + ...overrides, +}); + +const makeEmail = (id: string, seen: boolean): Email => + ({ + id, + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: seen ? { $seen: true } : {}, + size: 1, + receivedAt: '2026-10-01T10:00:00Z', + subject: id, + preview: '', + hasAttachment: false, + }) as unknown as Email; + +function makeClient() { + return { + getAccountId: () => 'acct-a', + batchMarkAsRead: vi.fn(async () => {}), + } as unknown as IJMAPClient & { batchMarkAsRead: ReturnType }; +} + +describe('autoMarkExpandedThreadAsRead follows markAsReadDelay', () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + client = makeClient(); + useAuthStore.setState({ + activeAccountId: 'login-a', + getClientForAccount: () => client as never, + } as never); + useEmailStore.setState({ + isUnifiedView: false, + unifiedRole: null, + viewingAccountId: null, + selectedMailbox: 'inbox', + mailboxes: [makeMailbox()], + accountMailboxes: {}, + emails: [makeEmail('m1', true), makeEmail('m2', false)], + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + processingReadStatus: new Set(), + }); + }); + + afterEach(() => { + useEmailStore.getState().collapseAllThreads(); + vi.useRealTimers(); + }); + + const expand = () => { + useEmailStore.getState().toggleThreadExpansion('t1'); + useEmailStore.getState().autoMarkExpandedThreadAsRead(client, 't1'); + }; + + it('leaves the conversation unread with "Never" (-1)', async () => { + useSettingsStore.setState({ markAsReadDelay: -1 }); + expand(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(client.batchMarkAsRead).not.toHaveBeenCalled(); + expect(useEmailStore.getState().emails.find((e) => e.id === 'm2')?.keywords?.$seen).toBeFalsy(); + }); + + it('marks the unread messages read at once with "Instantly" (0)', async () => { + useSettingsStore.setState({ markAsReadDelay: 0 }); + expand(); + await vi.advanceTimersByTimeAsync(0); + + expect(client.batchMarkAsRead).toHaveBeenCalledWith(['m2'], true, undefined); + }); + + it('marks the unread messages read after the configured delay', async () => { + useSettingsStore.setState({ markAsReadDelay: 3000 }); + expand(); + await vi.advanceTimersByTimeAsync(2999); + expect(client.batchMarkAsRead).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(client.batchMarkAsRead).toHaveBeenCalledWith(['m2'], true, undefined); + }); + + it('drops the delayed mark when the conversation is collapsed first', async () => { + useSettingsStore.setState({ markAsReadDelay: 3000 }); + expand(); + await vi.advanceTimersByTimeAsync(1000); + useEmailStore.getState().toggleThreadExpansion('t1'); + await vi.advanceTimersByTimeAsync(10_000); + + expect(client.batchMarkAsRead).not.toHaveBeenCalled(); + }); + + it('drops the delayed mark when cancelled, and leaves the thread expanded', async () => { + useSettingsStore.setState({ markAsReadDelay: 3000 }); + expand(); + await vi.advanceTimersByTimeAsync(1000); + useEmailStore.getState().cancelAutoMarkThreadAsRead('t1'); + await vi.advanceTimersByTimeAsync(10_000); + + expect(client.batchMarkAsRead).not.toHaveBeenCalled(); + expect(useEmailStore.getState().expandedThreadIds.has('t1')).toBe(true); + }); + + it('drops the delayed mark when every thread is collapsed', async () => { + useSettingsStore.setState({ markAsReadDelay: 3000 }); + expand(); + useEmailStore.getState().collapseAllThreads(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(client.batchMarkAsRead).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index f568bb9b7..16139b1b0 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -11,6 +11,7 @@ import { useCalendarStore } from "@/stores/calendar-store"; import { orderKeywords, placeHeldRows, sanitizeSortLevels, type HeldKeywords, type SortLevel } from "@/lib/message-list-order"; import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; import { emailHooks } from "@/lib/plugin-hooks"; +import { scheduleAutoMarkAsRead } from "@/lib/mark-as-read-delay"; import { allowEmailMove, createMailboxWithHooks, emitEmailMove, idsFromSelectionKeys, keywordChangeLists, mailboxToView, quotaToView } from "@/lib/plugin-mail-hooks"; import { emailSummary, emailToReadView } from "@/lib/plugin-projection"; import { resolveThreadRoute } from "@/lib/thread-routing"; @@ -453,6 +454,17 @@ interface EmailStore { updateThreadCache: (threadId: string, emails: Email[]) => void; fetchThreadEmailCounts: (client: IJMAPClient) => Promise; markThreadAsRead: (client: IJMAPClient, threadKey: string) => Promise; + /** + * Marks an expanded thread read the way the reading pane marks a message: + * following `markAsReadDelay` (never / instantly / after a delay). A delayed + * mark is dropped when the thread is collapsed first. + */ + autoMarkExpandedThreadAsRead: (client: IJMAPClient, threadKey: string) => void; + /** + * Drops a still-pending delayed mark for the thread, without collapsing it. + * Used when the user moves on to a message outside that thread. + */ + cancelAutoMarkThreadAsRead: (threadKey: string) => void; // Mailbox management createMailbox: (client: IJMAPClient, name: string, parentId?: string, accountId?: string) => Promise; @@ -1082,6 +1094,18 @@ function folderListState(s: EmailStore): FolderList { /** The list row beginOpenEmail put in the viewer, until its message arrives. */ let openingRow: Email | null = null; +// Pending delayed "mark thread read" timers, by thread key. Collapsing the +// thread, or opening a message outside it, cancels its timer. +const pendingThreadReads = new Map void>(); + +function cancelPendingThreadRead(threadKey?: string): void { + const keys = threadKey === undefined ? [...pendingThreadReads.keys()] : [threadKey]; + for (const key of keys) { + pendingThreadReads.get(key)?.(); + pendingThreadReads.delete(key); + } +} + /** * The per-thread message counts to store once a page arrived: the counts that * page brought along (Thread/get chained onto its query) under the keys the @@ -5646,9 +5670,10 @@ export const useEmailStore = create((set, get) => ({ if (newExpandedThreadIds.has(threadKey)) { newExpandedThreadIds.delete(threadKey); + cancelPendingThreadRead(threadKey); } else { newExpandedThreadIds.add(threadKey); - // Expanding marks the thread read; hold it before that lands. + // Expanding may mark the thread read; hold it before that lands. get().holdListRow(threadKey); } @@ -5718,6 +5743,22 @@ export const useEmailStore = create((set, get) => ({ } }, + autoMarkExpandedThreadAsRead: (client, threadKey) => { + cancelPendingThreadRead(threadKey); + const { markAsReadDelay } = useSettingsStore.getState(); + const cancel = scheduleAutoMarkAsRead(markAsReadDelay, () => { + pendingThreadReads.delete(threadKey); + // Collapsed (or the list was reset) before the delay ran out. + if (!get().expandedThreadIds.has(threadKey)) return; + void get().markThreadAsRead(client, threadKey); + }); + if (markAsReadDelay > 0) pendingThreadReads.set(threadKey, cancel); + }, + + cancelAutoMarkThreadAsRead: (threadKey) => { + cancelPendingThreadRead(threadKey); + }, + markThreadAsRead: async (client, threadKey) => { const state = get(); const threadEmails = state.threadEmailsCache.get(threadKey) ?? []; @@ -5815,6 +5856,7 @@ export const useEmailStore = create((set, get) => ({ }, collapseAllThreads: () => { + cancelPendingThreadRead(); set({ expandedThreadIds: new Set(), isLoadingThread: null