From 3b8c82514ad34b5c9722e0e067534b0e360a5f21 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 16 Nov 2025 22:39:38 +0100 Subject: [PATCH] Add delete task functionality for task authors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added ability for task authors to delete their own tasks from the expanded view in the task feed. **Features:** - Delete button visible only to task author in expanded task view - Confirmation dialog with destructive styling - Publishes NIP-09 deletion event (kind 5) with 'a' tag referencing the task's event address (kind:pubkey:d-tag format) - Real-time deletion handling via FeedService routing - Optimistic local state update for immediate UI feedback **Implementation:** - Added deleteTask() method to ScheduledEventService - Added handleTaskDeletion() for processing incoming deletion events - Updated FeedService to route kind 31922 deletions to ScheduledEventService - Added delete button and dialog flow to ScheduledEventCard component - Integrated with existing confirmation dialog pattern **Permissions:** - Only task authors can delete tasks (enforced by isAuthor check) - NIP-09 validation: relays only accept deletion from event author - Pubkey verification in handleTaskDeletion() **Testing:** - Created tasks and verified delete button appears for author only - Confirmed deletion removes task from UI immediately - Verified deletion persists after refresh - Tested with multiple users - others cannot delete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../nostr-feed/components/NostrFeed.vue | 11 ++ .../components/ScheduledEventCard.vue | 39 ++++++- .../composables/useScheduledEvents.ts | 20 ++++ .../nostr-feed/services/FeedService.ts | 11 ++ .../services/ScheduledEventService.ts | 106 ++++++++++++++++++ 5 files changed, 185 insertions(+), 2 deletions(-) diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index b3dfc22..529b7d7 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -107,6 +107,7 @@ const { startTask, completeEvent, unclaimTask, + deleteTask, allCompletions } = useScheduledEvents() @@ -301,6 +302,15 @@ async function onUnclaimTask(event: ScheduledEvent, occurrence?: string) { } } +async function onDeleteTask(event: ScheduledEvent) { + console.log('🗑️ NostrFeed: Deleting task:', event.title) + try { + await deleteTask(event) + } catch (error) { + console.error('❌ Failed to delete task:', error) + } +} + // Handle collapse toggle with cascading behavior function onToggleCollapse(postId: string) { const newCollapsed = new Set(collapsedPosts.value) @@ -555,6 +565,7 @@ function cancelDelete() { @start-task="onStartTask" @complete-task="onCompleteTask" @unclaim-task="onUnclaimTask" + @delete-task="onDeleteTask" />
diff --git a/src/modules/nostr-feed/components/ScheduledEventCard.vue b/src/modules/nostr-feed/components/ScheduledEventCard.vue index 25d312a..46c188e 100644 --- a/src/modules/nostr-feed/components/ScheduledEventCard.vue +++ b/src/modules/nostr-feed/components/ScheduledEventCard.vue @@ -16,7 +16,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' -import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand } from 'lucide-vue-next' +import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next' import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { AuthService } from '@/modules/base/auth/auth-service' @@ -34,6 +34,7 @@ interface Emits { (e: 'start-task', event: ScheduledEvent, occurrence?: string): void (e: 'complete-task', event: ScheduledEvent, occurrence?: string): void (e: 'unclaim-task', event: ScheduledEvent, occurrence?: string): void + (e: 'delete-task', event: ScheduledEvent): void } const props = withDefaults(defineProps(), { @@ -84,6 +85,12 @@ const canUnclaim = computed(() => { return completion.value.pubkey === currentUserPubkey.value }) +// Check if current user is the author of the task +const isAuthor = computed(() => { + if (!currentUserPubkey.value) return false + return props.event.pubkey === currentUserPubkey.value +}) + // Status badges configuration const statusConfig = computed(() => { switch (taskStatus.value) { @@ -150,7 +157,7 @@ const formattedTimeRange = computed(() => { }) // Action type for confirmation dialog -const pendingAction = ref<'claim' | 'start' | 'complete' | 'unclaim' | null>(null) +const pendingAction = ref<'claim' | 'start' | 'complete' | 'unclaim' | 'delete' | null>(null) // Handle claim task function handleClaimTask() { @@ -176,6 +183,12 @@ function handleUnclaimTask() { showConfirmDialog.value = true } +// Handle delete task +function handleDeleteTask() { + pendingAction.value = 'delete' + showConfirmDialog.value = true +} + // Confirm action function confirmAction() { if (!pendingAction.value) return @@ -198,6 +211,9 @@ function confirmAction() { case 'unclaim': emit('unclaim-task', props.event, occurrence.value) break + case 'delete': + emit('delete-task', props.event) + break } showConfirmDialog.value = false @@ -239,6 +255,12 @@ const dialogContent = computed(() => { description: `This will remove your claim on "${props.event.title}" and make it available for others.\n\nHave you communicated to others that you are unclaiming this task?`, confirmText: 'Unclaim Task' } + case 'delete': + return { + title: 'Delete Task?', + description: `This will permanently delete "${props.event.title}". This action cannot be undone.`, + confirmText: 'Delete Task' + } default: return { title: '', @@ -461,6 +483,19 @@ const dialogContent = computed(() => {
+ + +
+ +
diff --git a/src/modules/nostr-feed/composables/useScheduledEvents.ts b/src/modules/nostr-feed/composables/useScheduledEvents.ts index e8e8f42..580a26b 100644 --- a/src/modules/nostr-feed/composables/useScheduledEvents.ts +++ b/src/modules/nostr-feed/composables/useScheduledEvents.ts @@ -208,6 +208,25 @@ export function useScheduledEvents() { return scheduledEventService?.scheduledEvents ?? new Map() }) + /** + * Delete a task (only author can delete) + */ + const deleteTask = async (event: ScheduledEvent): Promise => { + if (!scheduledEventService) { + toast.error('Scheduled event service not available') + return + } + + try { + await scheduledEventService.deleteTask(event) + toast.success('Task deleted!') + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete task' + toast.error(message) + console.error('Failed to delete task:', error) + } + } + /** * Get all completions (reactive) - returns array for better reactivity */ @@ -231,6 +250,7 @@ export function useScheduledEvents() { startTask, completeEvent, unclaimTask, + deleteTask, toggleComplete, // DEPRECATED: Use specific actions instead // State diff --git a/src/modules/nostr-feed/services/FeedService.ts b/src/modules/nostr-feed/services/FeedService.ts index d46ab24..2141ed7 100644 --- a/src/modules/nostr-feed/services/FeedService.ts +++ b/src/modules/nostr-feed/services/FeedService.ts @@ -394,6 +394,17 @@ export class FeedService extends BaseService { return } + // Route to ScheduledEventService for scheduled event deletions (kind 31922) + if (deletedKind === '31922') { + console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService') + if (this.scheduledEventService) { + this.scheduledEventService.handleTaskDeletion(event) + } else { + console.warn('⚠️ FeedService: ScheduledEventService not available') + } + return + } + // Handle post deletions (kind 1) in FeedService if (deletedKind === '1' || !deletedKind) { // Extract event IDs to delete from 'e' tags diff --git a/src/modules/nostr-feed/services/ScheduledEventService.ts b/src/modules/nostr-feed/services/ScheduledEventService.ts index fc97fd1..4911b24 100644 --- a/src/modules/nostr-feed/services/ScheduledEventService.ts +++ b/src/modules/nostr-feed/services/ScheduledEventService.ts @@ -253,6 +253,50 @@ export class ScheduledEventService extends BaseService { } } + /** + * Handle deletion event (kind 5) for scheduled events (kind 31922) + * Made public so FeedService can route deletion events to this service + */ + public handleTaskDeletion(event: NostrEvent): void { + console.log('🗑️ ScheduledEventService: Received task deletion event (kind 5)', event.id) + + try { + // Extract event addresses to delete from 'a' tags + const eventAddressesToDelete = event.tags + ?.filter((tag: string[]) => tag[0] === 'a') + .map((tag: string[]) => tag[1]) || [] + + if (eventAddressesToDelete.length === 0) { + console.warn('Task deletion event missing a tags:', event.id) + return + } + + console.log('🔍 Looking for tasks to delete:', eventAddressesToDelete) + + // Find and remove tasks that match the deleted event addresses + let deletedCount = 0 + for (const eventAddress of eventAddressesToDelete) { + const task = this._scheduledEvents.get(eventAddress) + + // Only delete if: + // 1. The task exists + // 2. The deletion request comes from the task author (NIP-09 validation) + if (task && task.pubkey === event.pubkey) { + this._scheduledEvents.delete(eventAddress) + console.log('✅ Deleted task:', eventAddress) + deletedCount++ + } else if (task) { + console.warn('⚠️ Deletion request not from task author:', eventAddress) + } + } + + console.log(`🗑️ Deleted ${deletedCount} task(s) from deletion event`) + + } catch (error) { + console.error('Failed to handle task deletion event:', error) + } + } + /** * Get all scheduled events */ @@ -530,6 +574,68 @@ export class ScheduledEventService extends BaseService { } } + /** + * Delete a scheduled event (kind 31922) + * Only the author can delete their own event + */ + async deleteTask(event: ScheduledEvent): Promise { + if (!this.authService?.isAuthenticated?.value) { + throw new Error('Must be authenticated to delete tasks') + } + + if (!this.relayHub?.isConnected) { + throw new Error('Not connected to relays') + } + + const userPrivkey = this.authService.user.value?.prvkey + const userPubkey = this.authService.user.value?.pubkey + + if (!userPrivkey || !userPubkey) { + throw new Error('User credentials not available') + } + + // Only author can delete + if (userPubkey !== event.pubkey) { + throw new Error('Only the task author can delete this task') + } + + try { + this._isLoading.value = true + + const eventAddress = `31922:${event.pubkey}:${event.dTag}` + + // Create deletion event (kind 5) for the scheduled event + const deletionEvent: EventTemplate = { + kind: 5, + content: 'Task deleted', + tags: [ + ['a', eventAddress], // Reference to the parameterized replaceable event being deleted + ['k', '31922'] // Kind of event being deleted + ], + created_at: Math.floor(Date.now() / 1000) + } + + // Sign the event + const privkeyBytes = this.hexToUint8Array(userPrivkey) + const signedEvent = finalizeEvent(deletionEvent, privkeyBytes) + + // Publish the deletion request + console.log('📤 Publishing deletion request for task:', eventAddress) + const result = await this.relayHub.publishEvent(signedEvent) + console.log('✅ Task deletion request published to', result.success, '/', result.total, 'relays') + + // Optimistically remove from local state + this._scheduledEvents.delete(eventAddress) + console.log('🗑️ Removed task from local state:', eventAddress) + + } catch (error) { + console.error('Failed to delete task:', error) + throw error + } finally { + this._isLoading.value = false + } + } + /** * Helper function to convert hex string to Uint8Array */