diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b86406d9b0..bba9218a1a 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -95,6 +95,7 @@ export default defineConfig({ "**/cold-switch-longtask.perf.ts", "**/timeline-no-shift.spec.ts", "**/human-edit-agent-content.spec.ts", + "**/empty-edit-delete.spec.ts", "**/reaction-order.spec.ts", "**/reaction-names.spec.ts", "**/inbox-reactions.spec.ts", diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 7b750daa4f..b4d33b4776 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -48,6 +48,7 @@ import { channelWindowThreadSummaries, type ChannelWindowThreadSummary, } from "@/features/messages/lib/channelWindowStore"; +import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; import { getThreadReference } from "@/features/messages/lib/threading"; import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { @@ -483,6 +484,9 @@ export function ChannelScreen({ timelineMessages.find((message) => message.id === editTargetId) ?? null, [editTargetId, timelineMessages], ); + // Event id awaiting the empty-edit "Delete message?" confirmation (non-null + // while the dialog is open); see handleEditSave. + const [emptyDeleteId, setEmptyDeleteId] = React.useState(null); const { handleCancelEdit, handleCancelThreadReply, @@ -506,6 +510,7 @@ export function ChannelScreen({ markRevealedRepliesRead, openThreadHeadId: effectiveOpenThreadHeadId, onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId, + onRequestEmptyEditDelete: setEmptyDeleteId, sendMessageMutation, setExpandedThreadReplyIds, setEditTargetId, @@ -802,6 +807,19 @@ export function ChannelScreen({ open={welcomeAgentCreate.isOpen} sendError={welcomeAgentCreate.error} /> + { + if (emptyDeleteId) { + setEditTargetId(null); + void handleDelete({ id: emptyDeleteId }); + } + setEmptyDeleteId(null); + }} + onOpenChange={(open) => { + if (!open) setEmptyDeleteId(null); + }} + open={emptyDeleteId !== null} + />
>; + onRequestEmptyEditDelete: (eventId: string) => void; openThreadHeadId: string | null; sendMessageMutation: ReturnType; setExpandedThreadReplyIds: React.Dispatch>>; @@ -154,6 +156,22 @@ export function useChannelPaneHandlers({ return; } + // Clearing an edit to empty (no text, no attachments) is the keyboard + // shorthand for "Delete message". Rather than publish an empty edit, + // route it through the same "Delete message?" confirmation the Delete + // button shows. Keep edit mode active while the dialog is open so Cancel + // returns the user to the editor; edit mode is exited only once the + // deletion is confirmed (see ChannelScreen's onConfirm). Single decision + // point for both the main timeline and thread panel — both route + // edit-save through here. + const isEmptyDeletion = + content.trim().length === 0 && + (mediaTags === undefined || mediaTags.length === 0); + if (isEmptyDeletion) { + onRequestEmptyEditDelete(eventId); + return; + } + await editMutateRef.current({ eventId, content, @@ -162,7 +180,7 @@ export function useChannelPaneHandlers({ }); setEditTargetId(null); }, - [setEditTargetId], + [onRequestEmptyEditDelete, setEditTargetId], ); const handleOpenThread = React.useCallback( diff --git a/desktop/src/features/messages/ui/DeleteMessageConfirmDialog.tsx b/desktop/src/features/messages/ui/DeleteMessageConfirmDialog.tsx new file mode 100644 index 0000000000..9802f09efc --- /dev/null +++ b/desktop/src/features/messages/ui/DeleteMessageConfirmDialog.tsx @@ -0,0 +1,53 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; + +/** + * The "Delete message?" confirmation. Single definition shared by every + * surface that deletes a message — the message action menu (MessageActionBar) + * and the empty-edit delete path (clearing an edit to empty and hitting accept + * routes here, so it prompts exactly like the menu's Delete does). `onConfirm` + * fires when the user presses Delete; the caller owns the actual deletion. + */ +export function DeleteMessageConfirmDialog({ + open, + onOpenChange, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + return ( + + + + Delete message? + + This will permanently delete this message and cannot be undone. + + + + + + + + + + + + + ); +} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index ba45bc62fb..967e50f5d2 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -35,17 +35,8 @@ import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { emojiDisplayName } from "@/shared/lib/emojiName"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; +import { DeleteMessageConfirmDialog } from "./DeleteMessageConfirmDialog"; import { DropdownMenu, DropdownMenuContent, @@ -277,35 +268,11 @@ function MoreActionsMenu({ {onDelete ? ( - onDelete(message)} onOpenChange={setIsDeleteDialogOpen} open={isDeleteDialogOpen} - > - - - Delete message? - - This will permanently delete this message and cannot be undone. - - - - - - - - - - - - + /> ) : null} {canReport ? ( diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index df3a734cee..69e4ec67b5 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -513,10 +513,9 @@ function MessageComposerImpl({ if (editTargetRef.current && onEditSaveRef.current) { if (isSendingRef.current || isUploadingRef.current) return; const currentPendingImeta = media.pendingImetaRef.current; - const hasMedia = currentPendingImeta.length > 0; - // Empty text + zero attachments is a no-op (don't let edit become an - // effective deletion). - if (!trimmed && !hasMedia) return; + // No empty-edit guard here: clearing an edit to empty (no text, no + // attachments) flows through to onEditSave as empty content, which + // deletes the message instead of publishing it (see handleEditSave). // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []` // because edit semantics use `[]` as the explicit "wipe all diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts new file mode 100644 index 0000000000..772506571b --- /dev/null +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -0,0 +1,120 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// The mock identity's own pre-seeded message in #general (authored by +// DEFAULT_MOCK_IDENTITY.pubkey in e2eBridge.ts). Editing/deleting one's own +// message is exactly Sam's workflow: "delete a message by clearing its edit." +const OWN_MESSAGE_ID = "mock-general-welcome"; +const ORIGINAL_CONTENT = "Welcome to #general"; + +// Open the more-actions menu for a message row and wait for the menu to mount. +async function openMoreActionsMenu( + page: import("@playwright/test").Page, + messageId: string, +) { + const row = page.locator(`[data-message-id="${messageId}"]`); + await row.hover(); + await page.getByTestId(`more-actions-${messageId}`).click(); + await expect(page.locator('[role="menuitem"]').first()).toBeVisible({ + timeout: 5_000, + }); +} + +// Enter edit mode for a message, clear it to empty, and submit — the gesture +// that triggers the empty-edit delete confirmation. +async function submitEmptyEdit( + page: import("@playwright/test").Page, + messageId: string, +) { + await openMoreActionsMenu(page, messageId); + await page.getByTestId(`edit-message-${messageId}`).click(); + await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 }); + // Edit mode sets the editor content via Tiptap's async transaction pipeline; + // wait for it to populate before we clear it. + const input = page.getByTestId("message-input"); + await expect(input).not.toBeEmpty({ timeout: 5_000 }); + await input.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("Backspace"); + await expect(input).toBeEmpty(); + await page.keyboard.press("Enter"); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +}); + +test("clearing an edit to empty prompts to delete, then deletes on confirm", async ({ + page, +}) => { + const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`); + await expect(row).toBeVisible({ timeout: 10_000 }); + + await submitEmptyEdit(page, OWN_MESSAGE_ID); + + // The same "Delete message?" confirmation the Delete menu action shows — an + // empty edit is routed through it, not silently deleted. + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog).toContainText("Delete message?"); + // Edit mode stays active while the dialog is open — it exits only on confirm. + await expect(page.getByTestId("edit-target")).toBeVisible(); + + // Confirm → the message row is removed and edit mode has exited. + await dialog.getByRole("button", { name: "Delete" }).click(); + await expect(dialog).toBeHidden({ timeout: 5_000 }); + await expect(page.getByTestId("edit-target")).toBeHidden(); + await expect(row).toBeHidden({ timeout: 5_000 }); +}); + +test("cancelling the empty-edit delete keeps the message", async ({ page }) => { + const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`); + await expect(row).toBeVisible({ timeout: 10_000 }); + + await submitEmptyEdit(page, OWN_MESSAGE_ID); + + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Cancel → nothing is deleted, the original message survives, and the user is + // left in edit mode (the editing session is preserved, not discarded). + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).toBeHidden({ timeout: 5_000 }); + await expect(page.getByTestId("edit-target")).toBeVisible(); + await expect(row).toBeVisible(); + await expect(page.getByTestId("message-timeline")).toContainText( + ORIGINAL_CONTENT, + ); +}); + +test("a non-empty edit still edits and never deletes", async ({ page }) => { + const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`); + await expect(row).toBeVisible({ timeout: 10_000 }); + + await openMoreActionsMenu(page, OWN_MESSAGE_ID); + await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click(); + await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 }); + const input = page.getByTestId("message-input"); + await expect(input).not.toBeEmpty({ timeout: 5_000 }); + const editedContent = `Edited, not deleted ${Date.now()}`; + + await input.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type(editedContent); + await page.keyboard.press("Enter"); + + // No delete confirmation, edit mode exits, the row survives with new text. + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await expect(page.getByTestId("edit-target")).toBeHidden({ timeout: 5_000 }); + await expect(row).toBeVisible(); + await expect(page.getByTestId("message-timeline")).toContainText( + editedContent, + ); + await expect(page.getByTestId("message-timeline")).not.toContainText( + ORIGINAL_CONTENT, + ); +});