mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
31
desktop/src/features/channels/isDmNotifiableKind.test.mjs
Normal file
31
desktop/src/features/channels/isDmNotifiableKind.test.mjs
Normal 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");
|
||||
});
|
||||
10
desktop/src/features/channels/isDmNotifiableKind.ts
Normal file
10
desktop/src/features/channels/isDmNotifiableKind.ts
Normal 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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user