From 49a46d78546632a95f3e3da75db4464690aabe5c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:44:17 +0100 Subject: [PATCH] fix: stop a mail row dragged onto a composer field typing its subject there --- .../[locale]/pro/__tests__/pro-gate.test.tsx | 20 +++++++++++++- app/(main)/[locale]/pro/page.tsx | 4 ++- .../__tests__/composer-close-guard.test.tsx | 27 +++++++++++++++++++ components/email/email-composer.tsx | 20 +++++++++----- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx index 3844af6d5..799b73fd5 100644 --- a/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx +++ b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx @@ -63,7 +63,7 @@ vi.mock('@/hooks/use-deep-link-url', () => ({ useDeepLinkUrl: () => {} })); const { default: ProHome } = await import('../page'); const { useSettingsStore } = await import('@/stores/settings-store'); const { useProTabStore, takeHandedOffComposeDraft } = await import('@/stores/pro-tab-store'); -const { PRO_TAB_DRAG_MIME } = await import('@/components/pro/pro-shell-drop'); +const { EMAIL_IDS_DRAG_MIME, PRO_TAB_DRAG_MIME } = await import('@/components/pro/pro-shell-drop'); const replace = vi.fn(); @@ -176,6 +176,24 @@ describe('Pro page gate', () => { expect(useProTabStore.getState().tabs.find((t) => t.id === 'cal')?.paneId).toBe('split'); }); + it('stops a message row dropped where it can go nowhere, so no field takes its subject', async () => { + auth.state.isAuthenticated = true; + auth.state.client = {}; + const main = { id: 'home-mail', kind: 'mail' as const, labelKey: 'mail', closeable: false, paneId: 'main' as const }; + useProTabStore.setState({ + tabs: [main], activeTabId: main.id, activeSplitTabId: null, focusedPaneId: 'main', + splitOrientation: null, splitSide: 'right', readerTabId: null, loadedTabIds: ['home-mail'], + }); + const { container } = render(); + await act(async () => {}); + const surface = container.querySelector('[data-testid="mail-app"]')!; + const dataTransfer = { types: [EMAIL_IDS_DRAG_MIME, 'text/plain'], getData: () => '["m1"]', dropEffect: '' }; + // No layout in jsdom: the position resolves to the middle, not a split edge. + const notPrevented = fireEvent.drop(surface, { dataTransfer }); + expect(notPrevented).toBe(false); + expect(useProTabStore.getState().tabs).toHaveLength(1); + }); + it('leaves a tab dropped on a tab strip to the strip', async () => { auth.state.isAuthenticated = true; auth.state.client = {}; diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index 737338f90..948fc1c30 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -433,8 +433,10 @@ export default function ProHome() { if (!kind) return; const target = resolveTargetForEvent(e); setDraggedTabId(null); - if (!target) return; + // Also with nowhere to put it: a field under the pointer would take the text + // a message row carries. e.preventDefault(); + if (!target) return; if (kind === 'tab') { const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME); diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 8ac5e5b82..b91aa9d86 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -1,3 +1,4 @@ +import { PaneIdContext } from '@/hooks/use-pane-context'; import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; import { beforeEach, describe, it, expect, vi } from 'vitest'; import React from 'react'; @@ -183,6 +184,32 @@ vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => // ─── Tests ──────────────────────────────────────────────────────────────────── +describe('a message row dragged over the composer', () => { + const rowDrag = { types: ['application/x-email-ids', 'text/plain'], files: [], getData: () => 'Subject of the row', dropEffect: '' }; + const subjectField = () => screen.getByDisplayValue('Half-written'); + const draft = { + to: '', cc: '', bcc: '', subject: 'Half-written', body: '', showCc: false, showBcc: false, + selectedIdentityId: 'id-me', subAddressTag: '', mode: 'compose' as const, draftId: null, + }; + + it('is stopped in the standard shell, or Subject would take the row\'s subject', () => { + render(); + // fireEvent returns false when the event's default was prevented. + expect(fireEvent.dragOver(subjectField(), { dataTransfer: rowDrag })).toBe(false); + expect(fireEvent.drop(subjectField(), { dataTransfer: rowDrag })).toBe(false); + }); + + it('is left to the shell inside a Pro pane', () => { + render( + + + , + ); + expect(fireEvent.dragOver(subjectField(), { dataTransfer: rowDrag })).toBe(true); + expect(fireEvent.drop(subjectField(), { dataTransfer: rowDrag })).toBe(true); + }); +}); + /** * A host that replaces the compose session - a mailto: click, say - must not * drop text the user has not sent. It hands a follow-up to requestCloseRef and diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index cf4b1da6a..57f0fd5ec 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -80,6 +80,7 @@ import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; import { ComposerPanelToggle, ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars"; import { dragKindFromTypes } from "@/components/pro/pro-shell-drop"; +import { useIsPaneScoped } from "@/hooks/use-pane-context"; import { ComposerSelectionMenu, useComposerSelectionMenu } from "./composer-selection-menu"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; @@ -170,8 +171,13 @@ import { registerComposeFlush } from "@/lib/compose-flush-registry"; import { toUnicodeEmail } from "@/lib/idn"; // A Pro tab, message or folder dragged across a composer tab is on its way to -// the pane or the split edge: those drags belong to the shell. -const isShellDrag = (e: React.DragEvent) => dragKindFromTypes(e.dataTransfer.types) !== null; +// the pane or the split edge: those drags belong to the shell. There is no shell +// outside Pro, and the composer stops them like any other drag: a message row +// carries its subject as text, which To and Subject would take. +function useIsShellDrag() { + const isPaneScoped = useIsPaneScoped(); + return useCallback((e: React.DragEvent) => isPaneScoped && dragKindFromTypes(e.dataTransfer.types) !== null, [isPaneScoped]); +} /** * Derives the text/plain alternative from the composer's HTML body, preserving @@ -2501,6 +2507,7 @@ export function EmailComposer({ dragTimeoutRef.current = setTimeout(clearDragState, 150); }, [clearDragState]); + const isShellDrag = useIsShellDrag(); const handleDragEnter = useCallback((e: React.DragEvent) => { if (isShellDrag(e)) return; e.preventDefault(); @@ -2509,21 +2516,21 @@ export function EmailComposer({ setIsDraggingOver(true); resetDragTimeout(); } - }, [resetDragTimeout]); + }, [isShellDrag, resetDragTimeout]); const handleDragLeave = useCallback((e: React.DragEvent) => { if (isShellDrag(e)) return; e.preventDefault(); e.stopPropagation(); resetDragTimeout(); - }, [resetDragTimeout]); + }, [isShellDrag, resetDragTimeout]); const handleDragOver = useCallback((e: React.DragEvent) => { if (isShellDrag(e)) return; e.preventDefault(); e.stopPropagation(); resetDragTimeout(); - }, [resetDragTimeout]); + }, [isShellDrag, resetDragTimeout]); const handleDrop = useCallback((e: React.DragEvent) => { if (isShellDrag(e)) return; @@ -2533,7 +2540,7 @@ export function EmailComposer({ if (e.dataTransfer.files?.length) { addFiles(Array.from(e.dataTransfer.files)); } - }, [addFiles, clearDragState]); + }, [addFiles, clearDragState, isShellDrag]); const removeAttachment = (index: number) => { const att = attachments[index]; @@ -6044,6 +6051,7 @@ export function RecipientChipInput({ setDropIndex(past ? index + 1 : index); }; + const isShellDrag = useIsShellDrag(); // Insert the dragged chip at `target`. Same-field is a local reorder; // cross-field routes through onMoveChip with the destination index (#593). const performDrop = (e: React.DragEvent, target: number) => {