Merge branch 'feat/do-ux-mail-conv' into feat/do-ux-mail-fixes

# Conflicts:
#	components/mail/mail-app.tsx
This commit is contained in:
Linus Rath
2026-11-04 07:24:19 +01:00
48 changed files with 951 additions and 47 deletions
@@ -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> = {}): 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<Parameters<typeof EmailViewer>[0]> = {}) {
return render(
<EmailViewer
email={open}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
mvConversation={conversation}
{...props}
/>,
);
}
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();
});
});
@@ -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> = {}): 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(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={onToggleExpand}
onCollapseAllThreads={onCollapseAllThreads}
onEmailSelect={onEmailSelect}
conversationInReader={conversationInReader}
/>,
);
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<string>(), 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();
});
});
+10 -3
View File
@@ -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 && <MemoizedThreadListItem
thread={thread}
isExpanded={expandedThreadIds.has(thread.threadKey)}
isExpanded={!conversationInReader && expandedThreadIds.has(thread.threadKey)}
conversationInReader={conversationInReader}
selectedEmailKey={selectedEmailKey}
isLoading={isLoadingThread === thread.threadKey}
expandedEmails={threadEmailsCache.get(thread.threadKey)}
+17 -6
View File
@@ -281,6 +281,11 @@ interface EmailViewerProps {
* fold and unfold in place, as Gmail does; unread ones start unfolded.
*/
mvConversation?: Email[];
/**
* The default layout's reading pane shows `mvConversation` around the open
* message too, in its own look (not on phones, which have their own view).
*/
conversationPane?: boolean;
/**
* Mountain View: the open message's conversation is still on its way. The
* viewer waits for it rather than showing the message alone first. (#1223)
@@ -1024,6 +1029,7 @@ export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun,
expandedIds?: ReadonlySet<string>;
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,
<button
type="button"
onClick={() => setShowAll(true)}
aria-label={tPane('show_older', { count: middle.length })}
title={tPane('show_older', { count: middle.length })}
data-mv-conversation-more=""
className="absolute start-6 top-1/2 -translate-y-1/2 grid place-items-center w-10 h-10 rounded-full border border-border bg-background text-sm text-muted-foreground hover:bg-muted transition-colors"
>
@@ -1357,6 +1365,7 @@ export function EmailViewer({
mvPosition,
mvFolderName,
mvConversation,
conversationPane,
mvConversationPending,
onLoadConversationEmail,
onMarkConversationEmailRead,
@@ -1506,10 +1515,12 @@ export function EmailViewer({
// and has no bar fixed at the bottom; previous / next move to the top bar.
const mvMobileViewer = interfaceLayout === 'mountain-view' && isMobile;
const mvReplyRow = mvViewer || mvMobileViewer;
// Where the open message is read inside its conversation.
const conversationViewer = mvViewer || mvMobileViewer || (!!conversationPane && !isMobile);
// The phone's bottom action bar: toasts sit above it.
const bottomBarRef = useBottomControls(isMobile && !mvMobileViewer);
// The open conversation around the open message, split at it.
const mvOpenIndex = (mvViewer || mvMobileViewer) && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1;
const mvOpenIndex = conversationViewer && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1;
const mvBefore = mvOpenIndex > 0 ? mvConversation!.slice(0, mvOpenIndex) : [];
const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : [];
const mvConversationIds = mvOpenIndex >= 0 ? mvConversation!.map((e) => e.id).join(',') : '';
@@ -1556,7 +1567,7 @@ export function EmailViewer({
// The conversation is not here yet (the one on hand is another thread's, or
// none). Hold the message back instead of drawing it alone and then pushing
// it down under the earlier messages.
const mvAwaitingConversation = !!mvConversationPending && (mvViewer || mvMobileViewer) && mvOpenIndex < 0;
const mvAwaitingConversation = !!mvConversationPending && conversationViewer && mvOpenIndex < 0;
const renderMvExpanded = (target: Email) => (
<MvExpandedConversationCard
email={target}
@@ -5104,14 +5115,14 @@ ${printWatermark ? printWatermark.html : ''}
{/* Mountain View: the conversation's earlier messages, folded. */}
{mvBefore.length > 0 && (
<div data-mv-conversation-before="">
<div className="bg-background" data-mv-conversation-before="">
<MvConversationRun emails={mvBefore} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} compact={mvMobileViewer} collapseLongRun />
</div>
)}
{/* Mountain View: the open message folded like the rest of the conversation. */}
{mvOpenFolded && email ? (
<div data-mv-open-folded="">
<div className="bg-background" data-mv-open-folded="">
<MvConversationCard email={email} onOpen={() => setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
</div>
) : mvAwaitingConversation ? (
@@ -5126,7 +5137,7 @@ ${printWatermark ? printWatermark.html : ''}
) : (<>
{/* === SENDER INFO (Desktop) === */}
<div
className={cn("hidden lg:block bg-background px-6", !mvViewer && "border-b border-border", mvViewer && mvBefore.length > 0 && "border-t border-border", mvOpenIndex >= 0 && "cursor-pointer")}
className={cn("hidden lg:block bg-background px-6", !mvViewer && "border-b border-border", (mvViewer || conversationPane) && mvBefore.length > 0 && "border-t border-border", mvOpenIndex >= 0 && "cursor-pointer")}
style={mvViewer ? { paddingBlock: '0.75rem 0.5rem' } : { paddingBlock: 'var(--density-header-py)' }}
data-mv-open-header={mvOpenIndex >= 0 ? "" : undefined}
onClick={mvOpenIndex >= 0 ? (e) => {
@@ -6124,7 +6135,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Mountain View: the conversation's later messages, folded. */}
{mvAfter.length > 0 && (
<div className="mt-4 border-b border-border" data-mv-conversation-after="">
<div className="mt-4 border-b border-border bg-background" data-mv-conversation-after="">
<MvConversationRun emails={mvAfter} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} compact={mvMobileViewer} />
</div>
)}
+12 -3
View File
@@ -377,6 +377,8 @@ interface ThreadListItemProps {
onPrefetch?: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => void;
/** The reading pane shows the conversation: the row opens it there and never unfolds. */
conversationInReader?: boolean;
onToggleStar?: (email: Email) => void;
onMarkAsRead?: (email: Email, read: boolean) => void;
onDelete?: (email: Email) => void;
@@ -1043,6 +1045,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onPrefetch,
onContextMenu,
onOpenConversation,
conversationInReader = false,
onToggleStar,
onMarkAsRead,
onDelete,
@@ -1291,7 +1294,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
return;
}
if (mvRow || mvMobileRow) {
// So does the default layout, whose reading pane shows the conversation.
if (mvRow || mvMobileRow || conversationInReader) {
// Desktop keeps the ticked rows while the conversation is read (#1231).
onEmailSelect(latestEmail);
return;
@@ -1345,7 +1349,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
rowKey: emailKeyFor(latestEmail),
label: a11yLabel,
selected: isSelected && !isExpanded,
expanded: isExpanded,
expanded: conversationInReader ? undefined : isExpanded,
checked: isChecked,
selectionMode: selectedEmailKeys.size > 0,
onToggleCheck: toggleThreadSelection,
@@ -1469,7 +1473,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
selectLabel={selectRowLabel}
/>
{hasUnread && <UnreadDot besideAvatar />}
{!isMobile && !columns && (
{!isMobile && !columns && !conversationInReader && (
<button
data-expand-toggle
onClick={(e) => {
@@ -1523,6 +1527,10 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
// count follows the names, so a folded conversation is
// one line like any message. (#262)
<span data-list-from="" className={cn(LIST_COLUMN_CLASS.from, 'flex items-center gap-2 text-sm')}>
{conversationInReader ? (
// The reading pane shows the conversation: the chevron's room, as on a single message's row.
<span aria-hidden="true" className="w-3.5 shrink-0" />
) : (
<button
data-expand-toggle
onClick={(e) => {
@@ -1541,6 +1549,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<ChevronRight className="w-3.5 h-3.5" />
)}
</button>
)}
{isUnifiedView && latestEmail.accountId && threadAccountColor && showAccountDot && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
@@ -135,7 +135,9 @@ beforeEach(() => {
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: {},
@@ -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<React.ComponentType<Record<string, unknown>>>) => {
const Component = lazy(async () => ({ default: await load() }));
return function Dynamic(props: Record<string, unknown>) {
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
type ViewerProps = { mvConversation?: { id: string }[]; conversationPane?: boolean };
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ mvConversation, conversationPane }: ViewerProps) => (
<div data-testid="reader" data-pane={conversationPane ? 'conversation' : 'message'}>
{mvConversation ? mvConversation.map((e) => e.id).join(',') : 'no conversation'}
</div>
),
}));
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: () => <input aria-label="stub search field" data-search-input /> }));
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: () => <div>message list</div> }));
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<string, boolean> = { 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<string, ReturnType<typeof vi.fn> | (() => unknown)>;
let fetchEmailContent: ReturnType<typeof vi.fn>;
// 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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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'));
});
});
@@ -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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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(<MailApp />);
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')!;
@@ -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],
+80 -26
View File
@@ -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<string | null>(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<unknown>) | 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<typeof client>) => {
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}
@@ -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() {
</SettingItem>
)}
<SettingItem label={tEmail('return_to_list_after_action.label')} description={tEmail('return_to_list_after_action.description')}>
<ToggleSwitch checked={settings.returnToListAfterAction} onChange={(checked) => set('returnToListAfterAction', checked)} />
<ToggleSwitch checked={returnToList.checked} onChange={(checked) => set('returnToListAfterAction', checked)} />
</SettingItem>
</SettingsSection>
+11 -3
View File
@@ -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() {
</SettingItem>
)}
<SettingItem label={t('return_to_list_after_action.label')} description={t('return_to_list_after_action.description')}>
<SettingItem
label={t('return_to_list_after_action.label')}
description={returnToList.followsLayout
? `${t('return_to_list_after_action.description')} ${tPane('return_to_list_follows_layout')}`
: t('return_to_list_after_action.description')}
>
<ToggleSwitch
checked={returnToListAfterAction}
checked={returnToList.checked}
onChange={(checked) => updateSetting('returnToListAfterAction', checked)}
/>
</SettingItem>
+28
View File
@@ -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"),
};
}
+34
View File
@@ -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();
});
});
+41
View File
@@ -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);
});
});
+3 -1
View File
@@ -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);
+39
View File
@@ -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;
}
+50
View File
@@ -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());
}
+4
View File
@@ -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": "إنشاء هوية جديدة",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "ایجاد هویت جدید",
+4
View File
@@ -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é",
+4
View File
@@ -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": "צור זהות חדשה",
+4
View File
@@ -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",
+4
View File
@@ -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à",
+4
View File
@@ -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": "新しい送信者情報を作成",
+4
View File
@@ -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": "새 보내는 사람 추가",
+4
View File
@@ -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",
+4
View File
@@ -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": "Шинэ таниулбар үүсгэх",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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ść",
+4
View File
@@ -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",
+4
View File
@@ -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ă",
+4
View File
@@ -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": "Создать новую идентификацию",
+4
View File
@@ -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",
+4
View File
@@ -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": "ลบอีเมลแล้ว",
+4
View File
@@ -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",
+4
View File
@@ -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": "Створіть нову ідентичність",
+4
View File
@@ -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",
+4
View File
@@ -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": "建立新的寄件身分",
+4
View File
@@ -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": "创建新身份",