mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user