3 changed files with 372 additions and 0 deletions
@ -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> |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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…
Reference in new issue