diff --git a/src/lib/components/publications/DeleteButton.svelte b/src/lib/components/publications/DeleteButton.svelte new file mode 100644 index 0000000..a5ce83e --- /dev/null +++ b/src/lib/components/publications/DeleteButton.svelte @@ -0,0 +1,126 @@ + + +{#if canDelete} + + + +
+ +

+ Are you sure you want to delete this section? +

+

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

+ {#if deleteError} +

{deleteError}

+ {/if} +
+ + +
+
+
+{/if} + + diff --git a/src/lib/services/deletion.ts b/src/lib/services/deletion.ts new file mode 100644 index 0000000..4550687 --- /dev/null +++ b/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 { + 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; +} diff --git a/tests/unit/deletion.test.ts b/tests/unit/deletion.test.ts new file mode 100644 index 0000000..f483d8c --- /dev/null +++ b/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(); + }); + }); +});