Skip to content

feat(desktop): editable attachments + data-loss fix on message edit#755

Merged
wesbillman merged 9 commits into
mainfrom
fix/edit-deletes-attachment
May 27, 2026
Merged

feat(desktop): editable attachments + data-loss fix on message edit#755
wesbillman merged 9 commits into
mainfrom
fix/edit-deletes-attachment

Conversation

@wesbillman
Copy link
Copy Markdown
Collaborator

@wesbillman wesbillman commented May 27, 2026

Summary

Fixes a data-loss bug AND lands the proper Slack-style attachment-editable edit flow for desktop. Editing a message with an image/video used to delete the attachment from the saved message; this PR makes attachments first-class state in the edit composer (add / remove / replace, alongside text), and fixes follow-up rendering, focus, and tag-shape regressions caught during testing/review.

Reported by: Will Pfleger / kalvin in #sprout-bugs (thread root 6a14995049990b2380527d0bbb90c86d08421f4c7c51efb0eefc01c709d6b0b8).

What changed (in order on the branch)

1. Initial data-loss fix (commits 1–3)

Round-trip imeta media through the composer body so existing attachments survive an edit:

  • Edit-load: strip trailing ![image|video](url) lines but remember the imeta-derived URLs.
  • Edit-submit: re-append imeta lines to the new body before sending.
  • Empty body + non-empty media is allowed (caption-less edit). Empty body + empty media is rejected (no edit-as-deletion).
  • Helper formatImetaMediaLine({url, m}) shared between send and edit paths (mime→alt lives in one place).

2. Slack-style edit flow (commits 4–5)

Attachments are now first-class composer state on edit, not just preserved metadata:

  • Edit-load seeds pendingImeta from editTarget.imetaMedia. The existing attachment row renders them as removable thumbnails. Paperclip / drop / paste already work in edit mode — no new UI.
  • Edit-submit emits a fresh full imeta tag set (kind:40003 carries arbitrary tags). Body still carries the ![](url) markdown lines so the renderer (markdown.tsx) draws them — imetaByUrl is metadata enhancement, not a node synthesizer.
  • ImetaMedia widened to full BlobDescriptor (sha256/size/dim/blurhash/thumb/duration preserved).
  • Receiver overlay (applyEditTagOverlay.mjs): on render, edit's imeta tags replace original's; non-imeta tags (h, mentions, replies) are preserved.
  • Edit-mode entry preserves any draft attachments the user already had pending in the composer (regression caught in review).
  • Rust validation in edit_message: empty body + zero attachments rejected (was: empty body alone).

3. Refactor pass (commit 6)

Behavior-preserving consolidation:

  • applyEditTagOverlay.mjs (+ .d.mts) — single source for tag overlay, imported by both the renderer and the cache update + tests.
  • buildOutgoingMessage(body, pendingImeta) — shared by send and edit submit paths (undefined = "omit imeta tags" for sends, [] = "wipe attachments" for edits).
  • preEditSnapshotRef: { content, pendingImeta } collapses two refs that were always set/cleared together.

4. Focus fix on edit entry (commits 7–8)

Cosmetic regression caught during local testing — pressing Delete after clicking Edit was escaping the composer:

  • Root cause: Radix <DropdownMenuContent> schedules setTimeout(0) to restore focus to the menu trigger on close, which fires AFTER requestAnimationFrame. Our edit-mode focus call landed (briefly visible flicker) and was then stolen back to the trigger button.
  • Fix: per-mount editJustSelectedRef flipped on the Edit onClick. onCloseAutoFocus reads it and preventDefault()s only when Edit was the picked item — Reply / Mark unread / Delete / Copy / reactions / Escape / outside-click all keep their default Radix focus restoration (a11y intact).
  • Belt-and-suspenders: the composer's RAF stays in place for any future trigger UI that doesn't override.

5. Sparse-imeta hygiene (commit 9)

Reviewer (Codex bot) caught an asymmetry: buildImetaTags was emitting x and size lines unconditionally, so a legacy/cross-client imeta missing those NIP-94 fields would surface as literal x (empty value) and size 0 on the kind:40003 edit — both rejected by Sprout's relay validator.

  • Fix: emit x only when sha256 is non-empty; emit size only when > 0. Other optional fields (dim/blurhash/thumb/duration/image) were already conditional.
  • Send path is unchanged — full BlobDescriptor always carries valid sha256 + positive size, both lines emitted as before.
  • Scope: this is desktop-side structural correctness only. Successfully editing a legacy/cross-client sparse-imeta message ALSO requires relaxing the relay validator (crates/sprout-relay/src/handlers/imeta.rs requires url + m + x + size AND a local /media/... URL). That's a separate follow-up tied to federation / cross-client interop.

Steps to reproduce the bug (before this PR)

  1. Send a message with an image or video attachment.
  2. Right-click → Edit on your own message.
  3. Change the caption (or save unchanged).
  4. Bug A (original): the attachment disappears from the saved message.
  5. Bug B (caught during local testing of A's fix): even with imeta tags preserved, the rendered message shows no media until you re-open the editor.
  6. Bug C (caught while testing B's fix): clicking Edit briefly focuses the composer, then loses focus to the message row — hitting Delete escapes instead of editing.

Steps to verify the fix

P0 — bugs we fixed

  1. Send a message with an image. Edit, change the text, save. → Image still renders. Reload. → Still renders.
  2. Click Edit on a message. → Composer holds focus (no flicker, no escape on Delete keypress).
  3. Stage a draft image in the composer. Click Edit on an older message. Cancel. → Draft image survives.

P1 — new capability
4. Edit a text-only message, paste an image, save. → Image renders.
5. Edit a message with an image, click X on the thumbnail in the composer, save. → Image is gone. Reload. → Still gone.
6. Edit, remove image A, add image B, save. → Only B renders.
7. Send a message with 2 images, edit, remove one, save. → Only the kept one renders.
8. Edit, clear text, keep image, save. → Allowed; renders just the image.
9. Edit, clear text AND remove all attachments. → Save is rejected (no edit-as-deletion).

P2 — interactions
10. Repeat #1, #4, #5 with a video.
11. Reactions on a message stay after edit.
12. Mentions / p / h tags survive the edit.

Automated test coverage

  • desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs — pure helpers (extract, strip, append, round-trip, edge cases) + buildOutgoingMessage cases + sparse-imeta projection / round-trip / conditional emit.
  • desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs — tag overlay (no-edit pass-through, A,B→A,C swap with non-imeta preserved, edit-strips-all, edit-adds-to-text-only, leak-prevention).

Test counts at HEAD: Desktop node:test 279 / 0 · Rust src-tauri --lib 374 / 0. pnpm typecheck ✅, pnpm check (biome + file-sizes) ✅, full pre-push hooks ✅.

Out-of-scope follow-ups

  • Mobile parity — mobile's edit path doesn't handle attachments today.
  • Relay-side imeta validator — to actually accept legacy/cross-client sparse-imeta edits and federated (non-local URL) media, the validator in crates/sprout-relay/src/handlers/imeta.rs needs to allow optional fields and non-local URLs. Tracked separately.
  • NIP-92 alt / fallback / service projection — currently dropped because BlobDescriptor doesn't carry them. Documented at imetaMediaFromTags.
  • MessageComposer.tsx is now ~800 lines — flagged as a future split candidate.

Commits

  1. fix(desktop): preserve attachments when editing messages — initial round-trip.
  2. fix(desktop): cover edit-attachment edge cases — caption-less guard, append dedup, legacy-mime fallback.
  3. refactor(desktop): share imeta line formatter between send and edit paths.
  4. feat(desktop): Slack-style attachment-editable edit flow.
  5. fix(desktop): address Beth review on attachment-editable edit flow — render bug + draft-loss.
  6. refactor(desktop): tighten edit-attachment helpers — overlay extraction + outgoing-message helper + ref collapse.
  7. fix(desktop): focus editor on edit-mode entry so global keybinds land.
  8. fix(desktop): suppress Radix focus-restoration when picking Edit — scoped per-Edit-item, a11y preserved on other paths.
  9. fix(desktop): omit imeta x/size lines when values absent — sparse-imeta hygiene.

@wesbillman wesbillman requested a review from a team as a code owner May 27, 2026 00:44
@wesbillman wesbillman force-pushed the fix/edit-deletes-attachment branch from 327c24e to 5cff4e8 Compare May 27, 2026 00:55
wesbillman and others added 3 commits May 26, 2026 17:59
Edit events carry only new content; imeta tags live on the original
event. The renderer overlays edit.content on the original but
markdown.tsx only renders <img>/<video> for URLs literally in the body.
Editing dropped the attachment markdown -> attachment vanished.

Round-trip imeta through the composer:
- New imetaMediaMarkdown.ts: stripImetaMediaLines + appendImetaMediaLines
  + imetaMediaFromTags (mime-typed {url, m} list, in tag order).
- MessageComposer strips trailing ![image|video](url) lines on edit-load
  and re-appends them on save.
- ChannelScreen projects editTargetMessage.tags via imetaMediaFromTags;
  ChannelPane / MessageThreadPanel forward imetaMedia through editTarget.

Mime-typed list (not bare URLs) so videos round-trip with ![video] alt
regardless of URL suffix (markdown.tsx switches on .mp4 today).

Tests: 12 new node:test cases covering strip/append/round-trip incl.
videos without .mp4 suffix and interleaved-text preservation.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Self-verify pass on edit-deletes-attachment fix per Beth's review:

1. Allow saving an empty caption when the message still carries imeta
   attachments. Previously the edit-submit guard rejected an empty
   trimmed body even though the imeta lines would be re-appended,
   making caption-less attachment edits a silent no-op.

2. `appendImetaMediaLines` now skips URLs already textually present in
   the body. Defends future/manual layouts where an imeta line isn't
   trailing (interleaved captions, reorderable UI, etc.) from
   double-rendering on round-trip.

3. `imetaMediaFromTags` falls back to `image/jpeg` when an imeta tag
   lacks a mime type, so legacy events still round-trip.

Tests: +4 new (10 → 16 in this file), 262 desktop node:test pass.
typecheck + check ✅.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…aths

Extract formatImetaMediaLine({url, m}) so the mime->alt mapping
(![video] for video/*, else ![image]) lives in one place and the send
and edit paths use the exact same primitive. Behavior preserved.

Co-authored-by: summer <summer@sprout>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman force-pushed the fix/edit-deletes-attachment branch from 5cff4e8 to 88e57dc Compare May 27, 2026 00:59
Editing a message with attachments now opens those attachments in the
composer's existing pending-imeta row, where the user can remove them,
add new ones (paperclip/drop/paste), and edit the caption — exactly
like composing a fresh message. Saving emits an edit event (kind:40003)
that carries a full new imeta tag set; the receiver overlays the edit's
imeta tags onto the original event, leaving non-imeta tags (h, mentions)
untouched.

This stacks on the data-loss prevention fix from PR #755. The previous
approach round-tripped imeta markdown lines through the body to keep
attachments visible; now the body is text-only and attachments live
purely in tags, matching the send-path data model.

Changes
- ImetaMedia widened from {url, m} to BlobDescriptor so the same
  pendingImeta state powers both send and edit. imetaMediaFromTags
  projects the full descriptor (sha256, size, dim, blurhash, thumb,
  duration, image) from the original event's imeta tags.
- buildImetaTags extracted from the send path; reused for edit.
  formatImetaMediaLine retained for the send path's body markdown.
  appendImetaMediaLines removed (edit path no longer reconstitutes
  body lines — the user-visible body is now the source of truth).
- MessageComposer edit-load now seeds media.setPendingImeta from
  editTarget.imetaMedia; ComposerAttachments renders them with no new
  UI. Edit-cancel clears the seeded imeta. Edit-submit builds mediaTags
  from current pendingImeta and passes through onEditSave.
- onEditSave signature: (content, mediaTags?). Plumbed through
  ChannelPane, MessageThreadPanel, useChannelPaneHandlers,
  useEditMessageMutation, editMessage (tauri.ts), edit_message Tauri
  command, and build_message_edit (events.rs).
- Empty content + empty imeta is still rejected at the Rust boundary
  to prevent edits from being effective deletions; empty content with
  attachments is allowed (caption-less media-only edits).
- Receiver overlay (formatTimelineMessages): editsByTargetId now
  carries the edit's tags; the rendered message's tags merge non-imeta
  original tags with imeta tags from the edit. Optimistic update in
  useEditMessageMutation mirrors the same projection so local
  feedback is consistent until the edit event echoes back.

Tests
- imetaMediaMarkdown.test.mjs extended for the widened
  imetaMediaFromTags projection and buildImetaTags shape.
- New formatTimelineMessages.test.mjs covers the imeta overlay:
  - no edit → original tags untouched
  - edit replaces imeta A,B → A,C; non-imeta preserved
  - edit with zero imeta strips all attachments
  - edit adding imeta to a previously text-only message
- Desktop suite: 269 pass / 0 fail (was 262).
- Rust suite (src-tauri --lib): 374 pass / 0 fail.
- pnpm typecheck and pnpm check (biome + file-sizes) clean.

Out of scope
- Mobile parity. Mobile's edit path doesn't handle attachments today;
  follow-up task.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Two BLOCKs and two CHANGEs from review of the Slack-style edit feature.

BLOCK 1 — rendering bug
The previous commit's edit-submit dropped the `![image|video](url)` body
lines and relied on the receiver tag overlay alone. But `markdown.tsx`
only emits `<img>`/`<VideoPlayer>` for URLs literally present in the
body — `imetaByUrl` (from `MessageRow.tsx`) is metadata enrichment for
nodes the parser has already created, not a synthesizer. Result:
post-edit body had no image lines and the renderer drew nothing.

Fix: re-append `formatImetaMediaLine` per attachment to the saved body
in edit-submit, exactly mirroring the send path. Send and edit now
share a single body-shape model. The imeta-tag overlay still runs (so
blurhash/poster on `MessageRow` come from the edited tag set), but the
`<img>`/`<video>` element itself is back to being driven by the body.

BLOCK 2 — entering edit destroyed draft attachments
Edit-enter clobbered `pendingImeta` with the edit target's attachments
without snapshotting the user's in-flight draft. Edit-exit then set
`pendingImeta` to `[]`, dropping any uploads the user had staged for
a fresh send.

Fix: new `preEditPendingImetaRef` snapshots
`media.pendingImetaRef.current` on edit-enter; the else branch restores
it (alongside `preEditContentRef`) on edit-exit.

CHANGE 3 — drift-proof the overlay test
Extracted `applyEditOverlay` and `applyEditTagOverlay` to a sibling
`applyEditOverlay.mjs` with a `.d.mts` declaration. Both
`formatTimelineMessages.ts` (renderer) and `useEditMessageMutation`
(post-edit cache update) now import the same source. The test file
imports the same `.mjs` directly — no inlined copy, so the test can't
silently drift from production. Also added a test pinning the
behaviour that the edit's `h`/`e`/`p` tags do NOT leak into the merged
set (only the original's non-imeta tags survive).

CHANGE 4 — projection-ceiling comment
Documented that `imetaMediaFromTags` drops NIP-92 `alt`/`fallback`/
`service` fields (`BlobDescriptor` doesn't carry them). Loss only
fires on cross-client edits today; comment calls out the path forward
if those fields ever need to be first-class in the composer.

NIT — apply-on-success vs optimistic
`useEditMessageMutation` cache update runs in `onSuccess`, not
`onMutate`. Reworded the comment so "optimistic" doesn't mislead
future readers.

Files
- desktop/src/features/messages/lib/applyEditOverlay.mjs (new)
- desktop/src/features/messages/lib/applyEditOverlay.d.mts (new)
- desktop/src/features/messages/lib/formatTimelineMessages.ts
  (uses .mjs helper)
- desktop/src/features/messages/lib/formatTimelineMessages.test.mjs
  (imports the .mjs directly + 2 new tests pinning leak-prevention)
- desktop/src/features/messages/lib/imetaMediaMarkdown.ts
  (projection-ceiling comment on imetaMediaFromTags)
- desktop/src/features/messages/hooks.ts
  (uses applyEditTagOverlay; reworded apply-on-success comment)
- desktop/src/features/messages/ui/MessageComposer.tsx
  (preEditPendingImetaRef stash/restore; re-append imeta lines on
   edit-submit)
- desktop/scripts/check-file-sizes.mjs
  (MessageComposer 780→800; rewritten line-budget note)

Validation
- desktop tests: 271 pass / 0 fail (was 269; +2 from new
  applyEditTagOverlay leak-prevention tests)
- src-tauri --lib: 374 pass / 0 fail
- pnpm typecheck ✓, pnpm check (biome + file-sizes) ✓
- pnpm build ✓ (vite resolves the @/...mjs import end-to-end)
- All pre-commit hooks green

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Three behavior-preserving cleanups on top of the Beth-approved
attachment-editable edit flow:

- applyEditOverlay.mjs → applyEditTagOverlay.mjs. The combined
  body+tags helper was exported and tested but had no production
  caller (both the renderer and the post-edit cache update only
  used the tag-overlay variant). Drop it before it solidifies as
  public surface; rename the module + test file to match what's
  actually exported. Adds an immutability test on the way through.

- MessageComposer pre-edit save/restore refs collapse into a single
  preEditSnapshotRef holding { content, pendingImeta }. The two
  refs were always paired (set together on edit-mode entry, cleared
  together on exit), so a single ref-of-record is more honest about
  the lifecycle.

- Body+tag construction on send and edit submit (formatImetaMediaLine
  loop + buildImetaTags) extract to a shared buildOutgoingMessage
  helper in imetaMediaMarkdown.ts. The send/edit difference around
  empty pending — undefined vs [] — is documented at the callsites
  (the edit path coerces with ?? [] to keep the wipe-attachments
  signal that the receiver overlay relies on).

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Clicking Edit on a message left DOM focus on the message row, so global
keybinds (Delete, navigation arrows) fired against the row instead of
the editor — Wes hit Delete to clear text and got navigation/weirdness.

The edit-load effect already called `richText.focus()`, but it ran
synchronously alongside Radix-style focus-restoration from the closing
context menu. Restoration won the race and put focus back on the row.

Fix: defer the focus call to the next animation frame so it lands
after the menu's restoration. `focusEnd` (which the existing alias
`focus` already pointed at) drops the caret at end of the loaded
content. Cleanup cancels the RAF if the effect re-runs (e.g. user
cancels before the frame fires) so we don't focus stale state.

Files
- desktop/src/features/messages/ui/MessageComposer.tsx

Validation
- Desktop tests: 274 pass / 0 fail
- Rust src-tauri --lib: 374 pass / 0 fail
- typecheck ✓, biome ✓, file-sizes ✓ (797 lines, budget 800)
- All pre-commit hooks green

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Radix's FocusScope on DropdownMenuContent schedules focus-restoration
via setTimeout(0), which fires AFTER the composer's RAF-deferred
focusEnd. The composer wins the first paint (visible flicker), then
Radix steals focus back to the EllipsisVertical trigger, leaving
global keybinds (Delete) firing on the message row.

Fix is scoped to the Edit menu item only: a per-mount ref flips true
in the Edit onClick before onEdit fires, and DropdownMenuContent's
onCloseAutoFocus reads it to call event.preventDefault() and reset.
Reply / Mark unread / Delete / Copy / reactions / Escape / outside-
click all leave the ref false, so default trigger-restoration runs
unchanged — keyboard a11y intact for every non-Edit path.

The MessageComposer.tsx RAF stays in place as belt-and-suspenders
for any future trigger UI that doesn't override.

Files
- desktop/src/features/messages/ui/MessageActionBar.tsx (+13/-1)

Validation
- Desktop tests: 274 pass / 0 fail
- Rust src-tauri --lib: 374 pass / 0 fail
- typecheck ✓, biome ✓, file-sizes ✓
- All pre-commit hooks green

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman changed the title fix(desktop): preserve attachments when editing messages feat(desktop): editable attachments + data-loss fix on message edit May 27, 2026
@wesbillman
Copy link
Copy Markdown
Collaborator Author

@codex please review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8f47c80c9f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +59 to +61
type: entry.m ?? "image/jpeg",
sha256: entry.x ?? "",
size: entry.size ?? 0,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid fabricating invalid imeta fields for legacy attachments

When editing an existing/cross-client media tag that lacks NIP-94 fields (valid NIP-92 can be just url plus another field), this projects it to sha256: "" and size: 0; buildImetaTags then submits x and size 0 on the kind:40003 edit. The relay rejects imeta unless x is a 64-char lowercase hex value and size is positive (crates/sprout-relay/src/handlers/imeta.rs), so merely saving a text edit on such a legacy media message fails instead of preserving the attachment state. Preserve the original valid fields or treat these entries as uneditable rather than synthesizing invalid values.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — addressed in 8a693907.

buildImetaTags now emits x only when sha256 is non-empty, and size only when it's a positive number. url and m stay unconditional (m has an image/jpeg fallback in imetaMediaFromTags, BlobDescriptor's type is always populated by the upload pipeline).

Scope clarification: this commit fixes the desktop-side structural emit. Successfully editing a legacy/cross-client sparse-imeta message also requires loosening the relay validator (crates/sprout-relay/src/handlers/imeta.rs requires url + m + x + size AND a local /media/... URL). That's tracked as a separate follow-up — a federation/cross-client edit needs the relay-side change to actually land. With this commit alone, the desktop client stops fabricating x / size 0 lines; failure mode shifts from "x must be 64-char hex" to a cleaner "missing required field" rejection.

Tests: 5 new cases in imetaMediaMarkdown.test.mjs covering sparse projection (imetaMediaFromTags with no x / no size), conditional emit (buildImetaTags omits x when sha256 empty, omits size when 0), and round-trip determinism (sparse imeta → projection → rebuild yields same tag).

Hygiene for legacy and cross-client imeta entries that arrive without
a sha256 or size. Previously `buildImetaTags` emitted unconditional
`x ` and `size 0` lines, which our own relay validator rejects on
the way back out, breaking edits that round-trip through such entries.

NIP-92 itself treats every imeta field except `url` as optional, so
dropping empty `x` / zero `size` is spec-clean. `url` and `m` stay
unconditional (`m` already has a fallback to `image/jpeg` in
`imetaMediaFromTags`); all other fields were already conditional.

The send path is unaffected for fresh uploads (sha256 + size always
populated by the upload pipeline). The fix only changes behaviour
for projections of legacy/cross-client tags through the edit flow.

Tests
- `imetaMediaFromTags: entry without x leaves sha256 empty`
- `imetaMediaFromTags: entry without size leaves size 0`
- `buildImetaTags: omits x line when sha256 is empty`
- `buildImetaTags: omits size line when size is 0`
- `round-trip: sparse imeta from legacy tags rebuilds without empty x/size`

Files
- desktop/src/features/messages/lib/imetaMediaMarkdown.ts
  (conditional x/size emission + JSDoc note)
- desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs
  (mirror inline buildImetaTags + 5 new tests)

Validation
- Desktop tests: 279 pass / 0 fail (was 274; +5)
- Rust src-tauri --lib: 374 pass / 0 fail
- typecheck ✓, biome ✓, file-sizes ✓
- All pre-commit hooks green

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman merged commit f1e25b2 into main May 27, 2026
15 checks passed
@wesbillman wesbillman deleted the fix/edit-deletes-attachment branch May 27, 2026 02:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant