diff --git a/components/email/__tests__/composer-docked.test.tsx b/components/email/__tests__/composer-docked.test.tsx new file mode 100644 index 000000000..3c221d5fe --- /dev/null +++ b/components/email/__tests__/composer-docked.test.tsx @@ -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) => 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) => 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) => 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) => 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) => 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) => 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) => 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: '

Hello there

', + 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(); + 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(); + 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(); + expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(false); + expect(hidden(container.querySelector('[data-composer-from]'))).toBe(false); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 7777a7bb3..71bfccf51 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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({ >
{/* From field */} -
+
{t('from')}:
{fromOverrideEnabled ? ( @@ -5473,7 +5482,7 @@ export function EmailComposer({ )} {/* Subject field */} -
+
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} > diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 432749293..dfdc04a8b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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(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('[data-reader-scroll]'); + const end = pane?.querySelector('[data-open-message-end]'); + if (!pane || !end) return; + const start = pane.querySelector('[data-mv-open-header]') ?? pane.querySelector('[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 : ''} + {/* Where the open message ends: a reply docked under it scrolls to here. */} +