fix: keep the message in view above a docked reply, which starts compact and grows to half the pane, and takes short panes with a Show the message bar

This commit is contained in:
Linus Rath
2026-11-04 12:50:22 +01:00
parent 90e959a092
commit 0728f854d2
36 changed files with 558 additions and 12 deletions
@@ -0,0 +1,237 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// mail-p2-2: a reply docked under its message starts compact: no Subject
// line, no From line when there's only one address to send from, and the
// formatting bar folded behind its button. Full screen shows them again.
// ─── Heavy component mocks (mirrors composer-mobile-formatting.test.tsx) ──────
const toastError = vi.hoisted(() => vi.fn());
// Shows whether the composer asked for the toolbar to be folded away.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ toolbarPlacement }: { toolbarPlacement?: string }) =>
React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-toolbar': toolbarPlacement ?? 'top' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
const updateSetting = vi.fn();
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: (...args: unknown[]) => updateSetting(...args),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: toastError, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
const DATA = {
to: 'bob@example.com',
cc: '',
bcc: '',
subject: 'Re: Lunch',
body: '<p>Hello there</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'reply' as const,
draftId: null,
};
const hidden = (el: Element | null) => !!el && el.className.split(/\s+/).includes('hidden');
describe('a reply docked under its message', () => {
beforeEach(() => {
toastError.mockClear();
updateSetting.mockClear();
});
it('starts compact, with the formatting bar behind its button', () => {
const { container } = render(<EmailComposer initialData={DATA} docked />);
expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(true);
expect(hidden(container.querySelector('[data-composer-from]'))).toBe(true);
const editor = screen.getByTestId('rich-text-editor');
expect(editor).toHaveAttribute('data-toolbar', 'hidden');
fireEvent.click(container.querySelector('[data-dock-formatting-toggle]')!);
expect(editor).toHaveAttribute('data-toolbar', 'top');
});
it('shows everything in full screen', () => {
const { container } = render(<EmailComposer initialData={DATA} docked />);
fireEvent.click(screen.getByRole('button', { name: 'maximize' }));
expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(false);
expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-toolbar', 'top');
});
it('keeps every line when not docked', () => {
const { container } = render(<EmailComposer initialData={DATA} />);
expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(false);
expect(hidden(container.querySelector('[data-composer-from]'))).toBe(false);
});
});
+17 -7
View File
@@ -423,6 +423,12 @@ interface EmailComposerProps {
popOut?: () => ComposerPopOut | null;
/** The move goes back to the main window: the button says that, and shows at any width. */
popOutToMainLabel?: string;
/**
* Docked under the message it answers (mail-p2-2): the composer starts
* compact, without the Subject line, the From line when there's nothing to
* pick, and the formatting bar until asked for. Full screen shows them all.
*/
docked?: boolean;
/**
* This composer is all a window shows (lib/popout). It closes itself once a
* message has gone. Closing the window with unsaved text hands the draft to
@@ -611,6 +617,7 @@ export function EmailComposer({
onSaveState,
popOut,
popOutToMainLabel,
docked = false,
ownWindow,
onSubjectChange,
className,
@@ -1252,6 +1259,8 @@ export function EmailComposer({
// button or Escape puts it back. Not in a window of its own, which is all composer.
const [maximized, setMaximized] = useState(false);
const canMaximize = !ownWindow && !mvWindow;
const dockCompact = docked && !maximized;
const [showDockFormatting, setShowDockFormatting] = useState(false);
// "close" (X, Escape): save or discard. "discard" (the Discard button): the
// user already chose, so only confirm it.
const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close');
@@ -5171,7 +5180,7 @@ export function EmailComposer({
>
<div ref={composerFieldsRef} className="shrink-0 space-y-0 border-b">
{/* From field */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50", dockCompact && identities.length <= 1 && !fromOverrideEnabled && "hidden")} data-composer-from="">
<span className="text-sm text-muted-foreground min-w-12 md:min-w-16 shrink-0 whitespace-nowrap">{t('from')}:</span>
<div className={cn("flex-1 flex items-center gap-1 min-w-0", fromOverrideEnabled && "flex-wrap")}>
{fromOverrideEnabled ? (
@@ -5473,7 +5482,7 @@ export function EmailComposer({
)}
{/* Subject field */}
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5">
<div className={cn("flex flex-wrap items-center gap-2 px-4 py-2.5", dockCompact && "hidden")} data-composer-subject="">
<label htmlFor={`${fieldIdBase}-subject`} className="text-sm text-muted-foreground min-w-12 md:min-w-16 shrink-0 whitespace-nowrap">{t('subject_label')}</label>
<Input
id={`${fieldIdBase}-subject`}
@@ -5572,7 +5581,7 @@ export function EmailComposer({
ariaDescribedBy={`${fieldIdBase}-body-error`}
defaultTextStyle={composeStyle}
collapseToolbarOnMobile={phoneLayout && !showFormattingOnMobile}
toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'}
toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : dockCompact && !showDockFormatting ? 'hidden' : 'top'}
onEditorReady={handleEditorReady}
mentionCandidates={recipientMentionsEnabled ? mentionCandidates : undefined}
findMentionContacts={recipientMentionsEnabled && mentionAddsRecipientTo !== 'off' ? findMentionContacts : undefined}
@@ -5854,16 +5863,17 @@ export function EmailComposer({
<Button
variant="ghost"
size="icon"
onClick={() => mvWindow ? setMvShowFormatting((v) => !v) : setShowFormattingOnMobile((v) => !v)}
onClick={() => mvWindow ? setMvShowFormatting((v) => !v) : dockCompact ? setShowDockFormatting((v) => !v) : setShowFormattingOnMobile((v) => !v)}
className={cn(
"h-9 w-9 max-md:h-11 max-md:w-11",
!mvWindow && "md:hidden",
(mvWindow ? mvShowFormatting : showFormattingOnMobile) && "bg-accent text-accent-foreground",
!mvWindow && !dockCompact && "md:hidden",
(mvWindow ? mvShowFormatting : dockCompact ? showDockFormatting : showFormattingOnMobile) && "bg-accent text-accent-foreground",
)}
data-mv-formatting-toggle={mvWindow ? "" : undefined}
data-dock-formatting-toggle={dockCompact ? "" : undefined}
title={t('formatting')}
aria-label={t('formatting')}
aria-pressed={mvWindow ? mvShowFormatting : showFormattingOnMobile}
aria-pressed={mvWindow ? mvShowFormatting : dockCompact ? showDockFormatting : showFormattingOnMobile}
>
<Type className="w-4 h-4" />
</Button>
+37
View File
@@ -827,6 +827,19 @@ export function conversationOpenPlan(conversation: Email[], openId: string): { e
return { expanded, foldOpen: !!open.keywords?.$seen, newestId: newest.id };
}
/**
* The reading pane's scroll position that shows the message being answered
* above a docked reply: its end at the pane's bottom edge, or its start at
* the top when the whole message doesn't fit.
*/
export function replyScrollTop(pane: HTMLElement, end: HTMLElement, start: HTMLElement | null): number {
const paneTop = pane.getBoundingClientRect().top;
const endY = end.getBoundingClientRect().top - paneTop + pane.scrollTop;
const startY = start ? start.getBoundingClientRect().top - paneTop + pane.scrollTop : 0;
const toEnd = endY - pane.clientHeight;
return Math.max(0, startY > toEnd ? toEnd : startY);
}
/** A button's tooltip with the key the menus and the shortcut help show (mail-p2-13). */
function useKeyTip() {
const shortcut = useMessageActionShortcut();
@@ -3662,6 +3675,27 @@ export function EmailViewer({
const latestSrcDocRef = useRef<string | null>(null);
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
// Not on spam: a reply confirms the address to the sender.
// A reply docks under the message it answers: keep that message in view
// above it, its end just over the dock, or its start when it is taller
// than what is left of the pane (mail-p2-2).
useEffect(() => {
if (!replyComposerOpen || !email) return;
// The message answered is read while answering: unfold it.
setMvOpenCollapsed(false);
let frame = 0;
const place = () => {
const pane = document.querySelector<HTMLElement>('[data-reader-scroll]');
const end = pane?.querySelector<HTMLElement>('[data-open-message-end]');
if (!pane || !end) return;
const start = pane.querySelector<HTMLElement>('[data-mv-open-header]') ?? pane.querySelector<HTMLElement>('[data-mv-open-folded]');
pane.scrollTop = replyScrollTop(pane, end, start);
};
// Once the dock has taken its room, and again when its height settles.
frame = requestAnimationFrame(() => { frame = requestAnimationFrame(place); });
const timer = setTimeout(place, 250);
return () => { cancelAnimationFrame(frame); clearTimeout(timer); };
}, [replyComposerOpen, email?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- once per reply opened
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
&& (!effectiveEmailContent.isHtml || iframeReady);
// The plain-text body, where reader-above-quote mounts between text and quote.
@@ -6320,6 +6354,9 @@ ${printWatermark ? printWatermark.html : ''}
<PluginSlot name="email-footer" extraProps={pluginFooterProps} />
{/* Where the open message ends: a reply docked under it scrolls to here. */}
<div data-open-message-end="" aria-hidden="true" />
{/* Mountain View: the conversation's later messages, folded. */}
{mvAfter.length > 0 && (
<div className="mt-4 border-b border-border bg-background" data-mv-conversation-after="">
@@ -0,0 +1,58 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
// mail-p2-2 / compose-p2-4: the docked reply took 62 % of the reading pane
// (at least 18 rem), so the message it answered was out of sight, and on a
// short window the reply itself had two lines to type in. It now grows with
// its text up to half of a tall pane; on a short pane it takes the pane under
// the reader's toolbar, with "Show the message" to read and come back.
const { ReplyDock, dockLayout } = await import('../reply-dock');
function renderInPane(height: number, readerTop = 110) {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.hasAttribute('data-pane')) return { top: 0, height, width: 800, left: 0, right: 800, bottom: height, x: 0, y: 0, toJSON() {} } as DOMRect;
if (this.hasAttribute('data-reader-scroll')) return { top: readerTop, height: 0, width: 800, left: 0, right: 800, bottom: readerTop, x: 0, y: readerTop, toJSON() {} } as DOMRect;
return { top: 0, height: 0, width: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} } as DOMRect;
});
return render(
<div data-pane="">
<div data-reader-scroll="" />
<ReplyDock><textarea aria-label="reply text" /></ReplyDock>
</div>,
);
}
afterEach(() => vi.restoreAllMocks());
describe('the reply dock', () => {
it('takes at most half of a tall pane, sized by its content', () => {
const { container } = renderInPane(768);
const dock = container.querySelector('[data-reply-dock]') as HTMLElement;
expect(dock.getAttribute('data-dock-mode')).toBe('share');
expect(dock.style.maxHeight).toBe('384px');
expect(dock.style.height).toBe('');
expect(dock.className).not.toMatch(/basis-/);
expect(screen.queryByText('show_message')).toBeNull();
});
it('takes a short pane under the reader toolbar, and folds to show the message', () => {
const { container } = renderInPane(600, 110);
const dock = container.querySelector('[data-reply-dock]') as HTMLElement;
expect(dock.getAttribute('data-dock-mode')).toBe('takeover');
expect(dock.style.height).toBe('490px');
const editor = screen.getByLabelText('reply text');
act(() => { fireEvent.click(screen.getByText('show_message')); });
expect(dock.style.height).toBe('');
// Folded, the reply stays mounted with its text.
expect(editor.isConnected).toBe(true);
expect(editor.parentElement!.className).toMatch(/\bhidden\b/);
act(() => { fireEvent.click(screen.getByText('back_to_reply')); });
expect(dock.style.height).toBe('490px');
});
it('chooses by the pane height', () => {
expect(dockLayout(699).mode).toBe('takeover');
expect(dockLayout(700)).toEqual({ mode: 'share', maxHeight: 350 });
});
});
+3 -5
View File
@@ -11,6 +11,7 @@ import { productName } from "@/lib/product-name";
import { MessageListTabs, useMessageListPanelProps } from "@/components/email/message-list-tabs";
import dynamic from "next/dynamic";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ReplyDock } from "@/components/mail/reply-dock";
import { addEcho, isUndoWindow, mergeEchoes, sentEcho, withoutHeldSend } from "@/lib/held-send";
import { useQuickReplyRestoreStore } from "@/stores/quick-reply-restore-store";
@@ -5483,6 +5484,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
prefill={composerPrefill}
initialFiles={composerInitialFiles}
sessionKey={`inline-${composerSessionId}`}
docked={composerHost === 'dock'}
sourceEmailId={answeredEmailId}
onSaveState={(data) => {
setComposerPrefill(null);
@@ -6445,11 +6447,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</ErrorBoundary>
</>
)}
{dockedReply && (
<div className="flex basis-[62%] min-h-[18rem] shrink-0 flex-col border-t border-border" data-reply-dock="">
{composerNode}
</div>
)}
{dockedReply && <ReplyDock>{composerNode}</ReplyDock>}
</>
)}
</section>
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronUp } from "@/components/icons";
import { cn } from "@/lib/utils";
/**
* Below this reading-pane height a docked reply can't share the pane with
* the message: an 800×600 window or a 1366×768 laptop at 150 % left two
* lines to type in and none of the message (compose-p2-4).
*/
export const DOCK_SHARE_MIN_PANE = 700;
/**
* How the reply docked under its message takes the reading pane
* (mail-p2-2, compose-p2-4).
*
* - A tall pane: the reply starts as high as its content (the compact
* composer: To, a few lines, the bottom bar) and grows with the text up to
* half the pane, so the message stays readable above it.
* - A short pane: the reply takes the pane, with "Show the message" to fold it
* to a bar and read the message, and back.
*/
export function dockLayout(paneHeight: number): { mode: 'share' | 'takeover'; maxHeight?: number } {
if (paneHeight > 0 && paneHeight < DOCK_SHARE_MIN_PANE) return { mode: 'takeover' };
return { mode: 'share', maxHeight: paneHeight > 0 ? Math.round(paneHeight / 2) : undefined };
}
export function ReplyDock({ children }: { children: ReactNode }) {
const t = useTranslations("mail_p2_conv");
const ref = useRef<HTMLDivElement>(null);
const [paneHeight, setPaneHeight] = useState(0);
// The reader's toolbar and subject above its scrolling part: they stay.
const [readerChrome, setReaderChrome] = useState(0);
const [showMessage, setShowMessage] = useState(false);
useLayoutEffect(() => {
const pane = ref.current?.parentElement;
if (!pane) return;
const measure = () => {
const paneBox = pane.getBoundingClientRect();
setPaneHeight(Math.round(paneBox.height));
const scroll = pane.querySelector<HTMLElement>("[data-reader-scroll]");
setReaderChrome(scroll ? Math.max(0, Math.round(scroll.getBoundingClientRect().top - paneBox.top)) : 0);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(pane);
return () => ro.disconnect();
}, []);
const layout = dockLayout(paneHeight);
const takeover = layout.mode === "takeover";
const folded = takeover && showMessage;
return (
<div
ref={ref}
data-reply-dock=""
data-dock-mode={layout.mode}
className="flex min-h-0 shrink-0 flex-col border-t border-border bg-background"
style={takeover
? (folded ? undefined : { height: Math.max(paneHeight - readerChrome, 0) })
: { maxHeight: layout.maxHeight ?? "50%" }}
>
{takeover && (
<button
type="button"
onClick={() => setShowMessage((v) => !v)}
aria-expanded={showMessage}
data-dock-show-message=""
className="flex shrink-0 items-center justify-center gap-1.5 border-b border-border px-4 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
{showMessage ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
{showMessage ? t("back_to_reply") : t("show_message")}
</button>
)}
{/* Folded, the composer stays mounted, with its text and its undo history. */}
<div className={cn("flex min-h-0 flex-1 flex-col", folded && "hidden")}>
{children}
</div>
</div>
);
}
+4
View File
@@ -3933,6 +3933,10 @@
"show_older": "{count, plural, zero {عرض # رسالة أقدم} one {عرض رسالة أقدم واحدة} two {عرض رسالتين أقدم} few {عرض # رسائل أقدم} many {عرض # رسالة أقدم} other {عرض # رسالة أقدم}}",
"return_to_list_follows_layout": "ما لم تغيّره، يتبع هذا التخطيط: متوقف عندما تكون القائمة والرسالة جنبًا إلى جنب، ومفعّل في غير ذلك."
},
"mail_p2_conv": {
"show_message": "عرض الرسالة",
"back_to_reply": "العودة إلى ردك"
},
"identities": {
"modal_title": "إدارة هويات الإرسال",
"create_new": "إنشاء هوية جديدة",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Mostra el missatge",
"back_to_reply": "Torna a la resposta"
},
"identities": {
"modal_title": "Gestiona les identitats d'enviament",
"create_new": "Crea una identitat nova",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Zobrazit zprávu",
"back_to_reply": "Zpět k odpovědi"
},
"identities": {
"modal_title": "Správa identit odesílatele",
"create_new": "Vytvořit novou identitu",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Vis beskeden",
"back_to_reply": "Tilbage til dit svar"
},
"identities": {
"modal_title": "Administrér afsenderidentiteter",
"create_new": "Opret ny identitet",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Nachricht anzeigen",
"back_to_reply": "Zurück zur Antwort"
},
"identities": {
"modal_title": "Sendeidentitäten verwalten",
"create_new": "Neue Identität erstellen",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Show the message",
"back_to_reply": "Back to your reply"
},
"identities": {
"modal_title": "Manage sending identities",
"create_new": "New identity",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Mostrar el mensaje",
"back_to_reply": "Volver a tu respuesta"
},
"identities": {
"modal_title": "Administrar Identidades de Envío",
"create_new": "Crear Nueva Identidad",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Näita kirja",
"back_to_reply": "Tagasi vastuse juurde"
},
"identities": {
"modal_title": "Halda saatja identiteete",
"create_new": "Loo uus identiteet",
+4
View File
@@ -3933,6 +3933,10 @@
"show_older": "{count, plural, one {نمایش # پیام قدیمی‌تر} other {نمایش # پیام قدیمی‌تر}}",
"return_to_list_follows_layout": "تا وقتی آن را تغییر ندهید، از چیدمان پیروی می‌کند: وقتی فهرست و پیام کنار هم هستند خاموش، در غیر این صورت روشن."
},
"mail_p2_conv": {
"show_message": "نمایش پیام",
"back_to_reply": "بازگشت به پاسخ شما"
},
"identities": {
"modal_title": "مدیریت هویت‌های ارسال",
"create_new": "ایجاد هویت جدید",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Afficher le message",
"back_to_reply": "Revenir à votre réponse"
},
"identities": {
"modal_title": "Gérer les identités d'envoi",
"create_new": "Créer une nouvelle identité",
+4
View File
@@ -3817,6 +3817,10 @@
"show_older": "{count, plural, one {הצגת הודעה ישנה אחת} two {הצגת שתי הודעות ישנות} other {הצגת # הודעות ישנות}}",
"return_to_list_follows_layout": "כל עוד לא תשנו זאת, זה נקבע לפי הפריסה: כבוי כשהרשימה וההודעה זו לצד זו, ופועל בכל מצב אחר."
},
"mail_p2_conv": {
"show_message": "הצגת ההודעה",
"back_to_reply": "חזרה לתשובה שלך"
},
"identities": {
"modal_title": "ניהול זהויות שליחה",
"create_new": "צור זהות חדשה",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Üzenet megjelenítése",
"back_to_reply": "Vissza a válaszhoz"
},
"identities": {
"modal_title": "Küldő azonosságok kezelése",
"create_new": "Új azonosság létrehozása",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Mostra il messaggio",
"back_to_reply": "Torna alla risposta"
},
"identities": {
"modal_title": "Gestisci identità di invio",
"create_new": "Crea nuova identità",
+4
View File
@@ -3933,6 +3933,10 @@
"show_older": "{count, plural, other {以前のメッセージを # 件表示}}",
"return_to_list_follows_layout": "変更するまでは、レイアウトに合わせて切り替わります。一覧とメッセージが横に並ぶ場合はオフ、それ以外はオンです。"
},
"mail_p2_conv": {
"show_message": "メッセージを表示",
"back_to_reply": "返信に戻る"
},
"identities": {
"modal_title": "送信者情報を管理",
"create_new": "新しい送信者情報を作成",
+4
View File
@@ -3933,6 +3933,10 @@
"show_older": "{count, plural, other {이전 메시지 #개 보기}}",
"return_to_list_follows_layout": "변경하기 전까지는 레이아웃을 따릅니다. 목록과 메시지가 나란히 있으면 꺼지고, 그 밖에는 켜집니다."
},
"mail_p2_conv": {
"show_message": "메시지 보기",
"back_to_reply": "답장으로 돌아가기"
},
"identities": {
"modal_title": "보내는 사람 관리",
"create_new": "새 보내는 사람 추가",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Rādīt ziņojumu",
"back_to_reply": "Atpakaļ pie atbildes"
},
"identities": {
"modal_title": "Pārvaldīt sūtītāja identitātes",
"create_new": "Izveidot jaunu identitāti",
+4
View File
@@ -3933,6 +3933,10 @@
"show_older": "{count, plural, one {Өмнөх # захиа харуулах} other {Өмнөх # захиа харуулах}}",
"return_to_list_follows_layout": "Та өөрчлөхөөс нааш энэ нь байрлалаас хамаарна: жагсаалт ба захиа зэрэгцэн байвал унтраалттай, бусад үед асаалттай."
},
"mail_p2_conv": {
"show_message": "Захидлыг харах",
"back_to_reply": "Хариулт руу буцах"
},
"identities": {
"modal_title": "Илгээж буй таниулбаруудыг удирдах",
"create_new": "Шинэ таниулбар үүсгэх",
+4
View File
@@ -3933,6 +3933,10 @@
"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å."
},
"mail_p2_conv": {
"show_message": "Vis meldingen",
"back_to_reply": "Tilbake til svaret ditt"
},
"identities": {
"modal_title": "Administrer avsenderidentiteter",
"create_new": "Opprett ny identitet",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Bericht tonen",
"back_to_reply": "Terug naar je antwoord"
},
"identities": {
"modal_title": "Verzendidentiteiten beheren",
"create_new": "Nieuwe identiteit aanmaken",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Pokaż wiadomość",
"back_to_reply": "Wróć do odpowiedzi"
},
"identities": {
"modal_title": "Zarządzaj tożsamościami nadawcy",
"create_new": "Utwórz nową tożsamość",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Mostrar a mensagem",
"back_to_reply": "Voltar à sua resposta"
},
"identities": {
"modal_title": "Gerenciar Identidades de Envio",
"create_new": "Criar Nova Identidade",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Afișează mesajul",
"back_to_reply": "Înapoi la răspuns"
},
"identities": {
"modal_title": "Gestionarea identităților de expediere",
"create_new": "Creați o identitate nouă",
+4
View File
@@ -3933,6 +3933,10 @@
"show_older": "{count, plural, one {Показать # более раннее письмо} few {Показать # более ранних письма} many {Показать # более ранних писем} other {Показать # более раннего письма}}",
"return_to_list_follows_layout": "Пока вы это не измените, настройка зависит от макета: выключена, когда список и письмо стоят рядом, в остальных случаях включена."
},
"mail_p2_conv": {
"show_message": "Показать письмо",
"back_to_reply": "Вернуться к ответу"
},
"identities": {
"modal_title": "Управление идентификациями отправителя",
"create_new": "Создать новую идентификацию",
+4
View File
@@ -3933,6 +3933,10 @@
"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é."
},
"mail_p2_conv": {
"show_message": "Zobraziť správu",
"back_to_reply": "Späť na odpoveď"
},
"identities": {
"modal_title": "Správa identít odosielateľa",
"create_new": "Vytvoriť novú identitu",
+4
View File
@@ -638,6 +638,10 @@
"show_older": "{count, plural, other {แสดงข้อความก่อนหน้า # ฉบับ}}",
"return_to_list_follows_layout": "จนกว่าคุณจะเปลี่ยน ค่านี้จะเป็นไปตามเลย์เอาต์: ปิดเมื่อรายการและข้อความอยู่เคียงข้างกัน และเปิดในกรณีอื่น"
},
"mail_p2_conv": {
"show_message": "แสดงข้อความ",
"back_to_reply": "กลับไปที่คำตอบของคุณ"
},
"notifications": {
"email_sent": "ส่งอีเมลแล้ว",
"email_deleted": "ลบอีเมลแล้ว",
+4
View File
@@ -3933,6 +3933,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "İletiyi göster",
"back_to_reply": "Yanıtınıza dönün"
},
"identities": {
"modal_title": "Gönderme Kimliklerini Yönet",
"create_new": "Yeni Kimlik Oluştur",
+4
View File
@@ -3933,6 +3933,10 @@
"show_older": "{count, plural, one {Показати # давніший лист} few {Показати # давніші листи} many {Показати # давніших листів} other {Показати # давнішого листа}}",
"return_to_list_follows_layout": "Поки ви це не зміните, налаштування залежить від макета: вимкнено, коли список і лист розташовані поруч, в інших випадках увімкнено."
},
"mail_p2_conv": {
"show_message": "Показати лист",
"back_to_reply": "Повернутися до відповіді"
},
"identities": {
"modal_title": "Керування ідентифікаторами надсилання",
"create_new": "Створіть нову ідентичність",
+4
View File
@@ -3945,6 +3945,10 @@
"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."
},
"mail_p2_conv": {
"show_message": "Hiện thư",
"back_to_reply": "Quay lại thư trả lời"
},
"identities": {
"modal_title": "Quản lý danh tính gửi thư",
"create_new": "Danh tính mới",
+4
View File
@@ -3933,6 +3933,10 @@
"show_older": "{count, plural, other {顯示 # 封較早的郵件}}",
"return_to_list_follows_layout": "在您變更之前,此項會依版面配置而定:清單與郵件並排顯示時關閉,其他情況下開啟。"
},
"mail_p2_conv": {
"show_message": "顯示郵件",
"back_to_reply": "返回回覆"
},
"identities": {
"modal_title": "管理寄件身分",
"create_new": "建立新的寄件身分",
+4
View File
@@ -3933,6 +3933,10 @@
"show_older": "{count, plural, other {显示 # 封较早的邮件}}",
"return_to_list_follows_layout": "在你更改之前,此项随布局而定:列表和邮件并排显示时关闭,其他情况下开启。"
},
"mail_p2_conv": {
"show_message": "显示邮件",
"back_to_reply": "返回回复"
},
"identities": {
"modal_title": "管理发送身份",
"create_new": "创建新身份",