Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions crates/sprout-sdk/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down Expand Up @@ -1419,9 +1426,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, "");
}
Expand Down Expand Up @@ -1723,8 +1732,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);
}

Expand Down
10 changes: 8 additions & 2 deletions desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
13 changes: 10 additions & 3 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,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()])?,

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).

tag(vec!["e", &target_event_id.to_hex()])?,
];
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
}

Expand Down
31 changes: 31 additions & 0 deletions desktop/src/features/channels/isDmNotifiableKind.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
10 changes: 10 additions & 0 deletions desktop/src/features/channels/isDmNotifiableKind.ts
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 7 additions & 0 deletions desktop/src/features/channels/useLiveChannelUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 8 additions & 2 deletions desktop/src/features/forum/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
})
Expand Down
5 changes: 4 additions & 1 deletion desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
102 changes: 102 additions & 0 deletions desktop/src/features/messages/lib/formatTimelineMessages.test.mjs
Original file line number Diff line number Diff line change
@@ -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",
);
});
7 changes: 6 additions & 1 deletion desktop/src/features/messages/lib/formatTimelineMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down
7 changes: 5 additions & 2 deletions desktop/src/shared/api/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,8 +783,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(
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/shared/constants/kinds.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -47,6 +50,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

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.

...CHANNEL_MESSAGE_EVENT_KINDS,
40001, // legacy: pre-migration stream messages
KIND_STREAM_MESSAGE_EDIT, // 40003 — message edits
Expand Down
10 changes: 9 additions & 1 deletion desktop/test-loader-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ const srcRoot = path.resolve(

export function resolve(specifier, context, nextResolve) {
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
Expand Down
Loading