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

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Bart <bart@sprout.local>
This commit is contained in:
Taylor Ho
2026-06-09 23:02:13 -07:00
committed by GitHub
parent e08937cdde
commit 3e56331e9c
21 changed files with 302 additions and 27 deletions

View File

@@ -353,9 +353,16 @@ pub fn build_delete_message(
// ── Builder 7: build_delete_compat ───────────────────────────────────────────
/// Build a NIP-09 compatible deletion event (kind 5).
pub fn build_delete_compat(target_event_id: nostr::EventId) -> Result<EventBuilder, SdkError> {
let tags = vec![tag(&["e", &target_event_id.to_hex()])?];
/// Build a NIP-09 deletion event (kind 5). The `h` tag is non-standard for
/// NIP-09 but is required so channel-scoped subscriptions observe the delete.
pub fn build_delete_compat(
channel_id: Uuid,
target_event_id: nostr::EventId,
) -> Result<EventBuilder, SdkError> {
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["e", &target_event_id.to_hex()])?,
];
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
}
@@ -1440,9 +1447,11 @@ mod tests {
#[test]
fn delete_compat_happy_path() {
let cid = uuid();
let eid = event_id();
let ev = sign(build_delete_compat(eid).unwrap());
let ev = sign(build_delete_compat(cid, eid).unwrap());
assert_eq!(ev.kind.as_u16(), 5);
assert!(has_tag(&ev, "h", &cid.to_string()));
assert!(has_tag(&ev, "e", &eid.to_hex()));
assert_eq!(ev.content, "");
}
@@ -1772,8 +1781,8 @@ mod tests {
#[test]
fn extract_channel_id_absent() {
let eid = event_id();
let ev = sign(build_delete_compat(eid).unwrap());
// build_note (kind 1) is a global text note — no h tag.
let ev = sign(build_note("hello", None).unwrap());
assert_eq!(extract_channel_id(&ev), None);
}

View File

@@ -422,9 +422,15 @@ pub async fn edit_message(
}
#[tauri::command]
pub async fn delete_message(event_id: String, state: State<'_, AppState>) -> Result<(), String> {
pub async fn delete_message(
channel_id: String,
event_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
.map_err(|_| format!("invalid channel UUID: {channel_id}"))?;
let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?;
let builder = events::build_delete_compat(target_eid)?;
let builder = events::build_delete_compat(channel_uuid, target_eid)?;
submit_event(builder, &state).await?;
Ok(())
}

View File

@@ -341,9 +341,16 @@ pub fn build_message_edit(
Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags))
}
/// Kind 5 — NIP-09 deletion (messages).
pub fn build_delete_compat(target_event_id: EventId) -> Result<EventBuilder, String> {
let tags = vec![tag(vec!["e", &target_event_id.to_hex()])?];
/// Kind 5 — NIP-09 deletion. The `h` tag is non-standard for NIP-09 but is
/// required so channel-scoped subscriptions observe the delete.
pub fn build_delete_compat(
channel_id: Uuid,
target_event_id: EventId,
) -> Result<EventBuilder, String> {
let tags = vec![
tag(vec!["h", &channel_id.to_string()])?,
tag(vec!["e", &target_event_id.to_hex()])?,
];
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
}

View File

@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isDmNotifiableKind } from "./isDmNotifiableKind.ts";
// Regression guard for the phantom-DM-notification bug: when kind:5 deletes
// gained an `h` tag, they started matching the live DM subscription. Without
// this gate, deleting a DM message fires a "New message" toast on the other
// side. Reactions (7), Sprout-native deletes (9005), edits (40003), diffs
// (40008), and system messages (40099) hit the same subscription and must
// also be filtered.
test("human-visible message kinds fire DM notifications", () => {
assert.equal(isDmNotifiableKind(9), true, "kind:9 stream message");
assert.equal(isDmNotifiableKind(40002), true, "kind:40002 stream message v2");
assert.equal(isDmNotifiableKind(45001), true, "kind:45001 forum post");
assert.equal(isDmNotifiableKind(45003), true, "kind:45003 forum comment");
});
test("non-message kinds do NOT fire DM notifications", () => {
assert.equal(isDmNotifiableKind(5), false, "kind:5 NIP-09 deletion");
assert.equal(isDmNotifiableKind(7), false, "kind:7 reaction");
assert.equal(
isDmNotifiableKind(9005),
false,
"kind:9005 Sprout-native delete",
);
assert.equal(isDmNotifiableKind(40003), false, "kind:40003 message edit");
assert.equal(isDmNotifiableKind(40008), false, "kind:40008 message diff");
assert.equal(isDmNotifiableKind(40099), false, "kind:40099 system message");
});

View File

@@ -0,0 +1,10 @@
import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds";
const DM_NOTIFIABLE_KINDS = new Set<number>(CHANNEL_MESSAGE_EVENT_KINDS);
// DM OS-notifications gate. The DM subscription matches every `h`-tagged
// event in the channel (kind:5/7/9005/edits/etc.), so we must filter to
// human-visible message kinds before firing a toast.
export function isDmNotifiableKind(kind: number): boolean {
return DM_NOTIFIABLE_KINDS.has(kind);
}

View File

@@ -17,6 +17,8 @@ import {
} from "@/shared/constants/kinds";
import type { Channel, RelayEvent } from "@/shared/api/types";
import { isDmNotifiableKind } from "./isDmNotifiableKind";
export type UseLiveChannelUpdatesOptions = {
currentPubkey?: string;
onDmMessage?: (event: RelayEvent, channel: Channel) => void;
@@ -108,6 +110,11 @@ export function useLiveChannelUpdates(
);
const handleDmEvent = React.useEffectEvent((event: RelayEvent) => {
// Only human-visible message kinds should fire DM notifications.
if (!isDmNotifiableKind(event.kind)) {
return;
}
// Suppress backlog events that predate our subscription — these are
// historical replays, not live messages.
if (event.created_at < dmSubscriptionStartedAtRef.current) {

View File

@@ -83,7 +83,10 @@ export function useDeleteForumPostMutation(channel: Channel | null) {
return useMutation({
mutationFn: async ({ eventId }: { eventId: string }) => {
await deleteMessage(eventId);
if (!channel) {
throw new Error("No channel selected.");
}
await deleteMessage(channel.id, eventId);
},
onSuccess: () => {
if (channel) {
@@ -103,7 +106,10 @@ export function useDeleteForumReplyMutation(
return useMutation({
mutationFn: async ({ eventId }: { eventId: string }) => {
await deleteMessage(eventId);
if (!channel) {
throw new Error("No channel selected.");
}
await deleteMessage(channel.id, eventId);
},
onSuccess: () => {
if (channel) {

View File

@@ -414,9 +414,13 @@ export function HomeView({
if (!selectedItem || !canDelete) {
return;
}
const channelId = selectedItem.item.channelId;
if (!channelId) {
return;
}
setIsDeletingMessage(true);
void deleteMessage(selectedItem.id)
void deleteMessage(channelId, selectedItem.id)
.then(() => {
onRefresh();
})

View File

@@ -462,7 +462,10 @@ export function useDeleteMessageMutation(channel: Channel | null) {
return useMutation<void, Error, { eventId: string }>({
mutationFn: async ({ eventId }) => {
await deleteMessage(eventId);
if (!channel) {
throw new Error("No channel selected.");
}
await deleteMessage(channel.id, eventId);
},
onSuccess: (_data, { eventId }) => {
if (!channel) return;

View File

@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatTimelineMessages } from "./formatTimelineMessages.ts";
const HEX64_A =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const HEX64_B =
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const PUBKEY_A =
"1111111111111111111111111111111111111111111111111111111111111111";
const PUBKEY_B =
"2222222222222222222222222222222222222222222222222222222222222222";
const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
function streamMessage(overrides = {}) {
return {
id: HEX64_A,
pubkey: PUBKEY_A,
kind: 9,
created_at: 1_700_000_000,
content: "hello world",
tags: [["h", CHANNEL_ID]],
sig: "sig",
...overrides,
};
}
function deletionEvent(kind, targetId, overrides = {}) {
return {
id: HEX64_B,
pubkey: PUBKEY_B,
kind,
created_at: 1_700_000_001,
content: "",
tags: [
["h", CHANNEL_ID],
["e", targetId],
],
sig: "sig",
...overrides,
};
}
test("kind:5 (NIP-09) deletion hides the target message", () => {
const events = [streamMessage(), deletionEvent(5, HEX64_A)];
const out = formatTimelineMessages(events, null, undefined, null);
assert.equal(
out.length,
0,
"the kind:9 message should be filtered out by the kind:5 deletion",
);
});
test("kind:9005 (NIP-29 / Sprout-native) deletion hides the target message", () => {
// This is the actual reported bug: agents emit kind:9005 deletes via the
// CLI. Without recognizing 9005 as a deletion marker the message stayed
// rendered until manual refresh.
const events = [streamMessage(), deletionEvent(9005, HEX64_A)];
const out = formatTimelineMessages(events, null, undefined, null);
assert.equal(
out.length,
0,
"the kind:9 message should be filtered out by the kind:9005 deletion",
);
});
test("non-deletion event kinds do NOT hide the target message", () => {
// Sanity check: only kind:5 and kind:9005 are treated as deletion markers.
// A kind:7 reaction with the same `e` tag must not erase the target.
const reaction = {
id: HEX64_B,
pubkey: PUBKEY_B,
kind: 7,
created_at: 1_700_000_001,
content: "+",
tags: [
["h", CHANNEL_ID],
["e", HEX64_A],
],
sig: "sig",
};
const events = [streamMessage(), reaction];
const out = formatTimelineMessages(events, null, undefined, null);
assert.equal(out.length, 1, "the kind:9 message should still be visible");
});
test("deletion target with non-hex `e` tag value is ignored", () => {
const bogusDeletion = deletionEvent(9005, HEX64_A, {
tags: [
["h", CHANNEL_ID],
["e", "not-hex"],
],
});
const events = [streamMessage(), bogusDeletion];
const out = formatTimelineMessages(events, null, undefined, null);
assert.equal(
out.length,
1,
"malformed deletion tag should not match anything",
);
});

View File

@@ -22,6 +22,7 @@ import {
KIND_JOB_REQUEST,
KIND_JOB_RESULT,
KIND_DELETION,
KIND_NIP29_DELETE_EVENT,
KIND_REACTION,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
@@ -150,7 +151,11 @@ export function formatTimelineMessages(
}
const deletedEventIds = new Set<string>();
for (const event of events) {
if (event.kind !== KIND_DELETION) {
// Both kind:5 and kind:9005 are deletion markers; mirror the relay.
if (
event.kind !== KIND_DELETION &&
event.kind !== KIND_NIP29_DELETE_EVENT
) {
continue;
}

View File

@@ -785,8 +785,11 @@ export async function editMessage(
});
}
export async function deleteMessage(eventId: string): Promise<void> {
await invokeTauri("delete_message", { eventId });
export async function deleteMessage(
channelId: string,
eventId: string,
): Promise<void> {
await invokeTauri("delete_message", { channelId, eventId });
}
export async function addReaction(

View File

@@ -1,6 +1,9 @@
export const KIND_DELETION = 5;
export const KIND_REACTION = 7;
export const KIND_STREAM_MESSAGE = 9;
// Sprout-native deletion. The relay soft-deletes the target and emits a
// kind:40099 system message. Treated as a deletion marker alongside kind:5.
export const KIND_NIP29_DELETE_EVENT = 9005;
export const KIND_STREAM_MESSAGE_V2 = 40002;
export const KIND_STREAM_MESSAGE_EDIT = 40003;
export const KIND_STREAM_MESSAGE_DIFF = 40008;
@@ -50,6 +53,7 @@ export const HOME_MENTION_EVENT_KINDS = [...CHANNEL_MESSAGE_EVENT_KINDS];
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
...CHANNEL_MESSAGE_EVENT_KINDS,
40001, // legacy: pre-migration stream messages
KIND_STREAM_MESSAGE_EDIT, // 40003 — message edits

View File

@@ -17,7 +17,15 @@ export function resolve(specifier, context, nextResolve) {
return nextResolve(resolved, context);
}
if (specifier.startsWith("@/")) {
const resolved = `${srcRoot}/${specifier.slice(2)}.ts`;
const stripped = specifier.slice(2);
// Preserve explicit extensions (.mjs, .js, .json, .ts, etc.). The bundler
// tolerates extensionless `@/` imports for .ts files; node's ESM resolver
// does not, so we only synthesize `.ts` when the specifier has no
// extension. Otherwise paths like `@/.../foo.mjs` would be coerced into
// `foo.mjs.ts` and fail to resolve.
const resolved = path.extname(stripped)
? `${srcRoot}/${stripped}`
: `${srcRoot}/${stripped}.ts`;
return nextResolve(resolved, context);
}
// Resolve extensionless relative TS imports (e.g. `./parseImeta`) — the app's

View File

@@ -214,6 +214,19 @@ final channelCanvasProvider = FutureProvider.family<ChannelCanvas, String>((
);
});
/// Channel-scoped kind:5 deletion tags. The `h` tag lets channel-scoped
/// subscriptions observe the delete; the `e` tag points at the target event.
@visibleForTesting
List<List<String>> buildDeleteMessageTags({
required String channelId,
required String eventId,
}) {
return [
['h', channelId],
['e', eventId],
];
}
class ChannelActions {
final Ref _ref;
final RelaySessionNotifier _session;
@@ -438,13 +451,14 @@ class ChannelActions {
);
}
Future<void> deleteMessage(String eventId) async {
Future<void> deleteMessage({
required String channelId,
required String eventId,
}) async {
await _signedEventRelay.submit(
kind: EventKind.deletion,
content: '',
tags: [
['e', eventId],
],
tags: buildDeleteMessageTags(channelId: channelId, eventId: eventId),
);
}

View File

@@ -160,6 +160,7 @@ void showMessageActions({
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
);
},
@@ -242,6 +243,7 @@ void _showEditSheet({
void _confirmDelete({
required BuildContext context,
required WidgetRef ref,
required String channelId,
required String messageId,
}) {
showDialog<void>(
@@ -257,7 +259,9 @@ void _confirmDelete({
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop();
ref.read(channelActionsProvider).deleteMessage(messageId);
ref
.read(channelActionsProvider)
.deleteMessage(channelId: channelId, eventId: messageId);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,

View File

@@ -211,10 +211,14 @@ List<TimelineMessage> formatTimeline(
List<NostrEvent> events, {
String? currentPubkey,
}) {
// 1. Collect deletion targets.
// 1. Collect deletion targets. Both kind:5 (NIP-09) and kind:9005
// (Sprout-native) are deletion markers; mirror desktop's behavior.
final deletedIds = <String>{};
for (final event in events) {
if (event.kind != EventKind.deletion) continue;
if (event.kind != EventKind.deletion &&
event.kind != EventKind.nip29DeleteEvent) {
continue;
}
for (final tag in event.tags) {
if (tag.length >= 2 && tag[0] == 'e') {
deletedIds.add(tag[1]);

View File

@@ -132,7 +132,7 @@ Future<void> deleteForumEvent(
String? rootEventId,
}) async {
final actions = ref.read(channelActionsProvider);
await actions.deleteMessage(eventId);
await actions.deleteMessage(channelId: channelId, eventId: eventId);
ref.invalidate(forumPostsProvider(channelId));
if (rootEventId != null) {
ref.invalidate(

View File

@@ -11,6 +11,7 @@ abstract final class EventKind {
static const deletion = 5;
static const reaction = 7;
static const streamMessage = 9;
static const nip29DeleteEvent = 9005;
static const presenceUpdate = 20001;
static const typingIndicator = 20002;
static const auth = 22242;
@@ -37,6 +38,7 @@ abstract final class EventKind {
static const channelEventKinds = [
deletion, // 5
reaction, // 7
nip29DeleteEvent, // 9005 — Sprout-native deletion
...channelMessageEventKinds,
40001, // legacy pre-migration stream messages
streamMessageEdit, // 40003

View File

@@ -84,4 +84,18 @@ void main() {
expect(details.ttlDeadline, isNotNull);
expect(details.ttlDeadline!.isUtc, isTrue);
});
group('buildDeleteMessageTags', () {
test('emits both channel h tag and target e tag', () {
final tags = buildDeleteMessageTags(
channelId: 'c8c629ae-d35c-44fa-bc39-f6c1816756cc',
eventId: 'abc123',
);
expect(tags, [
['h', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'],
['e', 'abc123'],
]);
});
});
}

View File

@@ -88,6 +88,23 @@ NostrEvent _deletion({
sig: '',
);
NostrEvent _nip29Deletion({
required String id,
required List<String> targets,
int createdAt = 2000,
}) => NostrEvent(
id: id,
pubkey: 'alice',
createdAt: createdAt,
kind: EventKind.nip29DeleteEvent,
tags: [
['h', 'ch1'],
for (final t in targets) ['e', t],
],
content: '',
sig: '',
);
NostrEvent _edit({
required String id,
required String targetId,
@@ -311,6 +328,21 @@ void main() {
expect(result[0].content, 'keep');
});
test('filters messages deleted via kind:9005 (Sprout-native)', () {
// Agents emit kind:9005 deletes via the CLI. Mobile must mirror desktop
// and treat 9005 as a deletion marker, otherwise agent-deleted messages
// stay rendered until manual refresh.
final events = [
_textMsg(id: 'a', content: 'keep'),
_textMsg(id: 'b', content: 'delete', createdAt: 1100),
_nip29Deletion(id: 'd1', targets: ['b']),
];
final result = formatTimeline(events);
expect(result, hasLength(1));
expect(result[0].content, 'keep');
});
test('applies edits', () {
final events = [
_textMsg(id: 'a', content: 'original'),