Files
webmail/components/pro/__tests__/pro-folder-tab-mark-read.test.tsx
T
Peterka35 bbc3c2123e 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.
2026-10-17 21:01:34 +02:00

113 lines
3.6 KiB
TypeScript

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);
});
});