Skip to content
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string | null>(null);
const {
handleCancelEdit,
handleCancelThreadReply,
Expand All @@ -506,6 +510,7 @@ export function ChannelScreen({
markRevealedRepliesRead,
openThreadHeadId: effectiveOpenThreadHeadId,
onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId,
onRequestEmptyEditDelete: setEmptyDeleteId,
sendMessageMutation,
setExpandedThreadReplyIds,
setEditTargetId,
Expand Down Expand Up @@ -802,6 +807,19 @@ export function ChannelScreen({
open={welcomeAgentCreate.isOpen}
sendError={welcomeAgentCreate.error}
/>
<DeleteMessageConfirmDialog
onConfirm={() => {
if (emptyDeleteId) {
setEditTargetId(null);
void handleDelete({ id: emptyDeleteId });
}
setEmptyDeleteId(null);
}}
onOpenChange={(open) => {
if (!open) setEmptyDeleteId(null);
}}
open={emptyDeleteId !== null}
/>
<div
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
ref={channelContentRef}
Expand Down
20 changes: 19 additions & 1 deletion desktop/src/features/channels/useChannelPaneHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function useChannelPaneHandlers({
getReplyDescendantIdsForMessage,
markRevealedRepliesRead,
onOptimisticOpenThreadHeadIdChange,
onRequestEmptyEditDelete,
openThreadHeadId,
sendMessageMutation,
setExpandedThreadReplyIds,
Expand All @@ -45,6 +46,7 @@ export function useChannelPaneHandlers({
onOptimisticOpenThreadHeadIdChange: React.Dispatch<
React.SetStateAction<string | null | undefined>
>;
onRequestEmptyEditDelete: (eventId: string) => void;
openThreadHeadId: string | null;
sendMessageMutation: ReturnType<typeof useSendMessageMutation>;
setExpandedThreadReplyIds: React.Dispatch<React.SetStateAction<Set<string>>>;
Expand Down Expand Up @@ -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,
Expand All @@ -162,7 +180,7 @@ export function useChannelPaneHandlers({
});
setEditTargetId(null);
},
[setEditTargetId],
[onRequestEmptyEditDelete, setEditTargetId],
);

const handleOpenThread = React.useCallback(
Expand Down
53 changes: 53 additions & 0 deletions desktop/src/features/messages/ui/DeleteMessageConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete message?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this message and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={onConfirm} type="button" variant="destructive">
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
41 changes: 4 additions & 37 deletions desktop/src/features/messages/ui/MessageActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -277,35 +268,11 @@ function MoreActionsMenu({
</DropdownMenu>

{onDelete ? (
<AlertDialog
<DeleteMessageConfirmDialog
onConfirm={() => onDelete(message)}
onOpenChange={setIsDeleteDialogOpen}
open={isDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete message?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this message and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => onDelete(message)}
type="button"
variant="destructive"
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
/>
) : null}

{canReport ? (
Expand Down
7 changes: 3 additions & 4 deletions desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions desktop/tests/e2e/empty-edit-delete.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
Loading