Add delete task functionality for task authors
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 <noreply@anthropic.com>
This commit is contained in:
parent
8f05f4ec7c
commit
3b8c82514a
5 changed files with 185 additions and 2 deletions
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-center py-3 text-muted-foreground text-sm px-4">
|
||||
|
|
|
|||
|
|
@ -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<Props>(), {
|
||||
|
|
@ -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(() => {
|
|||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Delete Task Button (only for task author) -->
|
||||
<div v-if="isAuthor" class="mt-4 pt-4 border-t border-border">
|
||||
<Button
|
||||
@click.stop="handleDeleteTask"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="gap-2"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Delete Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue