diff --git a/components/email/__tests__/conversation-pane.test.tsx b/components/email/__tests__/conversation-pane.test.tsx new file mode 100644 index 000000000..2383f9a0b --- /dev/null +++ b/components/email/__tests__/conversation-pane.test.tsx @@ -0,0 +1,110 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useSettingsStore } from '@/stores/settings-store'; + +// The default layout's reading pane shows the whole conversation, as Mountain +// View does: the newest message open, the older ones folded to a line each +// and unfolded in place. Without `conversationPane` it shows the message alone. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function mail(n: number, overrides: Partial = {}): Email { + return { + id: `m${n}`, blobId: `b${n}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: `Sender ${n}`, email: `s${n}@example.com` }], to: [{ name: 'Me', email: 'me@example.com' }], + subject: 'Offsite agenda', receivedAt: `2026-01-0${n}T10:00:00Z`, preview: `Preview ${n}`, + hasAttachment: false, size: 100, ...overrides, + } as unknown as Email; +} +function withBody(e: Email, text: string): Email { + return { + ...e, textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], attachments: [], + bodyValues: { '1': { value: text, isTruncated: false } }, + } as unknown as Email; +} + +const foldedIds = (c: HTMLElement) => + [...c.querySelectorAll('[data-mv-conversation-card]')].map((e) => e.getAttribute('data-email-id')); +const expandedIds = (c: HTMLElement) => + [...c.querySelectorAll('[data-mv-conversation-expanded]')].map((e) => e.getAttribute('data-email-id')); + +function renderViewer(conversation: Email[], open: Email, props: Partial[0]> = {}) { + return render( + , + ); +} + +describe('The conversation in the default layout\'s reading pane', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'default', markAsReadDelay: 0 }); + }); + + it('shows the older messages folded above the open newest one', () => { + const open = withBody(mail(6), 'Newest body'); + const conversation = [mail(1), mail(2), mail(3), mail(4), mail(5), open]; + const { container } = renderViewer(conversation, open, { conversationPane: true }); + + // A long run keeps its first and last message around a count. + expect(foldedIds(container)).toEqual(['m1', 'm5']); + expect(container.querySelector('[data-mv-conversation-before]')).not.toBeNull(); + expect(screen.getByText('Newest body')).toBeInTheDocument(); + + // The count names what it does, and shows the rest. + fireEvent.click(screen.getByRole('button', { name: 'show_older' })); + expect(foldedIds(container)).toEqual(['m1', 'm2', 'm3', 'm4', 'm5']); + }); + + it('unfolds an older message in place and loads its body', () => { + const open = withBody(mail(3), 'Newest body'); + const onLoad = vi.fn(); + const { container } = renderViewer([mail(1), mail(2), open], open, { + conversationPane: true, + onLoadConversationEmail: onLoad, + }); + fireEvent.click(screen.getByText('Sender 1')); + expect(expandedIds(container)).toEqual(['m1']); + expect(onLoad).toHaveBeenCalledWith(expect.objectContaining({ id: 'm1' })); + }); + + it('opens unread older messages unfolded and marks them read', () => { + const open = withBody(mail(3), 'Newest body'); + const onRead = vi.fn(); + const { container } = renderViewer([mail(1), mail(2, { keywords: {} }), open], open, { + conversationPane: true, + onMarkConversationEmailRead: onRead, + }); + expect(expandedIds(container)).toEqual(['m2']); + expect(onRead).toHaveBeenCalledWith(expect.objectContaining({ id: 'm2' })); + }); + + it('shows the message alone without it ("Show emails as individual messages")', () => { + const open = withBody(mail(3), 'Newest body'); + const { container } = renderViewer([mail(1), mail(2), open], open); + expect(foldedIds(container)).toEqual([]); + expect(container.querySelector('[data-mv-conversation-before]')).toBeNull(); + expect(screen.getByText('Newest body')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/thread-list-item-conversation-pane.test.tsx b/components/email/__tests__/thread-list-item-conversation-pane.test.tsx new file mode 100644 index 000000000..711ae9d2a --- /dev/null +++ b/components/email/__tests__/thread-list-item-conversation-pane.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThreadListItem } from '../thread-list-item'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useUIStore } from '@/stores/ui-store'; +import { groupEmailsByThread } from '@/lib/thread-utils'; +import type { Email } from '@/lib/jmap/types'; + +// With the conversation in the reading pane, the list shows one row per +// conversation: a click opens its newest message there, and the row has no +// chevron to unfold it in the list. + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); +vi.mock('@/stores/auth-store', () => { + const state = { identities: [], username: null, client: null, activeAccountId: null, getClientForAccount: () => undefined }; + const useAuthStore = Object.assign( + (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + { getState: () => state }, + ); + return { useAuthStore }; +}); + +const makeEmail = (overrides: Partial = {}): Email => ({ + id: 'email-1', threadId: 't', mailboxIds: { inbox: true }, keywords: { $seen: true }, size: 1000, + receivedAt: '2024-01-15T10:00:00Z', from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Offsite agenda', hasAttachment: false, ...overrides, +}); +const conversation = () => groupEmailsByThread([ + makeEmail({ id: 'a', receivedAt: '2024-01-15T09:00:00Z' }), + makeEmail({ id: 'b', receivedAt: '2024-01-15T10:00:00Z', from: [{ name: 'Bob', email: 'bob@example.com' }] }), +])[0]; + +function renderRow(conversationInReader: boolean) { + const onEmailSelect = vi.fn(); + const onToggleExpand = vi.fn(); + const onCollapseAllThreads = vi.fn(); + const thread = conversation(); + render( + , + ); + return { onEmailSelect, onToggleExpand, onCollapseAllThreads, thread }; +} + +beforeEach(() => { + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], showPreview: true, mailLayout: 'split', + interfaceLayout: 'default', listColumns: false, + }); + useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox', isUnifiedView: false }); +}); + +describe('A conversation row with the conversation in the reading pane', () => { + it('opens the newest message without unfolding the conversation in the list', () => { + const { onEmailSelect, onToggleExpand, onCollapseAllThreads, thread } = renderRow(true); + fireEvent.click(screen.getByRole('row')); + expect(onEmailSelect).toHaveBeenCalledWith(thread.latestEmail); + expect(onToggleExpand).not.toHaveBeenCalled(); + expect(onCollapseAllThreads).not.toHaveBeenCalled(); + }); + + it('has no chevron and does not claim to unfold', () => { + renderRow(true); + expect(screen.queryByRole('button', { name: /expand_named/ })).toBeNull(); + expect(screen.getByRole('row')).not.toHaveAttribute('aria-expanded'); + }); + + it('has no chevron in the columns layout either', () => { + useSettingsStore.setState({ mailLayout: 'horizontal', listColumns: true }); + renderRow(true); + expect(screen.queryByRole('button', { name: /expand_named/ })).toBeNull(); + }); + + it('still ticks the whole conversation with Ctrl+click', () => { + const { onEmailSelect } = renderRow(true); + fireEvent.click(screen.getByRole('row'), { ctrlKey: true }); + expect(useEmailStore.getState().selectedEmailKeys.size).toBe(2); + expect(onEmailSelect).not.toHaveBeenCalled(); + }); + + it('unfolds in the list when the reading pane shows one message', () => { + const { onToggleExpand } = renderRow(false); + expect(screen.getByRole('button', { name: /expand_named/ })).toBeTruthy(); + fireEvent.click(screen.getByRole('row')); + expect(onToggleExpand).toHaveBeenCalled(); + }); +}); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 05d7ba06c..aaeda774e 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -87,6 +87,11 @@ interface EmailListProps { hasMore?: boolean; isLoadingMoreItems?: boolean; onOpenConversation?: (thread: ThreadGroup) => void; + /** + * The reading pane shows the whole conversation: a row opens its newest + * message there instead of unfolding the thread in the list. + */ + conversationInReader?: boolean; onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; onForward?: (email: Email) => void; @@ -164,6 +169,7 @@ export function EmailList({ hasMore, isLoadingMoreItems, onOpenConversation, + conversationInReader = false, onReply, onReplyAll, onForward, @@ -456,14 +462,14 @@ export function EmailList({ const rows: ListRow[] = []; for (const thread of threadGroups) { rows.push({ key: emailKeyFor(thread.latestEmail), members: thread.emails.map(emailKeyFor) }); - if (thread.emails.length > 1 && expandedThreadIds.has(thread.threadKey) && !isMobile) { + if (thread.emails.length > 1 && expandedThreadIds.has(thread.threadKey) && !isMobile && !conversationInReader) { for (const email of threadEmailsCache.get(thread.threadKey) ?? thread.emails) { rows.push({ key: emailKeyFor(email), members: [emailKeyFor(email)] }); } } } setListRows(rows); - }, [threadGroups, expandedThreadIds, threadEmailsCache, isMobile, setListRows]); + }, [threadGroups, expandedThreadIds, threadEmailsCache, isMobile, conversationInReader, setListRows]); useEffect(() => () => setListRows(null), [setListRows]); // The virtualizer's guesses follow the rows' shape (see useListRowShape). @@ -1013,7 +1019,8 @@ export function EmailList({ )} {thread && ; renderExpanded?: (email: Email) => React.ReactNode; }) { + const tPane = useTranslations('conversation_pane'); const [showAll, setShowAll] = useState(false); if (emails.length === 0) return null; const isExpanded = (e: Email) => !!renderExpanded && !!expandedIds?.has(e.id); @@ -1041,6 +1047,8 @@ export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, + )} {isUnifiedView && latestEmail.accountId && threadAccountColor && showAccountDot && ( { getClientForAccount: (id) => (id === 'login-primary' ? asClient(primary) : id === 'login-other' ? asClient(other) : undefined) as never, getAllConnectedClients: () => new Map([['login-primary', asClient(primary)], ['login-other', asClient(other)]]) as never, }); - useSettingsStore.setState({ interfaceLayout: 'default', markAsReadDelay: -1, enableUnifiedMailbox: false, mailAttachmentAction: 'download', mailLayout: 'split', attachmentImagePreviewsEnabled: false, postExportAction: 'none', attachmentDownloadTemplate: '{filename}', filenameLowercase: false } as never); + // One message in the reader: fetching its conversation would take the + // requests these tests count. + useSettingsStore.setState({ interfaceLayout: 'default', markAsReadDelay: -1, enableUnifiedMailbox: false, mailAttachmentAction: 'download', mailLayout: 'split', attachmentImagePreviewsEnabled: false, postExportAction: 'none', attachmentDownloadTemplate: '{filename}', filenameLowercase: false, disableThreading: true } as never); useEmailStore.setState({ emails: [message()], selectedEmail: message(), selectedMailbox: sharedMailbox.id, mailboxes: [primaryMailbox, sharedMailbox], viewingAccountId: null, accountMailboxes: {}, diff --git a/components/mail/__tests__/mail-app-conversation-pane.test.tsx b/components/mail/__tests__/mail-app-conversation-pane.test.tsx new file mode 100644 index 000000000..e26eeca3e --- /dev/null +++ b/components/mail/__tests__/mail-app-conversation-pane.test.tsx @@ -0,0 +1,218 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen, waitFor } 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 { useToastStore } from '@/stores/toast-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { clearAllHooks } from '@/lib/plugin-hooks'; +import { forgetEmailPrefetches } from '@/lib/email-prefetch'; + +// The default layout's reading pane shows the open message's conversation, on +// every layout but a phone's, and j / k go on from the last message opened. + +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +type ViewerProps = { mvConversation?: { id: string }[]; conversationPane?: boolean }; +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ mvConversation, conversationPane }: ViewerProps) => ( +
+ {mvConversation ? mvConversation.map((e) => e.id).join(',') : 'no conversation'} +
+ ), +})); +vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => 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/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => })); +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, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +function mailbox(id: string, role: string): Mailbox { + return { + id, name: role, role, sortOrder: 0, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, isSubscribed: true, + } as Mailbox; +} +const folders = [ + mailbox('inbox', 'inbox'), mailbox('archive', 'archive'), mailbox('trash', 'trash'), + mailbox('junk', 'junk'), mailbox('work', ''), +]; +const row = (id: string, minute: number, mailboxIds: Record = { inbox: true }) => ({ + id, threadId: `t-${id}`, subject: id, receivedAt: new Date(Date.UTC(2026, 9, 31, 10, minute)).toISOString(), + keywords: { $seen: true }, mailboxIds, +}) as unknown as Email; +const inbox = () => [row('m1', 30), row('m2', 20), row('m3', 10)]; + +let client: Record | (() => unknown)>; +let fetchEmailContent: ReturnType; +// Every message the store had open, in order. +let opened: (string | null)[]; +let unsubscribe: () => void; + +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) }); + useToastStore.setState({ toasts: [] }); + clearAllHooks(); + forgetEmailPrefetches(); + client = { + getAccountId: () => 'acc', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getMailboxes: vi.fn().mockResolvedValue(folders), + getThread: vi.fn(async (threadId: string) => ({ id: threadId, emailIds: [threadId.slice(2)] })), + getEmail: vi.fn(async (id: string) => ({ ...inbox().find((e) => e.id === id)!, bodyValues: {} })), + moveEmail: vi.fn().mockResolvedValue(undefined), + moveToTrash: vi.fn().mockResolvedValue(undefined), + markAsRead: vi.fn().mockResolvedValue(undefined), + markAsSpam: vi.fn().mockResolvedValue(undefined), + undoSpam: vi.fn().mockResolvedValue(undefined), + removeEmailsFromMailbox: vi.fn().mockResolvedValue(undefined), + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ + enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'focus', archiveMode: 'single', + deleteAction: 'trash', returnToListAfterAction: true, confirmArchive: false, confirmDelete: false, + singleKeyShortcuts: true, + } as never); + fetchEmailContent = vi.fn().mockResolvedValue(undefined); + const emails = inbox(); + useEmailStore.setState({ + emails, selectedEmail: emails[0], selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: folders, + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', + selectedEmailKeys: new Set(), unreadSection: null, + 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), + fetchEmailContent, + } as never); + opened = []; + unsubscribe = useEmailStore.subscribe((state, prev) => { + if (state.selectedEmail !== prev.selectedEmail) opened.push(state.selectedEmail?.id ?? null); + }); +}); +afterEach(() => { + unsubscribe(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const selectedId = () => useEmailStore.getState().selectedEmail?.id ?? null; +const press = (key: string) => act(() => { fireEvent.keyDown(window, { key }); }); + +describe('The reading pane in the default layout', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'default', mailLayout: 'split', disableThreading: false } as never); + client.getThread = vi.fn(async (threadId: string) => ({ + id: threadId, emailIds: threadId === 't-m1' ? ['m0', 'm1'] : [threadId.slice(2)], + })); + client.getSomeEmails = vi.fn(async (ids: string[]) => ids.map((id) => (id === 'm0' + ? { ...row('m0', 0), threadId: 't-m1', mailboxIds: { sent: true } } + : { ...inbox().find((e) => e.id === id)!, threadId: 't-m1' }))); + }); + + it('shows the whole conversation of the open message', async () => { + render(); + const reader = await screen.findByTestId('reader', {}, { timeout: 5000 }); + expect(reader).toHaveAttribute('data-pane', 'conversation'); + await waitFor(() => expect(reader).toHaveTextContent('m0,m1')); + }); + + it('shows the message alone with "Show emails as individual messages"', async () => { + useSettingsStore.setState({ disableThreading: true } as never); + render(); + const reader = await screen.findByTestId('reader', {}, { timeout: 5000 }); + expect(reader).toHaveAttribute('data-pane', 'message'); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); + expect(reader).toHaveTextContent('no conversation'); + expect(client.getThread).not.toHaveBeenCalled(); + }); + + it('does not unfold the conversation in the list with x', async () => { + useEmailStore.setState({ expandedThreadIds: new Set(), fetchThreadEmails: vi.fn().mockResolvedValue(undefined) } as never); + render(); + await screen.findByTestId('reader', {}, { timeout: 5000 }); + await press('x'); + expect(useEmailStore.getState().expandedThreadIds.size).toBe(0); + }); +}); + +describe('j and k after Escape', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'default', mailLayout: 'split' } as never); + useEmailStore.setState({ listRows: null } as never); + }); + + it('j opens the message after the one that was open, not the first', async () => { + render(); + await screen.findByTestId('reader', {}, { timeout: 5000 }); + await press('j'); + await waitFor(() => expect(selectedId()).toBe('m2')); + await press('Escape'); + await waitFor(() => expect(selectedId()).toBeNull()); + await press('j'); + await waitFor(() => expect(selectedId()).toBe('m3')); + }); + + it('k opens the message before it', async () => { + useEmailStore.setState({ selectedEmail: inbox()[2] } as never); + render(); + await screen.findByTestId('reader', {}, { timeout: 5000 }); + await press('Escape'); + await waitFor(() => expect(selectedId()).toBeNull()); + await press('k'); + await waitFor(() => expect(selectedId()).toBe('m2')); + }); + + it('steps by conversation, in the order of the list', async () => { + useEmailStore.setState({ + listRows: [{ key: 'm1', members: ['m1', 'm3'] }, { key: 'm2', members: ['m2'] }], + } as never); + render(); + await screen.findByTestId('reader', {}, { timeout: 5000 }); + await press('j'); + await waitFor(() => expect(selectedId()).toBe('m2')); + await press('j'); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); + expect(selectedId()).toBe('m2'); + await press('k'); + await waitFor(() => expect(selectedId()).toBe('m1')); + }); +}); diff --git a/components/mail/__tests__/mail-app-return-to-list.test.tsx b/components/mail/__tests__/mail-app-return-to-list.test.tsx index 6f64a44b3..84b280902 100644 --- a/components/mail/__tests__/mail-app-return-to-list.test.tsx +++ b/components/mail/__tests__/mail-app-return-to-list.test.tsx @@ -273,6 +273,75 @@ describe.each([ }); }); +// With the list beside the reading pane (split, horizontal) the setting, +// while nobody has changed it, opens the next message instead, as Outlook +// does. A choice the user made wins, on every layout and device. +describe('Archive with the list beside the reading pane', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'default', mailLayout: 'split', explicitSettings: [] } as never); + useEmailStore.setState({ listRows: null } as never); + }); + + it('opens the next message while the setting is untouched', async () => { + render(); + fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 })); + await waitFor(() => expect(client.moveEmail).toHaveBeenCalledWith('m1', 'archive', undefined)); + await waitFor(() => expect(selectedId()).toBe('m2')); + }); + + it('opens the next row in the order the list shows', async () => { + useEmailStore.setState({ + listRows: [{ key: 'm1', members: ['m1'] }, { key: 'm3', members: ['m3'] }, { key: 'm2', members: ['m2'] }], + } as never); + render(); + fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 })); + await waitFor(() => expect(client.moveEmail).toHaveBeenCalledWith('m1', 'archive', undefined)); + await waitFor(() => expect(selectedId()).toBe('m3')); + expect(opened).not.toContain('m2'); + }); + + it('goes back to the list when the user switched the setting on', async () => { + useSettingsStore.setState({ returnToListAfterAction: true, explicitSettings: ['returnToListAfterAction'] } as never); + render(); + fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 })); + await waitFor(() => expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['m2', 'm3'])); + expect(selectedId()).toBeNull(); + expect(opened).not.toContain('m2'); + }); + + it('opens the archived message again on Undo', async () => { + client.restoreEmails = vi.fn().mockResolvedValue(undefined); + useEmailStore.setState({ + refreshCurrentMailbox: vi.fn(async () => { useEmailStore.setState({ emails: inbox() } as never); }), + } as never); + render(); + fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 })); + await waitFor(() => expect(selectedId()).toBe('m2')); + const archived = await waitFor(() => { + const found = useToastStore.getState().toasts.find((toast) => toast.title === 'notifications.email_archived'); + expect(found?.action).toBeTruthy(); + return found!; + }); + act(() => { archived.action!.onClick(); }); + await waitFor(() => expect(client.restoreEmails).toHaveBeenCalled()); + await waitFor(() => expect(selectedId()).toBe('m1')); + }); +}); + +describe('Delete with the list beside the reading pane', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'default', mailLayout: 'horizontal', explicitSettings: [] } as never); + useEmailStore.setState({ listRows: null } as never); + }); + + it('opens the next message while the setting is untouched', async () => { + render(); + fireEvent.click(await screen.findByText('stub delete', {}, { timeout: 5000 })); + await waitFor(() => expect(client.moveToTrash).toHaveBeenCalled()); + await waitFor(() => expect(selectedId()).toBe('m2')); + }); +}); + describe('A list collapsed under an open message (focused layout)', () => { const list = () => screen.getByText('message list').closest('section')!; 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 index eb1b03844..f8162531e 100644 --- a/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx +++ b/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx @@ -11,7 +11,9 @@ 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. +// land once the user has moved on (`j`/`k`) to a message outside it. The +// default layout shows the conversation in the reading pane instead, so this +// runs where the list still unfolds it: Mountain View on a tablet. vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null })); vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => null })); @@ -32,7 +34,7 @@ vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMa 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 }), + useDeviceDetection: () => ({ isMobile: false, isTablet: true, isDesktop: false }), useMediaQuery: () => false, useIsDesktop: () => true, useIsMobile: () => false, @@ -78,7 +80,7 @@ beforeEach(() => { getClientForAccount: () => client as never, getAllConnectedClients: () => new Map([['login', client]]) as never, }); - useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', interfaceLayout: 'default' } as never); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', interfaceLayout: 'mountain-view' } 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], diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 95a20cf08..f6a40413d 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -80,6 +80,8 @@ import { useMailStatusAnnouncements } from "@/hooks/use-mail-status-announcement import { cn, formatDateTime, getMailboxPath } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, emailKeyFor, groupEmailsByThread, threadKeyFor } from "@/lib/thread-utils"; +import { stepListEmail } from "@/lib/list-step"; +import { listBesideReader, returnToListAfterAction } from "@/lib/return-to-list"; import { resolveThreadRoute } from "@/lib/thread-routing"; import { ErrorBoundary, @@ -732,6 +734,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { [scheduledEmails, scheduledAccountScope], ); const activeEmails = isScheduledView ? scopedScheduledEmails : emails; + // The default layout reads a thread as one conversation in the reading pane + // too, as Mountain View does, on every width but a phone's (those keep their + // own conversation view). The list then has one row per conversation. + // "Show emails as individual messages" keeps the single message. + const disableThreading = useSettingsStore((s) => s.disableThreading); + const paneConversationView = interfaceLayout !== 'mountain-view' && !isMobile && !disableThreading && !isScheduledView; + const readerConversationView = mvConversationView || paneConversationView; // In an account-scoped scheduled view the visible rows are a filtered subset // of the loaded page, so "has more" must reflect that account's own total // rather than the combined one — otherwise the list looks complete while @@ -973,23 +982,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { ); }; + // The open message's list key, kept after the reader closes. + const lastOpenedKeyRef = useRef(null); + useEffect(() => { + if (selectedEmail) lastOpenedKeyRef.current = emailKeyFor(selectedEmail); + }, [selectedEmail]); // Keyboard shortcuts handlers + // j / k go on from the open message, or from the last one opened once the + // reader is closed (Escape), not from the top. With the conversation in the + // reading pane they step by conversation, as the list's rows do. + const stepFrom = (direction: 1 | -1) => stepListEmail( + activeEmails, + selectedEmail ? emailKeyFor(selectedEmail) : lastOpenedKeyRef.current, + direction, + paneConversationView ? useEmailStore.getState().listRows : null, + ); const keyboardHandlers = useMemo(() => ({ onNextEmail: () => { - if (activeEmails.length === 0) return; - const currentIndex = selectedEmail ? activeEmails.findIndex(e => emailKeyFor(e) === emailKeyFor(selectedEmail)) : -1; - const nextIndex = currentIndex < activeEmails.length - 1 ? currentIndex + 1 : currentIndex; - if (nextIndex >= 0 && nextIndex < activeEmails.length) { - void handleEmailSelect(activeEmails[nextIndex]); + const next = stepFrom(1); + if (next) { + void handleEmailSelect(next); if (mvShell) setCursorOnly(true); } }, onPreviousEmail: () => { - if (activeEmails.length === 0) return; - const currentIndex = selectedEmail ? activeEmails.findIndex(e => emailKeyFor(e) === emailKeyFor(selectedEmail)) : activeEmails.length; - const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0; - if (prevIndex >= 0 && prevIndex < activeEmails.length) { - void handleEmailSelect(activeEmails[prevIndex]); + const previous = stepFrom(-1); + if (previous) { + void handleEmailSelect(previous); if (mvShell) setCursorOnly(true); } }, @@ -1242,7 +1261,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // thread's messages and marks them read (following markAsReadDelay), // collapsing only toggles. (#683) onToggleThreadExpansion: () => { - if (isScheduledView || !client) return; + // The reading pane shows the conversation; the list never unfolds it. + if (isScheduledView || !client || paneConversationView) return; if (!selectedEmail?.threadId) return; const threadKey = threadKeyFor(selectedEmail); const store = useEmailStore.getState(); @@ -3330,12 +3350,41 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // holds (a quick filter dropped it) stays selected: the store finds it // through the selection (#695), and with no row to go on from it opens // nothing after it anyway. (#1458) + // + // Left alone, the setting follows the layout (lib/return-to-list): with the + // list beside the reading pane the next message opens instead, as in + // Outlook. With the conversation in the reading pane that is the next + // conversation down the list (the one above at the end), as its row opens it. + const returnsToList = () => returnToListAfterAction( + !fullscreenReading && listBesideReader({ mailLayout, interfaceLayout, isMobile, isTablet }), + ); const leaveIfOpen = (email: Email) => { - const { selectedEmail: open, emails: rows } = useEmailStore.getState(); + const { selectedEmail: open, emails: rows, listRows } = useEmailStore.getState(); const key = emailKeyFor(email); if (!open || emailKeyFor(open) !== key || (mvShell && cursorOnly)) return; if (!rows.some((row) => emailKeyFor(row) === key)) return; - if (useSettingsStore.getState().returnToListAfterAction) handleMobileBack(); + if (returnsToList()) { + handleMobileBack(); + return; + } + if (!paneConversationView || !listRows) return; + const below = stepListEmail(rows, key, 1, listRows); + const next = below && emailKeyFor(below) !== key ? below : stepListEmail(rows, key, -1, listRows); + if (!next || emailKeyFor(next) === key || threadKeyFor(next) === threadKeyFor(email)) return; + handleMobileBack(); + void handleEmailSelect(next); + }; + // Undo puts back the message that was open, and opens it again, where it + // was open in the reading pane. Phones stay in the list. + const reopenEmailRef = useRef<((email: Email) => Promise) | null>(null); + const reopenAfterUndo = (email: Email) => { + const { selectedEmail: open } = useEmailStore.getState(); + if (isMobile || !open || emailKeyFor(open) !== emailKeyFor(email)) return undefined; + const key = emailKeyFor(email); + return () => { + const restored = useEmailStore.getState().emails.find((e) => emailKeyFor(e) === key); + if (restored) void reopenEmailRef.current?.(restored); + }; }; const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { @@ -3379,6 +3428,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return; } + const reopen = reopenAfterUndo(emailToDelete); leaveIfOpen(emailToDelete); // The store's delete reads the "Delete action" setting: move to the @@ -3387,7 +3437,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { try { // false: a plugin stopped it and said why, so no "deleted" here. if (await deleteEmail(client, emailToDelete.id, isInTrash, emailKeyFor(emailToDelete))) { - toastWithUndo(permanent ? t('notifications.email_deleted') : t('mail_list_extra.moved_to_trash'), undoMoveAction(client, restore, undo)); + toastWithUndo(permanent ? t('notifications.email_deleted') : t('mail_list_extra.moved_to_trash'), undoMoveAction(client, restore, undo, undefined, reopen)); } } catch (error) { console.error("Failed to delete email:", error); @@ -3487,6 +3537,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } let restore: EmailRestoreBatch[] | null = null; + const reopen = reopenAfterUndo(emailToArchive); leaveIfOpen(emailToArchive); const archived = await moveThreadToMailbox(client, emailToArchive.id, destination.id, emailKeyFor(emailToArchive), (batches) => { restore = batches; }); if (!archived) return; @@ -3498,7 +3549,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setConversationEmails([]); } - toastWithUndo(t('mail_list_extra.archived'), undoMoveAction(client, restore, undo)); + toastWithUndo(t('mail_list_extra.archived'), undoMoveAction(client, restore, undo, undefined, reopen)); void refreshMailboxes(); } catch (error) { console.error("Failed to archive email:", error); @@ -4594,8 +4645,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Mountain View reads a message inside its conversation, as Gmail does: load // the rest of the thread (list properties only) whenever another opens. - const mvConversationThreadId = mvConversationView && !cursorOnly ? (selectedEmail?.threadId ?? null) : null; - const mvConversationRef = mvConversationView && !cursorOnly ? selectedEmail : null; + const mvConversationThreadId = readerConversationView && !cursorOnly ? (selectedEmail?.threadId ?? null) : null; + const mvConversationRef = readerConversationView && !cursorOnly ? selectedEmail : null; const mvConversationRoute = (ref: Email, fallback: NonNullable) => { const source = resolveEmailBlobContext(ref, fallback); return { threadClient: source.client, accountId: source.accountId }; @@ -4902,7 +4953,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // hand) and let the body follow; the viewer shows a row without body // values as loading. Mountain View shows the message inside its // conversation, which is fetched alongside, so it waits for both. - const rowEmail = !mvConversationView && listEmail && 'receivedAt' in listEmail ? listEmail as Email : null; + const rowEmail = !readerConversationView && listEmail && 'receivedAt' in listEmail ? listEmail as Email : null; if (rowEmail) { beginOpenEmail(rowEmail); } else { @@ -4931,7 +4982,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Mountain View shows the message inside its conversation: fetch that // alongside the message, so the two appear together. (#1223) - const conversationPrefetch = mvConversationView && listEmail?.id && listEmail.threadId + const conversationPrefetch = readerConversationView && listEmail?.id && listEmail.threadId ? loadMvConversation(listEmail as Email, client) : null; // The request a mouse press on the row already started, if any. @@ -4994,6 +5045,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; pluginOpenEmailRef.current = handleEmailSelect; + reopenEmailRef.current = handleEmailSelect; // Handle back navigation from viewer on mobile. // Reset to list state directly. We can't just call window.history.back() @@ -5765,6 +5817,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, { pane: targetPane, reuseReader: hasSplit }); })} onOpenConversation={handleOpenConversation} + conversationInReader={paneConversationView} // Context menu handlers onReply={(email) => { selectEmail(email); @@ -6052,7 +6105,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Marking the open message unread returns to the list // (Gmail-style, gated on returnToListAfterAction). Staying // in the reading pane would just re-mark it read on view. - if (!read && useSettingsStore.getState().returnToListAfterAction) { + if (!read && returnsToList()) { handleMobileBack(); } } @@ -6069,16 +6122,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { total: Math.max(activeEmails.length, mailboxes.find((mb) => mb.id === selectedMailbox)?.totalEmails ?? (isUnifiedView ? listQueryTotal : 0)), } : undefined} mvFolderName={mvShell && !isScheduledView && !isUnifiedView ? currentMailboxName : undefined} - mvConversation={mvConversationView ? mvConversation ?? undefined : undefined} - mvConversationPending={mvConversationView && !!selectedEmail && mvConversationLoading === selectedEmail.threadId} - onLoadConversationEmail={mvConversationView ? (e) => void handleLoadConversationEmail(e) : undefined} - onMarkConversationEmailRead={mvConversationView ? (e) => void handleMarkConversationEmailRead(e) : undefined} - onReplyConversationEmail={mvConversationView ? async (e) => { + mvConversation={readerConversationView ? mvConversation ?? undefined : undefined} + conversationPane={paneConversationView} + mvConversationPending={readerConversationView && !!selectedEmail && mvConversationLoading === selectedEmail.threadId} + onLoadConversationEmail={readerConversationView ? (e) => void handleLoadConversationEmail(e) : undefined} + onMarkConversationEmailRead={readerConversationView ? (e) => void handleMarkConversationEmailRead(e) : undefined} + onReplyConversationEmail={readerConversationView ? async (e) => { // The reply quotes the message, so it needs the body. const full = e.bodyValues ? e : await handleLoadConversationEmail(e); await handleConversationReply(full ?? e); } : undefined} - onReplyAllConversationEmail={mvConversationView ? async (e) => { + onReplyAllConversationEmail={readerConversationView ? async (e) => { const full = e.bodyValues ? e : await handleLoadConversationEmail(e); await handleConversationReplyAll(full ?? e); } : undefined} diff --git a/components/settings/accessibility-settings.tsx b/components/settings/accessibility-settings.tsx index 3f6f606e2..515d9ea2e 100644 --- a/components/settings/accessibility-settings.tsx +++ b/components/settings/accessibility-settings.tsx @@ -12,6 +12,7 @@ import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode'; import { openKeyboardShortcuts } from '@/lib/app-commands'; import { voicesForLocale } from '@/lib/read-aloud'; import { useSpeechVoices } from '@/hooks/use-read-aloud'; +import { useReturnToListAfterAction } from '@/hooks/use-return-to-list'; import { NOTIFICATION_SOUNDS, playNotificationSound, type NotificationSoundChoice } from '@/lib/notification-sound'; import { ACCESSIBILITY_KEYS, @@ -73,6 +74,8 @@ export function AccessibilitySettings() { const [resetDone, setResetDone] = useState(false); const locale = useLocale(); const speechVoices = useSpeechVoices(); + // As it applies in this layout: left alone, it follows the layout. + const returnToList = useReturnToListAfterAction(); const toastMin = getRestriction('toastDuration')?.min; const allowsValue = (key: string, value: unknown) => @@ -502,7 +505,7 @@ export function AccessibilitySettings() { )} - set('returnToListAfterAction', checked)} /> + set('returnToListAfterAction', checked)} /> diff --git a/components/settings/reading-settings.tsx b/components/settings/reading-settings.tsx index 0b5cf8222..3f9518ce5 100644 --- a/components/settings/reading-settings.tsx +++ b/components/settings/reading-settings.tsx @@ -11,6 +11,7 @@ import { usePolicyStore } from '@/stores/policy-store'; import { useContactStore } from '@/stores/contact-store'; import { useAuthStore } from '@/stores/auth-store'; import { useRemoteContentBlockedByOrg } from '@/hooks/use-third-party-mode'; +import { useReturnToListAfterAction } from '@/hooks/use-return-to-list'; /** * The Reading page: the open message. Opening it, how its body is drawn, @@ -26,12 +27,14 @@ export function ReadingSettings() { const tLock = useTranslations('remote_content_lock'); const t = useTranslations('settings.email_behavior'); const tPage = useTranslations('settings.reading_page'); + const tPane = useTranslations('conversation_pane'); + // As it applies in this layout: left alone, it follows the layout. + const returnToList = useReturnToListAfterAction(); const [showTrustedModal, setShowTrustedModal] = useState(false); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const { markAsReadDelay, - returnToListAfterAction, mailAttachmentAction, attachmentPosition, hideInlineImageAttachments, @@ -78,9 +81,14 @@ export function ReadingSettings() { )} - + updateSetting('returnToListAfterAction', checked)} /> diff --git a/hooks/use-return-to-list.ts b/hooks/use-return-to-list.ts new file mode 100644 index 000000000..321dfeee0 --- /dev/null +++ b/hooks/use-return-to-list.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useSettingsStore } from "@/stores/settings-store"; +import { usePolicyStore } from "@/stores/policy-store"; +import { useDeviceDetection } from "@/hooks/use-media-query"; +import { effectiveReturnToList, listBesideReader, returnToListSetByPolicy } from "@/lib/return-to-list"; + +/** + * The "Return to list after…" switch as it applies on this device and in this + * layout (lib/return-to-list), and whether that comes from the layout because + * nobody has set it yet. + */ +export function useReturnToListAfterAction(): { checked: boolean; followsLayout: boolean } { + const { isMobile, isTablet } = useDeviceDetection(); + const value = useSettingsStore((s) => s.returnToListAfterAction); + const explicitSettings = useSettingsStore((s) => s.explicitSettings); + const mailLayout = useSettingsStore((s) => s.mailLayout); + const interfaceLayout = useSettingsStore((s) => s.interfaceLayout); + const simpleMode = useSettingsStore((s) => s.simpleMode); + // Re-read when the policy arrives or changes. + usePolicyStore((s) => s.policy); + const policySet = returnToListSetByPolicy(); + const besideReader = listBesideReader({ mailLayout, interfaceLayout, simpleMode, isMobile, isTablet }); + return { + checked: effectiveReturnToList({ returnToListAfterAction: value, explicitSettings }, besideReader, policySet), + followsLayout: !policySet && !explicitSettings.includes("returnToListAfterAction"), + }; +} diff --git a/lib/__tests__/list-step.test.ts b/lib/__tests__/list-step.test.ts new file mode 100644 index 000000000..3445df2b7 --- /dev/null +++ b/lib/__tests__/list-step.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import type { Email } from '@/lib/jmap/types'; +import { stepListEmail } from '../list-step'; + +const mail = (id: string) => ({ id, threadId: `t-${id}` }) as Email; +const emails = [mail('a'), mail('b'), mail('c'), mail('d')]; +const ids = (e: Email | null) => e?.id ?? null; + +describe('stepListEmail', () => { + it('goes on from the given message', () => { + expect(ids(stepListEmail(emails, 'c', 1))).toBe('d'); + expect(ids(stepListEmail(emails, 'c', -1))).toBe('b'); + }); + it('stays put at either end', () => { + expect(ids(stepListEmail(emails, 'd', 1))).toBe('d'); + expect(ids(stepListEmail(emails, 'a', -1))).toBe('a'); + }); + it('starts at the top for j and the bottom for k without a place', () => { + expect(ids(stepListEmail(emails, null, 1))).toBe('a'); + expect(ids(stepListEmail(emails, null, -1))).toBe('d'); + expect(ids(stepListEmail(emails, 'gone', 1))).toBe('a'); + }); + it('counts a conversation once and opens it at its row', () => { + // b's row stands for b and a (its older message); then d, then c. + const rows = [{ key: 'b', members: ['b', 'a'] }, { key: 'd', members: ['d'] }, { key: 'c', members: ['c'] }]; + expect(ids(stepListEmail(emails, 'a', 1, rows))).toBe('d'); + expect(ids(stepListEmail(emails, 'd', 1, rows))).toBe('c'); + expect(ids(stepListEmail(emails, 'd', -1, rows))).toBe('b'); + expect(ids(stepListEmail(emails, null, -1, rows))).toBe('c'); + }); + it('is empty for an empty list', () => { + expect(stepListEmail([], 'a', 1)).toBeNull(); + }); +}); diff --git a/lib/__tests__/return-to-list.test.ts b/lib/__tests__/return-to-list.test.ts new file mode 100644 index 000000000..a19b16d2b --- /dev/null +++ b/lib/__tests__/return-to-list.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { effectiveReturnToList, listBesideReader } from '../return-to-list'; + +// "Return to list after…": left alone it follows the layout (next message +// with the list beside the reader, back to the list elsewhere); set by the +// user or the admin, it decides everywhere. + +const untouched = { returnToListAfterAction: true, explicitSettings: [] as string[] }; +const chosen = (value: boolean) => ({ returnToListAfterAction: value, explicitSettings: ['returnToListAfterAction'] }); + +describe('listBesideReader', () => { + const desktop = { isMobile: false, isTablet: false, interfaceLayout: 'default' as const }; + it('is true for the split and horizontal layouts on a desktop', () => { + expect(listBesideReader({ ...desktop, mailLayout: 'split' })).toBe(true); + expect(listBesideReader({ ...desktop, mailLayout: 'horizontal' })).toBe(true); + }); + it('is false where the message takes the list’s place', () => { + expect(listBesideReader({ ...desktop, mailLayout: 'focus' })).toBe(false); + expect(listBesideReader({ ...desktop, mailLayout: 'split', interfaceLayout: 'mountain-view' })).toBe(false); + expect(listBesideReader({ ...desktop, mailLayout: 'split', simpleMode: true })).toBe(false); + expect(listBesideReader({ ...desktop, mailLayout: 'split', isMobile: true })).toBe(false); + expect(listBesideReader({ ...desktop, mailLayout: 'split', isTablet: true })).toBe(false); + }); +}); + +describe('effectiveReturnToList', () => { + it('opens the next message beside the reader while nobody set it', () => { + expect(effectiveReturnToList(untouched, true, false)).toBe(false); + }); + it('goes back to the list elsewhere while nobody set it', () => { + expect(effectiveReturnToList(untouched, false, false)).toBe(true); + }); + it('follows the user’s choice on every layout', () => { + expect(effectiveReturnToList(chosen(true), true, false)).toBe(true); + expect(effectiveReturnToList(chosen(false), false, false)).toBe(false); + }); + it('follows an admin preset or lock', () => { + expect(effectiveReturnToList(untouched, true, true)).toBe(true); + expect(effectiveReturnToList({ ...untouched, returnToListAfterAction: false }, false, true)).toBe(false); + }); +}); diff --git a/lib/email-action-toast.ts b/lib/email-action-toast.ts index 663a3b417..02d987771 100644 --- a/lib/email-action-toast.ts +++ b/lib/email-action-toast.ts @@ -166,13 +166,14 @@ export async function setSelectionTagWithUndo( * * `receipts` (filled while the move runs) name the messages it took to * another account: Undo moves those back, the same way, into the folders - * their restore points name (undoMove). + * their restore points name (undoMove). `onUndone` runs once they are back. */ export function undoMoveAction( client: IJMAPClient | null | undefined, batches: EmailRestoreBatch[] | null | undefined, labels: UndoLabels, receipts?: CrossAccountMoveReceipt[], + onUndone?: () => void, ): UndoToastAction | undefined { if (!client || !batches || !batches.some(b => b.points.length > 0)) return undefined; return { @@ -183,6 +184,7 @@ export function undoMoveAction( try { await useEmailStore.getState().undoMove(client, batches, receipts ?? []); toast.success(labels.done); + onUndone?.(); } catch (error) { console.error("Failed to undo:", error); toast.error(labels.failed); diff --git a/lib/list-step.ts b/lib/list-step.ts new file mode 100644 index 000000000..738b5194c --- /dev/null +++ b/lib/list-step.ts @@ -0,0 +1,39 @@ +import type { Email } from '@/lib/jmap/types'; +import { emailKeyFor } from '@/lib/thread-utils'; + +/** One row of the message list: its own key and the keys it stands for. */ +export interface ListStepRow { + key: string; + members: string[]; +} + +/** + * The message j / k open: the one after (`direction` 1) or before (-1) the + * message `fromKey` names, staying put at either end. `fromKey` is the open + * message, or the last one opened when the reader was closed, so j after + * Escape goes on from there. Without a place in the list, j starts at the + * top and k at the bottom. + * + * With `rows` (the list's rows in screen order) a conversation counts once, + * and opens at its newest message, as a click on its row does: the reading + * pane shows the rest of it. + */ +export function stepListEmail( + emails: readonly Email[], + fromKey: string | null | undefined, + direction: 1 | -1, + rows?: readonly ListStepRow[] | null, +): Email | null { + const byKey = new Map(emails.map((e) => [emailKeyFor(e), e] as const)); + const order: readonly ListStepRow[] = rows && rows.length > 0 + ? rows.filter((row) => byKey.has(row.key)) + : emails.map((e) => ({ key: emailKeyFor(e), members: [] })); + if (order.length === 0) return null; + const at = fromKey + ? order.findIndex((row) => row.key === fromKey || row.members.includes(fromKey)) + : -1; + const target = at < 0 + ? (direction === 1 ? 0 : order.length - 1) + : Math.min(order.length - 1, Math.max(0, at + direction)); + return byKey.get(order[target].key) ?? null; +} diff --git a/lib/return-to-list.ts b/lib/return-to-list.ts new file mode 100644 index 000000000..f33055bcb --- /dev/null +++ b/lib/return-to-list.ts @@ -0,0 +1,50 @@ +import { effectiveMailLayout, useSettingsStore, type InterfaceLayout, type MailLayout } from '@/stores/settings-store'; +import { usePolicyStore } from '@/stores/policy-store'; + +const KEY = 'returnToListAfterAction'; + +/** + * Whether the message list and the reading pane are on screen side by side: + * the split and horizontal layouts on a desktop. Phones, tablets, the focused + * layout, Simple mode and Mountain View put the message in the list's place. + */ +export function listBesideReader(opts: { + mailLayout: MailLayout; + interfaceLayout: InterfaceLayout; + simpleMode?: boolean; + isMobile: boolean; + isTablet: boolean; +}): boolean { + if (opts.isMobile || opts.isTablet) return false; + return effectiveMailLayout(opts.mailLayout, opts.interfaceLayout, opts.simpleMode) !== 'focus'; +} + +/** + * "Return to list after delete, archive, move or mark unread", as it applies + * here. Set by the user (on any device: the choice syncs) or by the admin + * policy, the setting decides. Left alone, it follows the layout: with the + * list beside the reading pane the next message opens, as in Outlook, so a + * folder is worked through with e, e, e; where the message takes the list's + * place it goes back to the list. Each device judges its own layout. + */ +export function effectiveReturnToList( + settings: { returnToListAfterAction: boolean; explicitSettings: readonly string[] }, + besideReader: boolean, + policySet: boolean, +): boolean { + if (policySet || settings.explicitSettings.includes(KEY)) return settings.returnToListAfterAction; + return besideReader ? false : settings.returnToListAfterAction; +} + +/** Whether the admin policy presets or locks the setting. */ +export function returnToListSetByPolicy(): boolean { + const policy = usePolicyStore.getState(); + const defaults = policy.policy?.defaults; + return (!!defaults && Object.prototype.hasOwnProperty.call(defaults, KEY)) || policy.isSettingLocked(KEY); +} + +/** The setting as it applies now, read at the moment of the action. */ +export function returnToListAfterAction(besideReader: boolean): boolean { + return effectiveReturnToList(useSettingsStore.getState(), besideReader, returnToListSetByPolicy()); +} + diff --git a/locales/ar/common.json b/locales/ar/common.json index 17e6a0535..f7c9d0564 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3790,6 +3790,10 @@ "expand_named": "توسيع المحادثة: {subject}", "collapse_named": "طي المحادثة: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, zero {عرض # رسالة أقدم} one {عرض رسالة أقدم واحدة} two {عرض رسالتين أقدم} few {عرض # رسائل أقدم} many {عرض # رسالة أقدم} other {عرض # رسالة أقدم}}", + "return_to_list_follows_layout": "ما لم تغيّره، يتبع هذا التخطيط: متوقف عندما تكون القائمة والرسالة جنبًا إلى جنب، ومفعّل في غير ذلك." + }, "identities": { "modal_title": "إدارة هويات الإرسال", "create_new": "إنشاء هوية جديدة", diff --git a/locales/ca/common.json b/locales/ca/common.json index c7b1f4f7e..f70c3e5cf 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Desplega la conversa: {subject}", "collapse_named": "Replega la conversa: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Mostra # missatge anterior} other {Mostra # missatges anteriors}}", + "return_to_list_follows_layout": "Mentre no ho canviïs, segueix el disseny: desactivat quan la llista i el missatge són l'un al costat de l'altre, activat en els altres casos." + }, "identities": { "modal_title": "Gestiona les identitats d'enviament", "create_new": "Crea una identitat nova", diff --git a/locales/cs/common.json b/locales/cs/common.json index f1425e905..a642b2368 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Rozbalit konverzaci: {subject}", "collapse_named": "Sbalit konverzaci: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Zobrazit # starší zprávu} few {Zobrazit # starší zprávy} many {Zobrazit # starší zprávy} other {Zobrazit # starších zpráv}}", + "return_to_list_follows_layout": "Dokud to nezměníte, řídí se to rozvržením: vypnuto, když jsou seznam a zpráva vedle sebe, jinak zapnuto." + }, "identities": { "modal_title": "Správa identit odesílatele", "create_new": "Vytvořit novou identitu", diff --git a/locales/da/common.json b/locales/da/common.json index 352ce130d..136421edb 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Udvid samtale: {subject}", "collapse_named": "Skjul samtale: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Vis # ældre besked} other {Vis # ældre beskeder}}", + "return_to_list_follows_layout": "Indtil du ændrer det, følger det layoutet: fra, når listen og beskeden står side om side, ellers til." + }, "identities": { "modal_title": "Administrér afsenderidentiteter", "create_new": "Opret ny identitet", diff --git a/locales/de/common.json b/locales/de/common.json index 6bf94c958..fe7182ed4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Konversation aufklappen: {subject}", "collapse_named": "Konversation zuklappen: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {# ältere Nachricht anzeigen} other {# ältere Nachrichten anzeigen}}", + "return_to_list_follows_layout": "Solange Sie es nicht ändern, richtet sich das nach dem Layout: aus, wenn Liste und Nachricht nebeneinander stehen, sonst an." + }, "identities": { "modal_title": "Sendeidentitäten verwalten", "create_new": "Neue Identität erstellen", diff --git a/locales/en/common.json b/locales/en/common.json index 4d35cc577..b9eaf1002 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Expand conversation: {subject}", "collapse_named": "Collapse conversation: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Show # older message} other {Show # older messages}}", + "return_to_list_follows_layout": "Until you change it, this follows the layout: off when the list and the message are side by side, on otherwise." + }, "identities": { "modal_title": "Manage sending identities", "create_new": "New identity", diff --git a/locales/es/common.json b/locales/es/common.json index 4d8046bab..0d961ed52 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Expandir conversación: {subject}", "collapse_named": "Contraer conversación: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Mostrar # mensaje anterior} other {Mostrar # mensajes anteriores}}", + "return_to_list_follows_layout": "Mientras no lo cambies, sigue el diseño: desactivado cuando la lista y el mensaje están uno al lado del otro, activado en los demás casos." + }, "identities": { "modal_title": "Administrar Identidades de Envío", "create_new": "Crear Nueva Identidad", diff --git a/locales/et/common.json b/locales/et/common.json index 82a04231e..e249abb6c 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Ava vestlus: {subject}", "collapse_named": "Sulge vestlus: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Näita # vanemat sõnumit} other {Näita # vanemat sõnumit}}", + "return_to_list_follows_layout": "Kuni te seda ei muuda, järgib see paigutust: väljas, kui loend ja sõnum on kõrvuti, muidu sees." + }, "identities": { "modal_title": "Halda saatja identiteete", "create_new": "Loo uus identiteet", diff --git a/locales/fa/common.json b/locales/fa/common.json index f8e80ab74..ae98f8257 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3790,6 +3790,10 @@ "expand_named": "باز کردن مکالمه: {subject}", "collapse_named": "جمع کردن مکالمه: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {نمایش # پیام قدیمی‌تر} other {نمایش # پیام قدیمی‌تر}}", + "return_to_list_follows_layout": "تا وقتی آن را تغییر ندهید، از چیدمان پیروی می‌کند: وقتی فهرست و پیام کنار هم هستند خاموش، در غیر این صورت روشن." + }, "identities": { "modal_title": "مدیریت هویت‌های ارسال", "create_new": "ایجاد هویت جدید", diff --git a/locales/fr/common.json b/locales/fr/common.json index 7295c3127..71ac3ddb7 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Développer la conversation: {subject}", "collapse_named": "Réduire la conversation: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Afficher # message plus ancien} other {Afficher # messages plus anciens}}", + "return_to_list_follows_layout": "Tant que vous ne le modifiez pas, ce réglage suit la disposition : désactivé quand la liste et le message sont côte à côte, activé sinon." + }, "identities": { "modal_title": "Gérer les identités d'envoi", "create_new": "Créer une nouvelle identité", diff --git a/locales/he/common.json b/locales/he/common.json index c3fac8aa4..5f5a17909 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3687,6 +3687,10 @@ "expand_named": "הרחב את השיחה: {subject}", "collapse_named": "כווץ את השיחה: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {הצגת הודעה ישנה אחת} two {הצגת שתי הודעות ישנות} other {הצגת # הודעות ישנות}}", + "return_to_list_follows_layout": "כל עוד לא תשנו זאת, זה נקבע לפי הפריסה: כבוי כשהרשימה וההודעה זו לצד זו, ופועל בכל מצב אחר." + }, "identities": { "modal_title": "ניהול זהויות שליחה", "create_new": "צור זהות חדשה", diff --git a/locales/hu/common.json b/locales/hu/common.json index acb38477f..90ca815c8 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Beszélgetés kibontása: {subject}", "collapse_named": "Beszélgetés összecsukása: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {# régebbi üzenet megjelenítése} other {# régebbi üzenet megjelenítése}}", + "return_to_list_follows_layout": "Amíg nem módosítja, az elrendezést követi: ki van kapcsolva, ha a lista és az üzenet egymás mellett van, egyébként be." + }, "identities": { "modal_title": "Küldő azonosságok kezelése", "create_new": "Új azonosság létrehozása", diff --git a/locales/it/common.json b/locales/it/common.json index 085d85039..88c81d14e 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Espandi conversazione: {subject}", "collapse_named": "Comprimi conversazione: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Mostra # messaggio precedente} other {Mostra # messaggi precedenti}}", + "return_to_list_follows_layout": "Finché non lo modifichi, segue il layout: disattivato quando l'elenco e il messaggio sono affiancati, attivato negli altri casi." + }, "identities": { "modal_title": "Gestisci identità di invio", "create_new": "Crea nuova identità", diff --git a/locales/ja/common.json b/locales/ja/common.json index e1687db7f..f5e526756 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3790,6 +3790,10 @@ "expand_named": "会話を展開: {subject}", "collapse_named": "会話を折りたたむ: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, other {以前のメッセージを # 件表示}}", + "return_to_list_follows_layout": "変更するまでは、レイアウトに合わせて切り替わります。一覧とメッセージが横に並ぶ場合はオフ、それ以外はオンです。" + }, "identities": { "modal_title": "送信者情報を管理", "create_new": "新しい送信者情報を作成", diff --git a/locales/ko/common.json b/locales/ko/common.json index 5e7002cc8..7f331e371 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3790,6 +3790,10 @@ "expand_named": "대화 펼치기: {subject}", "collapse_named": "대화 접기: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, other {이전 메시지 #개 보기}}", + "return_to_list_follows_layout": "변경하기 전까지는 레이아웃을 따릅니다. 목록과 메시지가 나란히 있으면 꺼지고, 그 밖에는 켜집니다." + }, "identities": { "modal_title": "보내는 사람 관리", "create_new": "새 보내는 사람 추가", diff --git a/locales/lv/common.json b/locales/lv/common.json index fc32baa3c..5a3041639 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Izvērst sarunu: {subject}", "collapse_named": "Sairt sarunu: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, zero {Rādīt # vecākas ziņas} one {Rādīt # vecāku ziņu} other {Rādīt # vecākas ziņas}}", + "return_to_list_follows_layout": "Kamēr to nemaināt, tas seko izkārtojumam: izslēgts, ja saraksts un ziņa ir blakus, citādi ieslēgts." + }, "identities": { "modal_title": "Pārvaldīt sūtītāja identitātes", "create_new": "Izveidot jaunu identitāti", diff --git a/locales/mn/common.json b/locales/mn/common.json index e045789d9..e091a4658 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Харилцан яриаг өргөжүүлэх: {subject}", "collapse_named": "Харилцан яриаг хураах: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Өмнөх # захиа харуулах} other {Өмнөх # захиа харуулах}}", + "return_to_list_follows_layout": "Та өөрчлөхөөс нааш энэ нь байрлалаас хамаарна: жагсаалт ба захиа зэрэгцэн байвал унтраалттай, бусад үед асаалттай." + }, "identities": { "modal_title": "Илгээж буй таниулбаруудыг удирдах", "create_new": "Шинэ таниулбар үүсгэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index 4991e10b6..d10b88cc3 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Utvid samtalen: {subject}", "collapse_named": "Fold sammen samtalen: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Vis # eldre melding} other {Vis # eldre meldinger}}", + "return_to_list_follows_layout": "Inntil du endrer det, følger det oppsettet: av når listen og meldingen står side om side, ellers på." + }, "identities": { "modal_title": "Administrer avsenderidentiteter", "create_new": "Opprett ny identitet", diff --git a/locales/nl/common.json b/locales/nl/common.json index 658ced16e..f2304f1a9 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Gesprek uitklappen: {subject}", "collapse_named": "Gesprek inklappen: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {# ouder bericht tonen} other {# oudere berichten tonen}}", + "return_to_list_follows_layout": "Zolang je het niet wijzigt, volgt dit de indeling: uit als de lijst en het bericht naast elkaar staan, anders aan." + }, "identities": { "modal_title": "Verzendidentiteiten beheren", "create_new": "Nieuwe identiteit aanmaken", diff --git a/locales/pl/common.json b/locales/pl/common.json index 82d94a25f..2a29da700 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Rozwiń konwersację: {subject}", "collapse_named": "Zwiń konwersację: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Pokaż # starszą wiadomość} few {Pokaż # starsze wiadomości} many {Pokaż # starszych wiadomości} other {Pokaż # starszej wiadomości}}", + "return_to_list_follows_layout": "Dopóki tego nie zmienisz, ustawienie zależy od układu: wyłączone, gdy lista i wiadomość są obok siebie, w innych przypadkach włączone." + }, "identities": { "modal_title": "Zarządzaj tożsamościami nadawcy", "create_new": "Utwórz nową tożsamość", diff --git a/locales/pt/common.json b/locales/pt/common.json index b5fc8675c..05686236e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Expandir conversa: {subject}", "collapse_named": "Recolher conversa: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Mostrar # mensagem anterior} other {Mostrar # mensagens anteriores}}", + "return_to_list_follows_layout": "Enquanto não alterar, segue o layout: desligado quando a lista e a mensagem estão lado a lado, ligado nos outros casos." + }, "identities": { "modal_title": "Gerenciar Identidades de Envio", "create_new": "Criar Nova Identidade", diff --git a/locales/ro/common.json b/locales/ro/common.json index ffee770f9..f73bcc5ee 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Extinde conversația: {subject}", "collapse_named": "Ascunde conversația: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Afișează # mesaj mai vechi} few {Afișează # mesaje mai vechi} other {Afișează # de mesaje mai vechi}}", + "return_to_list_follows_layout": "Cât timp nu îl modificați, urmează aspectul: dezactivat când lista și mesajul sunt unul lângă altul, activat în rest." + }, "identities": { "modal_title": "Gestionarea identităților de expediere", "create_new": "Creați o identitate nouă", diff --git a/locales/ru/common.json b/locales/ru/common.json index 7de1300e5..415524f9c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Развернуть разговор: {subject}", "collapse_named": "Свернуть разговор: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Показать # более раннее письмо} few {Показать # более ранних письма} many {Показать # более ранних писем} other {Показать # более раннего письма}}", + "return_to_list_follows_layout": "Пока вы это не измените, настройка зависит от макета: выключена, когда список и письмо стоят рядом, в остальных случаях включена." + }, "identities": { "modal_title": "Управление идентификациями отправителя", "create_new": "Создать новую идентификацию", diff --git a/locales/sk/common.json b/locales/sk/common.json index 430a9f3da..f7ffabee1 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Rozbaliť konverzáciu: {subject}", "collapse_named": "Zbaliť konverzáciu: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Zobraziť # staršiu správu} few {Zobraziť # staršie správy} many {Zobraziť # staršej správy} other {Zobraziť # starších správ}}", + "return_to_list_follows_layout": "Kým to nezmeníte, riadi sa to rozložením: vypnuté, keď sú zoznam a správa vedľa seba, inak zapnuté." + }, "identities": { "modal_title": "Správa identít odosielateľa", "create_new": "Vytvoriť novú identitu", diff --git a/locales/th/common.json b/locales/th/common.json index c33fb8a6d..614aff1eb 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -621,6 +621,10 @@ "expand_named": "ขยายการสนทนา: {subject}", "collapse_named": "ย่อการสนทนา: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, other {แสดงข้อความก่อนหน้า # ฉบับ}}", + "return_to_list_follows_layout": "จนกว่าคุณจะเปลี่ยน ค่านี้จะเป็นไปตามเลย์เอาต์: ปิดเมื่อรายการและข้อความอยู่เคียงข้างกัน และเปิดในกรณีอื่น" + }, "notifications": { "email_sent": "ส่งอีเมลแล้ว", "email_deleted": "ลบอีเมลแล้ว", diff --git a/locales/tr/common.json b/locales/tr/common.json index 896667e5a..034e7fb64 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Konuşmayı genişlet: {subject}", "collapse_named": "Konuşmayı daralt: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {# eski iletiyi göster} other {# eski iletiyi göster}}", + "return_to_list_follows_layout": "Siz değiştirene kadar bu ayar düzene göre belirlenir: liste ve ileti yan yanaysa kapalı, değilse açık." + }, "identities": { "modal_title": "Gönderme Kimliklerini Yönet", "create_new": "Yeni Kimlik Oluştur", diff --git a/locales/uk/common.json b/locales/uk/common.json index 6eb158f70..59d9b036c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3790,6 +3790,10 @@ "expand_named": "Розгорнути бесіду: {subject}", "collapse_named": "Згорнути бесіду: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, one {Показати # давніший лист} few {Показати # давніші листи} many {Показати # давніших листів} other {Показати # давнішого листа}}", + "return_to_list_follows_layout": "Поки ви це не зміните, налаштування залежить від макета: вимкнено, коли список і лист розташовані поруч, в інших випадках увімкнено." + }, "identities": { "modal_title": "Керування ідентифікаторами надсилання", "create_new": "Створіть нову ідентичність", diff --git a/locales/vi/common.json b/locales/vi/common.json index 76bee5d4c..f1ebc9ad2 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -3802,6 +3802,10 @@ "expand_named": "Mở rộng cuộc trò chuyện: {subject}", "collapse_named": "Thu gọn cuộc trò chuyện: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, other {Hiện # thư cũ hơn}}", + "return_to_list_follows_layout": "Cho đến khi bạn thay đổi, cài đặt này theo bố cục: tắt khi danh sách và thư nằm cạnh nhau, bật trong các trường hợp khác." + }, "identities": { "modal_title": "Quản lý danh tính gửi thư", "create_new": "Danh tính mới", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0c40b5aaf..c2487087d 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3790,6 +3790,10 @@ "expand_named": "展開對話串: {subject}", "collapse_named": "收合對話串: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, other {顯示 # 封較早的郵件}}", + "return_to_list_follows_layout": "在您變更之前,此項會依版面配置而定:清單與郵件並排顯示時關閉,其他情況下開啟。" + }, "identities": { "modal_title": "管理寄件身分", "create_new": "建立新的寄件身分", diff --git a/locales/zh/common.json b/locales/zh/common.json index 62424ddf5..65b4c5748 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3790,6 +3790,10 @@ "expand_named": "展开会话: {subject}", "collapse_named": "折叠会话: {subject}" }, + "conversation_pane": { + "show_older": "{count, plural, other {显示 # 封较早的邮件}}", + "return_to_list_follows_layout": "在你更改之前,此项随布局而定:列表和邮件并排显示时关闭,其他情况下开启。" + }, "identities": { "modal_title": "管理发送身份", "create_new": "创建新身份",