Skip to content

fix(delete): make agent-deleted messages disappear from desktop UI immediately#918

Merged
tellaho merged 7 commits into
mainfrom
tho/fix/delete-message-cache
Jun 10, 2026
Merged

fix(delete): make agent-deleted messages disappear from desktop UI immediately#918
tellaho merged 7 commits into
mainfrom
tho/fix/delete-message-cache

Conversation

@tellaho

@tellaho tellaho commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

Agent-deleted messages were not disappearing from the desktop UI until manual refresh. Two issues, two paths:

  1. Desktop user delete (kind:5)build_delete_compat produced kind:5 NIP-09 events with no h tag. Desktop's channel subscription filters by #h, so the event never matched and never reached the cache.
  2. Agent delete (kind:9005) — agents delete via the CLI (sprout messages deletecmd_delete_messagebuild_delete_message), which emits kind:9005 (NIP-29 / Sprout-native). Desktop only subscribed to kind:5 and formatTimelineMessages only walked kind:5, so 9005 events never registered as deletion markers.

Refresh "fixed" it because some non-channel-scoped fetch eventually pulled the deletion in.

Fix

Two complementary commits, kept together because they're the same UX bug from two angles:

c7e5f3ed — tag kind:5 deletions with channel id

Add ["h", channelId] to build_delete_compat's tags, mirroring build_message. Thread channel_id through the Tauri delete_message command and update all four desktop callers (forum hooks, messages hook, HomeView, tauri.ts shim).

dbb37662 — recognize kind:9005 in desktop timeline

  • Add KIND_NIP29_DELETE_EVENT (9005) to CHANNEL_EVENT_KINDS so the channel subscription and history fetch pick it up.
  • Teach formatTimelineMessages to walk kind:5 and kind:9005 alike when building deletedEventIds. Tag shape is identical (target id in the e tag), so getDeletionTargets works unmodified.

Why not collapse agents back to kind:5

The relay handles 9005 specifically — soft-deletes the target and emits a kind:40099 message_deleted system message used as an audit trail. kind:5's relay handler emits no 40099. Switching agents to kind:5 would silently drop the audit trail. Two kinds, two jobs.

Tests

New desktop/src/features/messages/lib/formatTimelineMessages.test.mjs covers:

  • ✓ kind:5 (NIP-09) deletion hides the target message
  • ✓ kind:9005 (NIP-29 / Sprout-native) deletion hides the target message
  • ✓ non-deletion event kinds with e tags do NOT hide the target
  • ✓ deletion target with non-hex e tag value is ignored

Also fixed a small bug in desktop/test-loader-hooks.mjs that unconditionally appended .ts to @/ specifiers, breaking .ts → .mjs imports.

All 583 desktop tests pass; pnpm typecheck, pnpm lint, and cargo check -p sprout-sdk -p sprout-cli all clean.

Verification plan

  1. From the desktop app, delete one of your own messages → should disappear immediately (kind:5 path).
  2. From an agent, run sprout messages delete --event <id> against a message visible in the desktop app → should disappear immediately without refresh (kind:9005 path).

Reported by @tho. Diagnosis discussed at sprout://message?channel=36411e44-0e2d-4cfe-bd6e-567eb169db9f&id=22ac0a10e5643bc30589e60276abf2a6a0cc8515f9c271b4f79f016d3c85be39

@tellaho tellaho requested a review from a team as a code owner June 9, 2026 05:07
@tellaho tellaho marked this pull request as draft June 9, 2026 05:43
Bart added 2 commits June 8, 2026 22:47
Sprout's channel subscription filter is { kinds: [...], "#h": [channelId] }
— Nostr filters AND across fields, so a kind:5 NIP-09 deletion that lacks
an h tag never matches the subscription and clients only see the deletion
once a non-channel-scoped fetch backfills it. The desktop UI then keeps
rendering the deleted message until manual refresh.

Add the channel id as an h tag on kind:5 deletions in both the SDK builder
(build_delete_compat) and the desktop Tauri builder (events::build_delete_compat).
Thread channel_id through the delete_message Tauri command and all four
desktop callers (forum hooks, messages hook, HomeView, tauri.ts shim).

Extra tags are safe per NIP-09; the relay derives the deletion target
from the e tag and validates author permissions independently of the h
tag. Mirrors the pattern already used by build_message (kind:9).

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Agent-deleted messages were not disappearing from the desktop UI until
manual refresh. Agents delete via the CLI (sprout messages delete →
build_delete_message), which emits kind:9005 (NIP-29 / Sprout-native).
Desktop only subscribed to and walked kind:5 NIP-09 deletions, so 9005
events never landed in cache and never registered as deletion markers
in formatTimelineMessages.deletedEventIds.

Two narrow desktop changes:

1. Add KIND_NIP29_DELETE_EVENT (9005) to CHANNEL_EVENT_KINDS so the
   channel subscription and history fetch both pick it up.
2. Treat kind:9005 the same as kind:5 in formatTimelineMessages —
   walk both kinds, extract target ids from e tags. Tag shape is
   identical between the two builders so getDeletionTargets works
   unmodified.

Why not collapse agents back to kind:5: the relay handles 9005
specifically — it soft-deletes the target AND emits a kind:40099
system message used as an audit trail. kind:5's relay handler emits
no 40099. Switching agents to kind:5 would silently drop the audit
trail. Two kinds, two jobs.

Tests: new formatTimelineMessages.test.mjs covers kind:5 and kind:9005
deletion-marker behavior plus negative cases (non-deletion kinds with
e tags, malformed targets).

Loader fix: test-loader-hooks.mjs unconditionally appended .ts to @/
specifiers, so any .ts file that imports a sibling .mjs (like
formatTimelineMessages → applyEditTagOverlay.mjs) couldn't be loaded
in tests. Preserve explicit extensions; only synthesize .ts when the
specifier has no extension.

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho tellaho force-pushed the tho/fix/delete-message-cache branch from dbb3766 to 4da35ee Compare June 9, 2026 05:47
@tellaho tellaho marked this pull request as ready for review June 9, 2026 17:12
target_event_id: EventId,
) -> Result<EventBuilder, String> {
let tags = vec![
tag(vec!["h", &channel_id.to_string()])?,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This h tag makes kind:5 deletes visible to the live channel/DM subscription, which fixes the cache path but also creates a new notification path for deletions.

useLiveChannelUpdates.handleDmEvent currently calls onDmMessage for any external DM event with an h tag, without checking event.kind (desktop/src/features/channels/useLiveChannelUpdates.ts:110-140). AppShell then renders empty-content events as a “New message” desktop notification (desktop/src/app/AppShell.tsx:241-248). So if another participant deletes a message in an inactive DM, this can produce a bogus “New message” notification.

Please gate DM notifications to CHANNEL_MESSAGE_EVENT_KINDS / UNREAD_TRIGGER_KINDS (or explicitly ignore deletion kinds) before tracking/sending the DM notification.

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 — fixed in 5f9a426.

handleDmEvent now short-circuits on non-message kinds via a new isDmNotifiableKind() helper that wraps CHANNEL_MESSAGE_EVENT_KINDS (mirrors the UNREAD_TRIGGER_KINDS pattern in the same file). Gate runs before any side-effecting work in the handler, so kind:5 / 7 / 9005 / 40003 / 40008 / 40099 no longer flow through to onDmMessageAppShell.handleDmNotification.

Added a regression test covering kind:9 / 40002 / 45001 / 45003 (notify) vs kind:5 / 7 / 9005 / 40003 / 40008 / 40099 (don't notify).

export const CHANNEL_EVENT_KINDS = [
KIND_DELETION, // 5 — NIP-09 event deletions
KIND_REACTION, // 7 — NIP-25 reactions
KIND_NIP29_DELETE_EVENT, // 9005 — NIP-29 / Sprout-native deletions

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This fixes desktop’s subscription set for kind:9005, but the mobile mirror is now out of sync and will still show agent/CLI-deleted messages.

mobile/lib/shared/relay/nostr_models.dart says EventKind.channelEventKinds matches desktop, but it still omits 9005. mobile/lib/features/channels/timeline_message.dart:214-223 also only treats kind:5 as a deletion marker. Result: desktop hides Sprout-native deletes immediately, while mobile neither subscribes to nor filters the kind:9005 delete event.

Either include the mobile kind/formatter update in this PR, or explicitly scope this PR as desktop-only and track the mobile fix immediately.

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.

Bundled into this PR — mobile is now in sync.

  • 0f2bccd — added EventKind.nip29DeleteEvent (9005) to mobile/lib/shared/relay/nostr_models.dart and included it in channelEventKinds. Taught formatTimeline's deletion walker (mobile/lib/features/channels/timeline_message.dart) to accept both kind:5 and kind:9005 with identical e-tag extraction. Mirrors a kind:9005 timeline test against the existing kind:5 case.
  • cca99eb — also tagged the emit side: mobile/lib/features/channels/channel_management_provider.dart's deleteMessage now emits kind:5 with the channel h tag alongside e, matching desktop's build_delete_compat. So deletes originating on mobile are observable to other clients' h-scoped channel subs without needing a non-channel-scoped backfill.
  • Mobile DM-notif audit: scanned mobile/lib — no equivalent of desktop's handleDmEvent → AppShell.handleDmNotification OS-notification flow exists today, so there's no latent kind-gating bug on the mobile side to fix in parallel.

flutter test 382/382 ✅ • flutter analyze clean.

Bart and others added 5 commits June 9, 2026 11:07
Address PR #918 review feedback (wesbillman / Pinky).

The h-tag fix on kind:5 deletes made them visible to the live DM
subscription ({ kinds, "#h":[dmChannelId] }). handleDmEvent never
gated by kind, so deleting a DM message could fire a phantom
"New message" desktop notification on the receiving side. Reactions
(7), kind:9005, edits (40003), diffs (40008), and system messages
(40099) all flow through the same subscription post-fix and have
the same latent bug.

Add isDmNotifiableKind() — wraps CHANNEL_MESSAGE_EVENT_KINDS, the
same set already used by UNREAD_TRIGGER_KINDS in the same file for
the same semantic question ("is this a real message?"). handleDmEvent
short-circuits at the top when the kind isn't notifiable.

Also trim the over-explanatory comments on build_delete_compat
(both SDK and src-tauri copies), KIND_NIP29_DELETE_EVENT, and the
formatTimelineMessages deletion-walker block per Pinky's nit.

Tests: regression guard for the DM kind gate covering kind:9 / 40002
/ 45001 / 45003 (notify) and kind:5 / 7 / 9005 / 40003 / 40008 / 40099
(don't notify).

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Bring mobile to parity with desktop's kind:9005 handling. Agents
emit kind:9005 deletes via the CLI; without this, mobile keeps
rendering agent-deleted messages until manual refresh.

Two narrow mobile changes:

1. Add EventKind.nip29DeleteEvent (9005) to nostr_models.dart and
   include it in channelEventKinds so the channel subscription and
   history fetch both pick it up.
2. Teach formatTimeline's deletion walker to treat kind:9005 the same
   as kind:5 — collect e-tag targets from both. Tag shape is identical
   between the two builders so the walker is unmodified beyond the
   kind check.

No mobile DM-notification path was found that mirrors desktop's
handleDmEvent → AppShell.handleDmNotification flow, so no DM kind-gating
fix is needed on mobile today.

Tests: timeline_message_test.dart adds a kind:9005 deletion case
mirroring the existing kind:5 test.

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Wraps a long assert.equal() to satisfy biome's print-width rule. CI on
the merge commit was failing format-check; locally on the branch alone
the formatter was happy. Pure formatter delta, no logic change.

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Mobile's deleteMessage emitted a kind:5 deletion with only an `e` tag,
so other clients' h-scoped channel subscriptions never observed the
delete until a non-channel-scoped fetch backfilled it. Mirror desktop's
build_delete_compat: emit both `['h', channelId]` and `['e', eventId]`.

Threads channelId through ChannelActions.deleteMessage and its callers.
Tag construction is extracted to buildDeleteMessageTags for testing.

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Apply dart format to satisfy CI's --set-exit-if-changed gate.
Pure formatting; no behavior change.

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho tellaho requested a review from wesbillman June 9, 2026 21:11
@tellaho

tellaho commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

@wesbillman alright, some due diligence by testing on iOS simulator. been needing a reason to get the build running!

BEFORE AFTER
Screen Recording 2026-06-09 at 10 48 29 PM - current Screen Recording 2026-06-09 at 10 58 34 PM - fix
Ned replies he deleted it; but it's still there Ned visibly deletes it; then confirms in his reply

@tellaho tellaho merged commit 3e56331 into main Jun 10, 2026
16 checks passed
@tellaho tellaho deleted the tho/fix/delete-message-cache branch June 10, 2026 06:02
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.

3 participants