Browse Source

Add event deletion functionality with NIP-09 support

master
limina1 4 months ago
parent
commit
a14421d0e8
  1. 126
      src/lib/components/publications/DeleteButton.svelte
  2. 117
      src/lib/services/deletion.ts
  3. 129
      tests/unit/deletion.test.ts

126
src/lib/components/publications/DeleteButton.svelte

@ -0,0 +1,126 @@
<script lang="ts">
import { Button, Modal } from "flowbite-svelte";
import { TrashBinOutline } from "flowbite-svelte-icons";
import { getContext } from "svelte";
import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { deleteEvent, canDeleteEvent } from "$lib/services/deletion";
import { userStore } from "$lib/stores/userStore";
let {
address,
leafEvent,
onDeleted,
}: {
address: string;
leafEvent: Promise<NDKEvent | null>;
onDeleted?: () => void;
} = $props();
const ndk: NDK = getContext("ndk");
let showDeleteModal = $state(false);
let isDeleting = $state(false);
let deleteError = $state<string | null>(null);
let resolvedEvent = $state<NDKEvent | null>(null);
// Check if user can delete this event
let canDelete = $derived(canDeleteEvent(resolvedEvent, ndk));
// Resolve the event promise
$effect(() => {
leafEvent.then(event => {
resolvedEvent = event;
});
});
async function handleDelete() {
if (!resolvedEvent) {
deleteError = "Event not found";
return;
}
isDeleting = true;
deleteError = null;
const result = await deleteEvent(
{
eventId: resolvedEvent.id,
eventAddress: address,
eventKind: resolvedEvent.kind,
reason: "Deleted by author",
onSuccess: (deletionEventId) => {
console.log(`[DeleteButton] Published deletion event: ${deletionEventId}`);
showDeleteModal = false;
onDeleted?.();
},
onError: (error) => {
console.error(`[DeleteButton] Deletion failed: ${error}`);
deleteError = error;
},
},
ndk,
);
isDeleting = false;
if (result.success) {
console.log(`[DeleteButton] Successfully deleted section: ${address}`);
}
}
function openDeleteModal() {
deleteError = null;
showDeleteModal = true;
}
</script>
{#if canDelete}
<Button
color="red"
size="xs"
class="single-line-button opacity-0 transition-opacity duration-200"
onclick={openDeleteModal}
>
<TrashBinOutline class="w-3 h-3 mr-1" />
Delete
</Button>
<Modal bind:open={showDeleteModal} size="sm" title="Delete Section">
<div class="text-center">
<TrashBinOutline class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" />
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
Are you sure you want to delete this section?
</h3>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">
This will publish a deletion request to all relays. Note that not all relays
may honor this request, and the content may remain visible on some relays.
</p>
{#if deleteError}
<p class="mb-5 text-sm text-red-500">{deleteError}</p>
{/if}
<div class="flex justify-center gap-4">
<Button
color="red"
disabled={isDeleting}
onclick={handleDelete}
>
{isDeleting ? "Deleting..." : "Yes, delete it"}
</Button>
<Button
color="alternative"
disabled={isDeleting}
onclick={() => (showDeleteModal = false)}
>
No, cancel
</Button>
</div>
</div>
</Modal>
{/if}
<style>
:global(.single-line-button) {
opacity: 0;
}
</style>

117
src/lib/services/deletion.ts

@ -0,0 +1,117 @@
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
export interface DeletionOptions {
eventId?: string;
eventAddress?: string;
eventKind?: number;
reason?: string;
onSuccess?: (deletionEventId: string) => void;
onError?: (error: string) => void;
}
export interface DeletionResult {
success: boolean;
deletionEventId?: string;
error?: string;
}
/**
* Deletes a Nostr event by publishing a kind 5 deletion request (NIP-09)
* @param options - Deletion options
* @param ndk - NDK instance
* @returns Promise resolving to deletion result
*/
export async function deleteEvent(
options: DeletionOptions,
ndk: NDK,
): Promise<DeletionResult> {
const { eventId, eventAddress, eventKind, reason = "", onSuccess, onError } = options;
if (!eventId && !eventAddress) {
const error = "Either eventId or eventAddress must be provided";
onError?.(error);
return { success: false, error };
}
if (!ndk?.activeUser) {
const error = "Please log in first";
onError?.(error);
return { success: false, error };
}
try {
// Create deletion event (kind 5)
const deletionEvent = new NDKEvent(ndk);
deletionEvent.kind = 5;
deletionEvent.created_at = Math.floor(Date.now() / 1000);
deletionEvent.content = reason;
deletionEvent.pubkey = ndk.activeUser.pubkey;
// Build tags based on what we have
const tags: string[][] = [];
if (eventId) {
// Add 'e' tag for event ID
tags.push(['e', eventId]);
}
if (eventAddress) {
// Add 'a' tag for replaceable event address
tags.push(['a', eventAddress]);
}
if (eventKind) {
// Add 'k' tag for event kind (recommended by NIP-09)
tags.push(['k', eventKind.toString()]);
}
deletionEvent.tags = tags;
// Sign the deletion event
await deletionEvent.sign();
// Publish to all available relays
const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
if (allRelayUrls.length === 0) {
throw new Error("No relays available in NDK pool");
}
const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk);
const publishedToRelays = await deletionEvent.publish(relaySet);
if (publishedToRelays.size > 0) {
console.log(
`[deletion.ts] Published deletion request to ${publishedToRelays.size} relays`,
);
const result = { success: true, deletionEventId: deletionEvent.id };
onSuccess?.(deletionEvent.id);
return result;
} else {
throw new Error("Failed to publish deletion request to any relays");
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
console.error(`[deletion.ts] Error deleting event: ${errorMessage}`);
onError?.(errorMessage);
return { success: false, error: errorMessage };
}
}
/**
* Checks if the current user has permission to delete an event
* @param event - The event to check
* @param ndk - NDK instance
* @returns True if the user can delete the event
*/
export function canDeleteEvent(event: NDKEvent | null, ndk: NDK): boolean {
if (!event || !ndk?.activeUser) {
return false;
}
// User can only delete their own events
return event.pubkey === ndk.activeUser.pubkey;
}

129
tests/unit/deletion.test.ts

@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { deleteEvent, canDeleteEvent } from '$lib/services/deletion';
import NDK, { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
describe('Deletion Service', () => {
let mockNdk: NDK;
let mockEvent: NDKEvent;
beforeEach(() => {
// Create mock NDK instance
mockNdk = {
activeUser: {
pubkey: 'test-pubkey-123',
},
pool: {
relays: new Map([
['wss://relay1.example.com', { url: 'wss://relay1.example.com' }],
['wss://relay2.example.com', { url: 'wss://relay2.example.com' }],
]),
},
} as unknown as NDK;
// Create mock event
mockEvent = {
id: 'event-id-123',
kind: 30041,
pubkey: 'test-pubkey-123',
tagAddress: () => '30041:test-pubkey-123:test-identifier',
} as unknown as NDKEvent;
});
describe('canDeleteEvent', () => {
it('should return true when user is the event author', () => {
const result = canDeleteEvent(mockEvent, mockNdk);
expect(result).toBe(true);
});
it('should return false when user is not the event author', () => {
const differentUserEvent = {
...mockEvent,
pubkey: 'different-pubkey-456',
} as unknown as NDKEvent;
const result = canDeleteEvent(differentUserEvent, mockNdk);
expect(result).toBe(false);
});
it('should return false when event is null', () => {
const result = canDeleteEvent(null, mockNdk);
expect(result).toBe(false);
});
it('should return false when ndk has no active user', () => {
const ndkWithoutUser = {
...mockNdk,
activeUser: undefined,
} as unknown as NDK;
const result = canDeleteEvent(mockEvent, ndkWithoutUser);
expect(result).toBe(false);
});
});
describe('deleteEvent', () => {
it('should return error when no eventId or eventAddress provided', async () => {
const result = await deleteEvent({}, mockNdk);
expect(result.success).toBe(false);
expect(result.error).toBe('Either eventId or eventAddress must be provided');
});
it('should return error when user is not logged in', async () => {
const ndkWithoutUser = {
...mockNdk,
activeUser: undefined,
} as unknown as NDK;
const result = await deleteEvent(
{ eventId: 'test-id' },
ndkWithoutUser
);
expect(result.success).toBe(false);
expect(result.error).toBe('Please log in first');
});
it('should create deletion event with correct tags', async () => {
const mockSign = vi.fn();
const mockPublish = vi.fn().mockResolvedValue(new Set(['wss://relay1.example.com']));
// Mock NDKEvent constructor
const MockNDKEvent = vi.fn().mockImplementation(function(this: any) {
this.kind = 0;
this.created_at = 0;
this.tags = [];
this.content = '';
this.pubkey = '';
this.sign = mockSign;
this.publish = mockPublish;
return this;
});
// Mock NDKRelaySet
const mockRelaySet = {} as NDKRelaySet;
vi.spyOn(NDKRelaySet, 'fromRelayUrls').mockReturnValue(mockRelaySet);
// Replace global NDKEvent temporarily
const originalNDKEvent = global.NDKEvent;
(global as any).NDKEvent = MockNDKEvent;
const result = await deleteEvent(
{
eventId: 'event-123',
eventAddress: '30041:pubkey:identifier',
eventKind: 30041,
reason: 'Test deletion',
},
mockNdk
);
// Restore original
(global as any).NDKEvent = originalNDKEvent;
expect(MockNDKEvent).toHaveBeenCalled();
expect(mockSign).toHaveBeenCalled();
expect(mockPublish).toHaveBeenCalled();
});
});
});
Loading…
Cancel
Save