fix: follow markAsReadDelay when expanding a conversation or opening a Pro folder row

Expanding a conversation in the message list (row click, expand chevron,
or the `x` shortcut) and clicking a row in a Pro folder tab marked the
messages read straight away. They ignored "Mark as read: Never" and any
delay, which the reading pane and the conversation view follow.

Route them through one helper, scheduleAutoMarkAsRead: -1 never marks,
0 marks at once, any other value marks after that many milliseconds and
returns a cancel function.

- New store action autoMarkExpandedThreadAsRead keeps one pending timer
  per thread key. Collapsing the thread, or collapsing all threads, drops
  it. Both expansion paths (EmailList and the `x` handler in MailApp) use
  it.
- MailApp's handleEmailSelect drops the pending mark of every expanded
  thread other than the one the newly opened message belongs to, through
  cancelAutoMarkThreadAsRead. Moving on with `j`/`k` or a click no longer
  lets the delayed mark land on a conversation the user has left. The
  thread stays expanded.
- ProFolderTabBody schedules the row mark through the helper and drops a
  pending one when another row is opened or the tab unmounts.
This commit is contained in:
Peterka35
2026-10-17 21:01:34 +02:00
parent 2c6914ae62
commit bbc3c2123e
10 changed files with 671 additions and 9 deletions
@@ -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(<EmailList emails={THREAD} />);
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);
});
});
+5 -5
View File
@@ -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];
@@ -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: () => <div>message list</div> }));
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<typeof vi.fn>; getThreadEmails: ReturnType<typeof vi.fn>;
batchMarkAsRead: ReturnType<typeof vi.fn>; markAsRead: ReturnType<typeof vi.fn>;
};
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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);
});
});
+16 -2
View File
@@ -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<Email>;
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.
@@ -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(
<ProFolderTabBody
tabId="folder-1"
data={{ accountId: null, mailboxId: 'inbox', title: 'Inbox' }}
/>,
);
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);
});
});
+12 -1
View File
@@ -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);
+43
View File
@@ -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();
});
});
+24
View File
@@ -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);
}
@@ -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> = {}): 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<typeof vi.fn> };
}
describe('autoMarkExpandedThreadAsRead follows markAsReadDelay', () => {
let client: ReturnType<typeof makeClient>;
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();
});
});
+43 -1
View File
@@ -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<void>;
markThreadAsRead: (client: IJMAPClient, threadKey: string) => Promise<void>;
/**
* 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<boolean>;
@@ -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<string, () => 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<EmailStore>((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<EmailStore>((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<EmailStore>((set, get) => ({
},
collapseAllThreads: () => {
cancelPendingThreadRead();
set({
expandedThreadIds: new Set(),
isLoadingThread: null