mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": "إنشاء هوية جديدة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ایجاد هویت جدید",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "צור זהות חדשה",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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à",
|
||||
|
||||
@@ -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": "新しい送信者情報を作成",
|
||||
|
||||
@@ -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": "새 보내는 사람 추가",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Шинэ таниулбар үүсгэх",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ść",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -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": "Создать новую идентификацию",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ลบอีเมลแล้ว",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Створіть нову ідентичність",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "建立新的寄件身分",
|
||||
|
||||
@@ -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": "创建新身份",
|
||||
|
||||
Reference in New Issue
Block a user