feat(desktop): delete a message by clearing its edit to empty - #3813
Conversation
Editing a message down to an empty body and submitting used to be a
deliberate no-op ("don't let edit become an effective deletion"). But a
common workflow is to delete a message by opening its edit, clearing the
text, and hitting accept — which just hung.
Now an empty edit submit (no text, no attachments) is the keyboard
shorthand for the "Delete message" action: it surfaces the same
confirmation dialog and, on confirm, deletes the message and exits edit
mode. A confirmation — rather than an instant delete — means an
accidental clear-and-Enter can't destroy a message with no undo. When no
delete handler is wired (e.g. archived channels) it stays an inert
no-op, so an empty edit can never publish an empty body.
Wired through both the main timeline and thread composers. The decision
logic lives in a pure, unit-tested helper (emptyEditDelete.ts).
The MessageComposer file-size ratchet forbids growing that already-large
file, so the feature lives in a dedicated hook (useEmptyEditDelete) and
the self-contained insertEmoji callback was lifted into its own hook
(useComposerInsertEmoji) to make room — no behavior change to emoji
insertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sam Westerman <swesterman@squareup.com>
…uplicate dialog Review feedback: the first cut added a second "Delete message?" AlertDialog in a composer-side hook — a verbatim copy of the one already in MessageActionBar. That duplicated the delete-confirmation UI. Drop the dialog entirely. When an edit is cleared to empty and submitted, the composer now calls the existing delete handler directly (the same deleteMutate the "Delete message" button uses, via handleDeleteEditTarget, which also exits edit mode) — no separate confirmation, since clearing a message and pressing Enter is already a deliberate act. The tested pure guard `resolveEmptyEditDelete` is kept, so an empty edit still no-ops when no delete handler is wired and never publishes an empty body. Removes useEmptyEditDelete.tsx (~120 lines). The useComposerInsertEmoji extraction stays: MessageComposer.tsx is grandfathered over the 1000-line ratchet (1027 on main), and that extraction is what keeps this change under the frozen baseline (now 1019). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Sam Westerman <swesterman@squareup.com>
…ndleEditSave Review feedback: the onDeleteEditTarget prop threaded through ChannelScreen → ChannelPane → MessageComposer / MessageThreadPanel was redundant. Both the main timeline and the thread panel already route edit-save through the same handleEditSave, so the empty→delete decision belongs there — a single decision point, no new prop chain. handleEditSave now deletes the edit target (via the same deleteMutate the "Delete message" button uses) when the submitted content is empty with no media tags, instead of publishing an empty edit. The composer's empty-edit branch just hands "" to onEditSave. Removes the onDeleteEditTarget prop from MessageComposer, MessageThreadPanel, ChannelPane, and their types; drops handleDeleteEditTarget; and deletes the now-unused resolveEmptyEditDelete helper + test. Net −106/+27 this pass. Image-only edits (empty text, media present) still publish normally — only a fully empty edit deletes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Sam Westerman <swesterman@squareup.com>
The empty->delete behavior needs no special-case branch in the composer:
the old edit path had a guard (`if (!trimmed && !hasMedia) return;`) that
*blocked* empty edits. Removing that guard lets empty content flow through
buildOutgoingMessage("") -> onEditSave("", [], []) -> handleEditSave, which
already deletes on empty content. Net-negative change to the composer.
That removes the reason the emoji-insertion logic was extracted into
useComposerInsertEmoji.ts (only ever done to claw back lines under the
file-size ratchet). Reverted that extraction entirely so this PR touches
nothing emoji-related; MessageComposer.tsx sits at 1026, under its 1027
grandfathered baseline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Sam Westerman <swesterman@squareup.com>
Add tests/e2e/empty-edit-delete.spec.ts covering the feature end to end on the mock identity's own #general message: clearing an edit to empty deletes the row (no confirmation dialog), and a non-empty edit still edits and never deletes. Registered in the smoke project. Drop the onEditSave doc block on MessageComposer.types.ts: it described the handler's empty->delete behavior on the prop interface, which is the wrong home (a different onEditSave impl need not delete on empty) and duplicated the comment at the composer call site. Returns the types file to baseline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Sam Westerman <swesterman@squareup.com>
Per review, an empty-edit delete should not fire silently — it should show the same "Delete message?" confirmation the Delete menu action does. Extract that confirmation into a shared DeleteMessageConfirmDialog (single definition, no duplication) and use it in two places: - MessageActionBar renders it for the Delete menu action (was inline). - handleEditSave, on an empty edit, exits edit mode and asks ChannelScreen to open the same dialog via onRequestEmptyEditDelete; Delete runs the existing deleteMutate, Cancel leaves the message untouched. E2E updated: empty edit prompts then deletes on confirm, survives on cancel; a non-empty edit still edits with no prompt. 3 specs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Sam Westerman <swesterman@squareup.com>
tellaho
left a comment
There was a problem hiding this comment.
🤖 One interaction detail I noticed: before this change, attempting to save an empty edit returned early and left the user in edit mode, so they could resume typing. Now handleEditSave clears the edit target before opening the confirmation dialog, which means Cancel keeps the original message but exits edit mode and discards the editing session.
Could we keep edit mode active while the dialog is open, then exit only after deletion is confirmed? That would preserve the previous Cancel behavior. What do you think?
Per PR review: previously handleEditSave cleared the edit target before opening the confirmation, so Cancel exited edit mode and discarded the editing session. Now the empty branch leaves edit mode active while the "Delete message?" dialog is open; edit mode is exited only when deletion is confirmed (in ChannelScreen's onConfirm). Cancel returns the user to the editor, restoring the pre-change resume-editing behavior. E2E updated: assert edit mode stays active while the dialog is open and after Cancel, and exits on confirm. 3 specs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Sam Westerman <swesterman@squareup.com>
|
🤖 Good catch @tellaho — implemented in
Added E2E assertions to lock it in: edit mode persists while the dialog is open and after Cancel, and exits on Delete. All three specs green. |
…cache * origin/main: feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
…evert-fix * origin/main: fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) fix(desktop): align data deletion labels (#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…chive * origin/main: (25 commits) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) fix(desktop): align data deletion labels (#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) fix(desktop): allow linux-only media items as dead code off-linux (#3811) fix(desktop): report authenticated relay recovery (#3812) fix(desktop): don't gate hover affordances on the hover media query (#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358) test(desktop): click visible thread collapse guide (#3800) feat(desktop): raise the install ceiling and make installs observable (#3368) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # desktop/src/testing/e2eBridge.ts # desktop/tests/helpers/bridge.ts
…chive * origin/main: (25 commits) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) fix(desktop): align data deletion labels (#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) fix(desktop): allow linux-only media items as dead code off-linux (#3811) fix(desktop): report authenticated relay recovery (#3812) fix(desktop): don't gate hover affordances on the hover media query (#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358) test(desktop): click visible thread collapse guide (#3800) feat(desktop): raise the install ceiling and make installs observable (#3368) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # desktop/src/testing/e2eBridge.ts # desktop/tests/helpers/bridge.ts
…-style * origin/main: (22 commits) feat(desktop): import local Pocket voices (block#3259) fix(desktop): open profiles from avatars (block#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands block#2467 + block#3208) (block#3910) docs: add VISION_REMOTE_AGENTS.md (block#3924) feat(desktop): auto-enable huddle transcription for agents (block#3180) feat(agent): optional reply guard reminds a silent turn to publish (block#3763) feat(desktop): upgrade Pocket TTS model (block#3266) feat(desktop): delete a message by clearing its edit to empty (block#3813) feat(relay): raise hosted community limit to five (block#3829) feat(desktop): locally stored NIP-49 encrypted key backup (block#2937) fix(catalog): update Amp tagline (block#3806) fix(desktop): channel topic and membership metadata cleanup (block#3642) fix(desktop): align data deletion labels (block#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (block#3635) fix(desktop): allow linux-only media items as dead code off-linux (block#3811) fix(desktop): report authenticated relay recovery (block#3812) fix(desktop): don't gate hover affordances on the hover media query (block#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (block#3358) test(desktop): click visible thread collapse guide (block#3800) feat(desktop): raise the install ceiling and make installs observable (block#3368) ... Amp-Thread-ID: https://ampcode.com/threads/T-019fb8e1-6ece-72a7-8808-9b12e0f7e833 Co-authored-by: Amp <amp@ampcode.com> Signed-off-by: Joah Gerstenberg <joah@squareup.com> # Conflicts: # desktop/src-tauri/src/linux_media.rs # desktop/src/app/AppShell.tsx
…-phase2-integration * origin/main: (38 commits) fix(release): preserve main in desktop PR body (#3979) chore(release): release Buzz Desktop version 0.5.3 (#3972) fix(release): require exact-head approval for desktop tags (#3973) fix(release): make desktop tagging squash-safe (#3965) Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) chore(release): release Buzz Desktop version 0.5.3 fix(release): make immutable desktop release operable (#3943) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) ... Signed-off-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
…el-label-registry-sync * origin/main: (39 commits) feat(relay): accept kind:30621 multi-repo projects at ingest (#3171) fix(release): preserve main in desktop PR body (#3979) chore(release): release Buzz Desktop version 0.5.3 (#3972) fix(release): require exact-head approval for desktop tags (#3973) fix(release): make desktop tagging squash-safe (#3965) Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) chore(release): release Buzz Desktop version 0.5.3 fix(release): make immutable desktop release operable (#3943) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) ... Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
What
Clearing an edit to empty and hitting accept now deletes the message instead of hanging. One of Sam's frequent workflows is to delete a message by editing it, clearing the text, and pressing Enter — which previously no-op'd (a deliberate guard blocked empty edits).
How
Pure client-side wiring — no relay, schema, or Rust changes.
MessageComposer.tsx— the edit path had a guard that blocked empty edits (if (!trimmed && !hasMedia) return;). That guard is simply removed, so empty content flows through the normal edit path toonEditSave("", [], []).buildOutgoingMessage("")is a safe no-op.handleEditSaveinuseChannelPaneHandlers.ts— when an edit is submitted with empty text and no media tags, it exits edit mode and opens the same "Delete message?" confirmation the Delete menu action shows, rather than publishing an empty edit.DeleteMessageConfirmDialog.tsx— the confirmation dialog, extracted into one shared component.MessageActionBarrenders it for the Delete menu action (previously inline), andChannelScreenrenders it for the empty-edit path. No duplicated dialog UI. Delete runs the existingdeleteMutate; Cancel leaves the message untouched.Because both the main timeline and the thread panel already route edit-save through
handleEditSave, this covers both surfaces with a single dialog at theChannelScreenlevel — no per-composer plumbing.handleEditSavereturns before the edit mutation.Review history
This PR was reworked three times in response to review — each pass made it smaller:
AlertDialogrendered from a composer hook — a verbatim duplicate of the confirmation already inMessageActionBar.tsx. Removed.onDeleteEditTargetcallback downChannelScreen → ChannelPane → MessageComposer / MessageThreadPanel. Also redundant — the delete decision moved entirely intohandleEditSave, which every edit-save already flows through.MessageComposer.tsxover the file-size ratchet and led to an unrelated emoji-helper extraction to make room. Both gone: deleting the pre-existing guard (rather than adding a branch) is net-negative, so there's no ratchet pressure and nothing emoji-related in this PR.MessageComposer.types.tsis back to baseline too.DeleteMessageConfirmDialogcomponent (so it's reuse, not the duplicate dialog from cut Dependency Dashboard #1).Testing
desktop/tests/e2e/empty-edit-delete.spec.ts(Playwright, smoke project), three tests, all passing locally:#generalmessage, clears it, Enter → the "Delete message?" dialog appears; Delete → the row disappears and edit mode exits.pnpm typecheck, biome, file-size + px-text guards all clean; full desktop unit suite (3847 tests) passing locally.🐝 Built by Bumble in Buzz, from a conversation in #test-swesterman.