feat: keep the selection while reading a message in mountain view #1231

This commit is contained in:
Linus Rath
2026-10-13 15:40:13 +02:00
parent 9cae33e3cb
commit e09676126b
3 changed files with 52 additions and 7 deletions
@@ -296,6 +296,44 @@ describe('ThreadListItem opening a thread on the phone', () => {
});
});
// #1231: opening a message threw away the ticked rows. Mountain View on the
// desktop keeps them, as Gmail does, so selecting can carry on after reading.
describe('ThreadListItem keeping the selection when a message opens', () => {
function open(interfaceLayout: 'mountain-view' | 'default', emails: Email[]) {
useSettingsStore.setState({ interfaceLayout, mailLayout: 'split' });
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
useEmailStore.setState({ selectedEmailKeys: new Set(['other']), selectedMailbox: 'inbox' });
const onEmailSelect = vi.fn();
const [thread] = groupEmailsByThread(emails);
render(
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={onEmailSelect} />,
);
fireEvent.click(screen.getByText('Test Subject'));
return onEmailSelect;
}
const conversation = () => [
makeEmail({ id: 'older', receivedAt: '2024-01-14T10:00:00Z' }),
makeEmail({ id: 'newer', receivedAt: '2024-01-15T10:00:00Z' }),
];
it('keeps the ticked rows when a Mountain View message opens', () => {
const onEmailSelect = open('mountain-view', [makeEmail()]);
expect(onEmailSelect).toHaveBeenCalledTimes(1);
expect([...useEmailStore.getState().selectedEmailKeys]).toEqual(['other']);
});
it('keeps them when a Mountain View conversation opens', () => {
const onEmailSelect = open('mountain-view', conversation());
expect(onEmailSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'newer' }));
expect([...useEmailStore.getState().selectedEmailKeys]).toEqual(['other']);
});
it('still clears them in the default layout', () => {
open('default', [makeEmail()]);
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
});
});
describe('ThreadListItem Mountain View phone row layout', () => {
it('sets the star at the end of the preview line, not under the time', () => {
useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split', showPreview: true, density: 'regular' } as never);
+5 -2
View File
@@ -438,7 +438,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
e.preventDefault();
selectRangeEmails(email);
} else {
if (selectedEmailKeys.size > 0) clearSelection();
// Mountain View keeps the ticked rows while a message is read, as
// Gmail does, so selecting can carry on afterwards. (#1231)
if (selectedEmailKeys.size > 0 && !mvRow) clearSelection();
onClick();
}
};
@@ -971,7 +973,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
}
if (mvRow || mvMobileRow) {
if (selectedEmailKeys.size > 0) clearSelection();
// Desktop keeps the ticked rows while the conversation is read (#1231).
if (selectedEmailKeys.size > 0 && !mvRow) clearSelection();
onEmailSelect(latestEmail);
return;
}
+9 -5
View File
@@ -804,7 +804,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
onArchive: async () => {
if (isScheduledView) return;
if (selectedEmailKeys.size > 0 && client) {
if (shortcutsTargetSelection && client) {
const count = selectedEmailKeys.size;
await runBatchEmailAction(() => batchArchive(client), {
success: t('notifications.emails_archived', { count }),
@@ -819,7 +819,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
onDelete: async () => {
if (isScheduledView) return;
if (selectedEmailKeys.size > 0 && client) {
if (shortcutsTargetSelection && client) {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const permanent = bulkDeleteIsPermanent(currentMailbox);
if (shouldConfirmBulkDelete(currentMailbox)) {
@@ -849,7 +849,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMarkAsUnread: async () => {
if (isScheduledView) return;
if (!client) return;
if (selectedEmailKeys.size > 0) {
if (shortcutsTargetSelection) {
await batchMarkAsRead(client, false);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, false);
@@ -858,7 +858,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMarkAsRead: async () => {
if (isScheduledView) return;
if (!client) return;
if (selectedEmailKeys.size > 0) {
if (shortcutsTargetSelection) {
await batchMarkAsRead(client, true);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, true);
@@ -872,7 +872,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// is a no-op there too.
if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return;
const isInJunk = currentMailbox?.role === 'junk';
if (selectedEmailKeys.size > 0 && client) {
if (shortcutsTargetSelection && client) {
// The spam actions take bare ids of the viewed account: resolve the
// selection keys through the loaded rows and thread members.
const ids = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
@@ -981,6 +981,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailKeys, mailboxes, isScheduledView, mvShell, showCrossStarred]);
// Initialize keyboard shortcuts
// Mountain View keeps the ticked rows while a message is read (#1231). The
// shortcuts then act on the message on screen, as its toolbar does, and
// reach the selection again back in the list.
const shortcutsTargetSelection = selectedEmailKeys.size > 0 && !(mvShell && !cursorOnly && selectedEmail);
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer,
emails: activeEmails,