3 changed files with 372 additions and 0 deletions
@ -0,0 +1,126 @@
@@ -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 @@
@@ -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 @@
@@ -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