diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 30df5ee..fa6e762 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -139,8 +139,6 @@ export const SERVICE_TOKENS = { // Tasks services TASK_SERVICE: Symbol('taskService'), - /** @deprecated Use TASK_SERVICE instead */ - SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'), // Links services SUBMISSION_SERVICE: Symbol('submissionService'), diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index ca2dfdf..85a91c4 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -13,7 +13,7 @@ import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lu import { useFeed } from '../composables/useFeed' import { useProfiles } from '@/modules/base/composables/useProfiles' import { useReactions } from '@/modules/base/composables/useReactions' -import { useScheduledEvents } from '../composables/useScheduledEvents' +import { useTasks } from '@/modules/tasks/composables/useTasks' import ThreadedPost from './ThreadedPost.vue' import ScheduledEventCard from './ScheduledEventCard.vue' import appConfig from '@/app.config' @@ -98,7 +98,9 @@ const { getDisplayName, fetchProfiles } = useProfiles() // Use reactions service for likes/hearts const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() -// Use scheduled events service +// Task service is shared with the standalone tasks app; FeedService +// already routes kind 31922/31925/5 events to it, so opt out of the +// composable's own subscription lifecycle. const { getEventsForSpecificDate, getCompletion, @@ -109,7 +111,7 @@ const { unclaimTask, deleteTask, allCompletions -} = useScheduledEvents() +} = useTasks({ autoSubscribe: false }) // Selected date for viewing scheduled tasks (defaults to today) const selectedDate = ref(new Date().toISOString().split('T')[0]) @@ -405,7 +407,7 @@ async function confirmDeletePost() { const userPrivkey = authService?.user.value?.prvkey if (!userPrivkey) { - toast.error("User private key not available") + toast.error("User private key not available") // pragma: allowlist secret showDeleteDialog.value = false postToDelete.value = null return diff --git a/src/modules/nostr-feed/composables/useReactions.ts b/src/modules/nostr-feed/composables/useReactions.ts deleted file mode 100644 index 96e68ac..0000000 --- a/src/modules/nostr-feed/composables/useReactions.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { computed } from 'vue' -import { injectService, SERVICE_TOKENS } from '@/core/di-container' -import type { ReactionService, EventReactions } from '../services/ReactionService' -import { useToast } from '@/core/composables/useToast' - -/** - * Composable for managing reactions in the feed - */ -export function useReactions() { - const reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE) - const toast = useToast() - - /** - * Get reactions for a specific event - */ - const getEventReactions = (eventId: string): EventReactions => { - if (!reactionService) { - return { - eventId, - likes: 0, - dislikes: 0, - totalReactions: 0, - userHasLiked: false, - userHasDisliked: false, - reactions: [] - } - } - return reactionService.getEventReactions(eventId) - } - - /** - * Subscribe to reactions for a list of event IDs - */ - const subscribeToReactions = async (eventIds: string[]): Promise => { - if (!reactionService || eventIds.length === 0) return - - try { - await reactionService.subscribeToReactions(eventIds) - } catch (error) { - console.error('Failed to subscribe to reactions:', error) - } - } - - /** - * Toggle like on an event - like if not liked, unlike if already liked - */ - const toggleLike = async (eventId: string, eventPubkey: string, eventKind: number): Promise => { - if (!reactionService) { - toast.error('Reaction service not available') - return - } - - try { - await reactionService.toggleLikeEvent(eventId, eventPubkey, eventKind) - - // Check if we liked or unliked - const eventReactions = reactionService.getEventReactions(eventId) - if (eventReactions.userHasLiked) { - toast.success('Post liked!') - } else { - toast.success('Like removed') - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to toggle reaction' - - if (message.includes('authenticated')) { - toast.error('Please sign in to react to posts') - } else if (message.includes('Not connected')) { - toast.error('Not connected to relays') - } else { - toast.error(message) - } - - console.error('Failed to toggle like:', error) - } - } - - /** - * Get loading state - */ - const isLoading = computed(() => { - return reactionService?.isLoading ?? false - }) - - /** - * Get all event reactions (for debugging) - */ - const allEventReactions = computed(() => { - return reactionService?.eventReactions ?? new Map() - }) - - return { - // Methods - getEventReactions, - subscribeToReactions, - toggleLike, - - // State - isLoading, - allEventReactions - } -} \ No newline at end of file diff --git a/src/modules/nostr-feed/composables/useScheduledEvents.ts b/src/modules/nostr-feed/composables/useScheduledEvents.ts deleted file mode 100644 index 580a26b..0000000 --- a/src/modules/nostr-feed/composables/useScheduledEvents.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { computed } from 'vue' -import { injectService, SERVICE_TOKENS } from '@/core/di-container' -import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService' -import type { AuthService } from '@/modules/base/auth/auth-service' -import { useToast } from '@/core/composables/useToast' - -/** - * Composable for managing scheduled events in the feed - */ -export function useScheduledEvents() { - const scheduledEventService = injectService(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE) - const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) - const toast = useToast() - - // Get current user's pubkey - const currentUserPubkey = computed(() => authService?.user.value?.pubkey) - - /** - * Get all scheduled events - */ - const getScheduledEvents = (): ScheduledEvent[] => { - if (!scheduledEventService) return [] - return scheduledEventService.getScheduledEvents() - } - - /** - * Get events for a specific date (YYYY-MM-DD) - */ - const getEventsForDate = (date: string): ScheduledEvent[] => { - if (!scheduledEventService) return [] - return scheduledEventService.getEventsForDate(date) - } - - /** - * Get events for a specific date (filtered by current user participation) - * @param date - ISO date string (YYYY-MM-DD). Defaults to today. - */ - const getEventsForSpecificDate = (date?: string): ScheduledEvent[] => { - if (!scheduledEventService) return [] - return scheduledEventService.getEventsForSpecificDate(date, currentUserPubkey.value) - } - - /** - * Get today's scheduled events (filtered by current user participation) - */ - const getTodaysEvents = (): ScheduledEvent[] => { - if (!scheduledEventService) return [] - return scheduledEventService.getTodaysEvents(currentUserPubkey.value) - } - - /** - * Get completion status for an event - */ - const getCompletion = (eventAddress: string): EventCompletion | undefined => { - if (!scheduledEventService) return undefined - return scheduledEventService.getCompletion(eventAddress) - } - - /** - * Check if an event is completed - */ - const isCompleted = (eventAddress: string): boolean => { - if (!scheduledEventService) return false - return scheduledEventService.isCompleted(eventAddress) - } - - /** - * Get task status for an event - */ - const getTaskStatus = (eventAddress: string, occurrence?: string): TaskStatus | null => { - if (!scheduledEventService) return null - return scheduledEventService.getTaskStatus(eventAddress, occurrence) - } - - /** - * Claim a task - */ - const claimTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise => { - if (!scheduledEventService) { - toast.error('Scheduled event service not available') - return - } - - try { - await scheduledEventService.claimTask(event, notes, occurrence) - toast.success('Task claimed!') - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to claim task' - if (message.includes('authenticated')) { - toast.error('Please sign in to claim tasks') - } else { - toast.error(message) - } - console.error('Failed to claim task:', error) - } - } - - /** - * Start a task (mark as in-progress) - */ - const startTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise => { - if (!scheduledEventService) { - toast.error('Scheduled event service not available') - return - } - - try { - await scheduledEventService.startTask(event, notes, occurrence) - toast.success('Task started!') - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to start task' - toast.error(message) - console.error('Failed to start task:', error) - } - } - - /** - * Unclaim a task (remove task status) - */ - const unclaimTask = async (event: ScheduledEvent, occurrence?: string): Promise => { - if (!scheduledEventService) { - toast.error('Scheduled event service not available') - return - } - - try { - await scheduledEventService.unclaimTask(event, occurrence) - toast.success('Task unclaimed') - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to unclaim task' - toast.error(message) - console.error('Failed to unclaim task:', error) - } - } - - /** - * Toggle completion status of an event (optionally for a specific occurrence) - * DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control - */ - const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise => { - console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence) - - if (!scheduledEventService) { - console.error('❌ useScheduledEvents: Scheduled event service not available') - toast.error('Scheduled event service not available') - return - } - - try { - const eventAddress = `31922:${event.pubkey}:${event.dTag}` - const currentlyCompleted = scheduledEventService.isCompleted(eventAddress, occurrence) - console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted) - - if (currentlyCompleted) { - console.log('⬇️ useScheduledEvents: Unclaiming task...') - await scheduledEventService.unclaimTask(event, occurrence) - toast.success('Task unclaimed') - } else { - console.log('⬆️ useScheduledEvents: Marking as complete...') - await scheduledEventService.completeEvent(event, notes, occurrence) - toast.success('Task completed!') - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to toggle completion' - - if (message.includes('authenticated')) { - toast.error('Please sign in to complete tasks') - } else if (message.includes('Not connected')) { - toast.error('Not connected to relays') - } else { - toast.error(message) - } - - console.error('❌ useScheduledEvents: Failed to toggle completion:', error) - } - } - - /** - * Complete an event with optional notes - */ - const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise => { - if (!scheduledEventService) { - toast.error('Scheduled event service not available') - return - } - - try { - await scheduledEventService.completeEvent(event, notes, occurrence) - toast.success('Task completed!') - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to complete task' - toast.error(message) - console.error('Failed to complete task:', error) - } - } - - /** - * Get loading state - */ - const isLoading = computed(() => { - return scheduledEventService?.isLoading ?? false - }) - - /** - * Get all scheduled events (reactive) - */ - const allScheduledEvents = computed(() => { - 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 - */ - const allCompletions = computed(() => { - if (!scheduledEventService?.completions) return [] - return Array.from(scheduledEventService.completions.values()) - }) - - return { - // Methods - Getters - getScheduledEvents, - getEventsForDate, - getEventsForSpecificDate, - getTodaysEvents, - getCompletion, - isCompleted, - getTaskStatus, - - // Methods - Actions - claimTask, - startTask, - completeEvent, - unclaimTask, - deleteTask, - toggleComplete, // DEPRECATED: Use specific actions instead - - // State - isLoading, - allScheduledEvents, - allCompletions - } -} diff --git a/src/modules/nostr-feed/services/FeedService.ts b/src/modules/nostr-feed/services/FeedService.ts index 707735d..6fba04d 100644 --- a/src/modules/nostr-feed/services/FeedService.ts +++ b/src/modules/nostr-feed/services/FeedService.ts @@ -1,6 +1,6 @@ import { ref, computed } from 'vue' import { BaseService } from '@/core/base/BaseService' -import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { eventBus } from '@/core/event-bus' import type { Event as NostrEvent, Filter } from 'nostr-tools' @@ -47,7 +47,7 @@ export class FeedService extends BaseService { protected relayHub: any = null protected visibilityService: any = null protected reactionService: any = null - protected scheduledEventService: any = null + protected taskService: any = null // Event ID tracking for deduplication private seenEventIds = new Set() @@ -73,13 +73,12 @@ export class FeedService extends BaseService { this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE) this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE) - // ScheduledEventService moved to tasks module - use tryInjectService for backward compat - this.scheduledEventService = tryInjectService(SERVICE_TOKENS.TASK_SERVICE) + this.taskService = injectService(SERVICE_TOKENS.TASK_SERVICE) console.log('FeedService: RelayHub injected:', !!this.relayHub) console.log('FeedService: VisibilityService injected:', !!this.visibilityService) console.log('FeedService: ReactionService injected:', !!this.reactionService) - console.log('FeedService: TaskService injected:', !!this.scheduledEventService) + console.log('FeedService: TaskService injected:', !!this.taskService) if (!this.relayHub) { throw new Error('RelayHub service not available') @@ -261,28 +260,19 @@ export class FeedService extends BaseService { // Route reaction events (kind 7) to ReactionService if (event.kind === 7) { - if (this.reactionService) { - this.reactionService.handleReactionEvent(event) - } + this.reactionService.handleReactionEvent(event) return } - // Route scheduled events (kind 31922) to ScheduledEventService + // Route scheduled events (kind 31922) to TaskService if (event.kind === 31922) { - if (this.scheduledEventService) { - this.scheduledEventService.handleScheduledEvent(event) - } + this.taskService.handleScheduledEvent(event) return } - // Route RSVP/completion events (kind 31925) to ScheduledEventService + // Route RSVP/completion events (kind 31925) to TaskService if (event.kind === 31925) { - console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService') - if (this.scheduledEventService) { - this.scheduledEventService.handleCompletionEvent(event) - } else { - console.warn('⚠️ FeedService: ScheduledEventService not available') - } + this.taskService.handleCompletionEvent(event) return } @@ -378,31 +368,19 @@ export class FeedService extends BaseService { // Route to ReactionService for reaction deletions (kind 7) if (deletedKind === '7') { - if (this.reactionService) { - this.reactionService.handleDeletionEvent(event) - } + this.reactionService.handleDeletionEvent(event) return } - // Route to ScheduledEventService for completion/RSVP deletions (kind 31925) + // Route to TaskService for completion/RSVP deletions (kind 31925) if (deletedKind === '31925') { - console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService') - if (this.scheduledEventService) { - this.scheduledEventService.handleDeletionEvent(event) - } else { - console.warn('⚠️ FeedService: ScheduledEventService not available') - } + this.taskService.handleDeletionEvent(event) return } - // Route to ScheduledEventService for scheduled event deletions (kind 31922) + // Route to TaskService 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') - } + this.taskService.handleTaskDeletion(event) return } @@ -623,16 +601,8 @@ export class FeedService extends BaseService { * Get like count for a post from ReactionService */ private getLikeCount(postId: string): number { - try { - if (this.reactionService && typeof this.reactionService.getEventReactions === 'function') { - const reactions = this.reactionService.getEventReactions(postId) - return reactions?.likes || 0 - } - } catch (error) { - // Silently fail if reaction service is not available - console.debug('FeedService: Could not get like count for post', postId, error) - } - return 0 + const reactions = this.reactionService.getEventReactions(postId) + return reactions?.likes || 0 } /** diff --git a/src/modules/nostr-feed/services/ReactionService.ts b/src/modules/nostr-feed/services/ReactionService.ts deleted file mode 100644 index 24adfb7..0000000 --- a/src/modules/nostr-feed/services/ReactionService.ts +++ /dev/null @@ -1,585 +0,0 @@ -import { ref, reactive } from 'vue' -import { BaseService } from '@/core/base/BaseService' -import { injectService, SERVICE_TOKENS } from '@/core/di-container' -import { finalizeEvent, type EventTemplate } from 'nostr-tools' -import type { Event as NostrEvent } from 'nostr-tools' - -export interface Reaction { - id: string - eventId: string // The event being reacted to - pubkey: string // Who reacted - content: string // The reaction content ('+', '-', emoji) - created_at: number -} - -export interface EventReactions { - eventId: string - likes: number - dislikes: number - totalReactions: number - userHasLiked: boolean - userHasDisliked: boolean - userReactionId?: string // Track the user's reaction ID for deletion - reactions: Reaction[] -} - -export class ReactionService extends BaseService { - protected readonly metadata = { - name: 'ReactionService', - version: '1.0.0', - dependencies: [] - } - - protected relayHub: any = null - protected authService: any = null - - // Reaction state - indexed by event ID - private _eventReactions = reactive(new Map()) - private _isLoading = ref(false) - - // Track reaction subscription - private currentSubscription: string | null = null - private currentUnsubscribe: (() => void) | null = null - - // Track which events we're monitoring - private monitoredEvents = new Set() - - // Track deleted reactions to hide them - private deletedReactions = new Set() - - protected async onInitialize(): Promise { - console.log('ReactionService: Starting initialization...') - - this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) - this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) - - if (!this.relayHub) { - throw new Error('RelayHub service not available') - } - - // Deletion monitoring is now handled by FeedService's consolidated subscription - console.log('ReactionService: Initialization complete (deletion monitoring handled by FeedService)') - } - - /** - * Get reactions for a specific event - */ - getEventReactions(eventId: string): EventReactions { - if (!this._eventReactions.has(eventId)) { - this._eventReactions.set(eventId, { - eventId, - likes: 0, - dislikes: 0, - totalReactions: 0, - userHasLiked: false, - userHasDisliked: false, - reactions: [] - }) - } - return this._eventReactions.get(eventId)! - } - - /** - * Subscribe to reactions for a list of event IDs - */ - async subscribeToReactions(eventIds: string[]): Promise { - if (eventIds.length === 0) return - - // Filter out events we're already monitoring - const newEventIds = eventIds.filter(id => !this.monitoredEvents.has(id)) - if (newEventIds.length === 0) return - - console.log(`ReactionService: Subscribing to reactions for ${newEventIds.length} events`) - - try { - if (!this.relayHub?.isConnected) { - await this.relayHub?.connect() - } - - // Add to monitored set - newEventIds.forEach(id => this.monitoredEvents.add(id)) - - const subscriptionId = `reactions-${Date.now()}` - - // Subscribe to reactions (kind 7) for these events - // Deletions (kind 5) are now handled by FeedService's consolidated subscription - const filters = [ - { - kinds: [7], // Reactions - '#e': newEventIds, // Events being reacted to - limit: 1000 - } - ] - - const unsubscribe = this.relayHub.subscribe({ - id: subscriptionId, - filters: filters, - onEvent: (event: NostrEvent) => { - this.handleReactionEvent(event) - }, - onEose: () => { - console.log(`ReactionService: Subscription ${subscriptionId} ready`) - } - }) - - // Store subscription info (we can have multiple) - if (!this.currentSubscription) { - this.currentSubscription = subscriptionId - this.currentUnsubscribe = unsubscribe - } - - } catch (error) { - console.error('Failed to subscribe to reactions:', error) - } - } - - /** - * Handle incoming reaction event - * Made public so FeedService can route kind 7 events to this service - */ - public handleReactionEvent(event: NostrEvent): void { - try { - // Find the event being reacted to - const eTag = event.tags.find(tag => tag[0] === 'e') - if (!eTag || !eTag[1]) { - console.warn('Reaction event missing e tag:', event.id) - return - } - - const eventId = eTag[1] - const content = event.content.trim() - - // Create reaction object - const reaction: Reaction = { - id: event.id, - eventId, - pubkey: event.pubkey, - content, - created_at: event.created_at - } - - // Update event reactions - const eventReactions = this.getEventReactions(eventId) - - // Check if this reaction already exists (deduplication) or is deleted - const existingIndex = eventReactions.reactions.findIndex(r => r.id === reaction.id) - if (existingIndex >= 0) { - return // Already have this reaction - } - - // Check if this reaction has been deleted - if (this.deletedReactions.has(reaction.id)) { - return // This reaction was deleted - } - - // IMPORTANT: Remove any previous reaction from the same user - // This ensures one reaction per user per event, even if deletion events aren't processed - const previousReactionIndex = eventReactions.reactions.findIndex(r => - r.pubkey === reaction.pubkey && - r.content === reaction.content - ) - - if (previousReactionIndex >= 0) { - // Replace the old reaction with the new one - eventReactions.reactions[previousReactionIndex] = reaction - } else { - // Add as new reaction - eventReactions.reactions.push(reaction) - } - - // Recalculate counts and user state - this.recalculateEventReactions(eventId) - - } catch (error) { - console.error('Failed to handle reaction event:', error) - } - } - - /** - * Handle deletion event (called by FeedService when a kind 5 event with k=7 is received) - * Made public so FeedService can route deletion events to this service - */ - public handleDeletionEvent(event: NostrEvent): void { - try { - // Process each deleted event - const eTags = event.tags.filter(tag => tag[0] === 'e') - const deletionAuthor = event.pubkey - - for (const eTag of eTags) { - const deletedEventId = eTag[1] - if (deletedEventId) { - // Add to deleted set - this.deletedReactions.add(deletedEventId) - - // Find and remove the reaction from all event reactions - for (const [eventId, eventReactions] of this._eventReactions) { - const reactionIndex = eventReactions.reactions.findIndex(r => r.id === deletedEventId) - - if (reactionIndex >= 0) { - const reaction = eventReactions.reactions[reactionIndex] - - // IMPORTANT: Only process deletion if it's from the same user who created the reaction - // This follows NIP-09 spec: "Relays SHOULD delete or stop publishing any referenced events - // that have an identical `pubkey` as the deletion request" - if (reaction.pubkey === deletionAuthor) { - eventReactions.reactions.splice(reactionIndex, 1) - // Recalculate counts for this event - this.recalculateEventReactions(eventId) - } - } - } - } - } - } catch (error) { - console.error('Failed to handle deletion event:', error) - } - } - - /** - * Recalculate reaction counts and user state for an event - */ - private recalculateEventReactions(eventId: string): void { - const eventReactions = this.getEventReactions(eventId) - const userPubkey = this.authService?.user?.value?.pubkey - - // Use Sets to track unique users who liked/disliked - const likedUsers = new Set() - const dislikedUsers = new Set() - let userHasLiked = false - let userHasDisliked = false - let userReactionId: string | undefined - - // Group reactions by user, keeping only the most recent - const latestReactionsByUser = new Map() - - for (const reaction of eventReactions.reactions) { - // Skip deleted reactions - if (this.deletedReactions.has(reaction.id)) { - continue - } - - // Keep only the latest reaction from each user - const existing = latestReactionsByUser.get(reaction.pubkey) - if (!existing || reaction.created_at > existing.created_at) { - latestReactionsByUser.set(reaction.pubkey, reaction) - } - } - - // Now count unique reactions - for (const reaction of latestReactionsByUser.values()) { - const isLike = reaction.content === '+' || reaction.content === '❤️' || reaction.content === '' - const isDislike = reaction.content === '-' - - if (isLike) { - likedUsers.add(reaction.pubkey) - if (userPubkey && reaction.pubkey === userPubkey) { - userHasLiked = true - userReactionId = reaction.id - } - } else if (isDislike) { - dislikedUsers.add(reaction.pubkey) - if (userPubkey && reaction.pubkey === userPubkey) { - userHasDisliked = true - userReactionId = reaction.id - } - } - } - - // Update the reactive state with unique user counts - eventReactions.likes = likedUsers.size - eventReactions.dislikes = dislikedUsers.size - eventReactions.totalReactions = latestReactionsByUser.size - eventReactions.userHasLiked = userHasLiked - eventReactions.userHasDisliked = userHasDisliked - eventReactions.userReactionId = userReactionId - } - - /** - * Send a heart reaction (like) to an event - */ - async likeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise { - if (!this.authService?.isAuthenticated?.value) { - throw new Error('Must be authenticated to react') - } - - if (!this.relayHub?.isConnected) { - throw new Error('Not connected to relays') - } - - const userPubkey = this.authService.user.value?.pubkey - const userPrivkey = this.authService.user.value?.prvkey - - if (!userPubkey || !userPrivkey) { - throw new Error('User keys not available') - } - - // Check if user already liked this event - const eventReactions = this.getEventReactions(eventId) - if (eventReactions.userHasLiked) { - throw new Error('Already liked this event') - } - - try { - this._isLoading.value = true - - // Create reaction event template according to NIP-25 - const eventTemplate: EventTemplate = { - kind: 7, // Reaction - content: '+', // Like reaction - tags: [ - ['e', eventId, '', eventPubkey], // Event being reacted to - ['p', eventPubkey], // Author of the event being reacted to - ['k', eventKind.toString()] // Kind of the event being reacted to - ], - created_at: Math.floor(Date.now() / 1000) - } - - // Sign the event - const privkeyBytes = this.hexToUint8Array(userPrivkey) - const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) - - // Publish the reaction - await this.relayHub.publishEvent(signedEvent) - - // Optimistically update local state - this.handleReactionEvent(signedEvent) - - } catch (error) { - console.error('Failed to like event:', error) - throw error - } finally { - this._isLoading.value = false - } - } - - /** - * Remove a like from an event (unlike) using NIP-09 deletion events - */ - async unlikeEvent(eventId: string): Promise { - if (!this.authService?.isAuthenticated?.value) { - throw new Error('Must be authenticated to remove reaction') - } - - if (!this.relayHub?.isConnected) { - throw new Error('Not connected to relays') - } - - const userPubkey = this.authService.user.value?.pubkey - const userPrivkey = this.authService.user.value?.prvkey - - if (!userPubkey || !userPrivkey) { - throw new Error('User keys not available') - } - - // Get the user's reaction ID to delete - const eventReactions = this.getEventReactions(eventId) - - if (!eventReactions.userHasLiked || !eventReactions.userReactionId) { - throw new Error('No reaction to remove') - } - - try { - this._isLoading.value = true - - // Create deletion event according to NIP-09 - const eventTemplate: EventTemplate = { - kind: 5, // Deletion request - content: '', // Empty content or reason - tags: [ - ['e', eventReactions.userReactionId], // The reaction event to delete - ['k', '7'] // Kind of event being deleted (reaction) - ], - created_at: Math.floor(Date.now() / 1000) - } - - // Sign the event - const privkeyBytes = this.hexToUint8Array(userPrivkey) - const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) - - // Publish the deletion - const result = await this.relayHub.publishEvent(signedEvent) - - console.log(`ReactionService: Deletion published to ${result.success}/${result.total} relays`) - - // Optimistically update local state - this.handleDeletionEvent(signedEvent) - - } catch (error) { - console.error('Failed to unlike event:', error) - throw error - } finally { - this._isLoading.value = false - } - } - - /** - * Toggle like on an event - like if not liked, unlike if already liked - */ - async toggleLikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise { - const eventReactions = this.getEventReactions(eventId) - - if (eventReactions.userHasLiked) { - // Unlike the event - await this.unlikeEvent(eventId) - } else { - // Like the event - await this.likeEvent(eventId, eventPubkey, eventKind) - } - } - - /** - * Send a dislike reaction to an event - */ - async dislikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise { - if (!this.authService?.isAuthenticated?.value) { - throw new Error('Must be authenticated to react') - } - - if (!this.relayHub?.isConnected) { - throw new Error('Not connected to relays') - } - - const userPubkey = this.authService.user.value?.pubkey - const userPrivkey = this.authService.user.value?.prvkey - - if (!userPubkey || !userPrivkey) { - throw new Error('User keys not available') - } - - // Check if user already disliked this event - const eventReactions = this.getEventReactions(eventId) - if (eventReactions.userHasDisliked) { - throw new Error('Already disliked this event') - } - - try { - this._isLoading.value = true - - // Create reaction event template according to NIP-25 - const eventTemplate: EventTemplate = { - kind: 7, // Reaction - content: '-', // Dislike reaction - tags: [ - ['e', eventId, '', eventPubkey], // Event being reacted to - ['p', eventPubkey], // Author of the event being reacted to - ['k', eventKind.toString()] // Kind of the event being reacted to - ], - created_at: Math.floor(Date.now() / 1000) - } - - // Sign the event - const privkeyBytes = this.hexToUint8Array(userPrivkey) - const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) - - // Publish the reaction - await this.relayHub.publishEvent(signedEvent) - - // Optimistically update local state - this.handleReactionEvent(signedEvent) - - } catch (error) { - console.error('Failed to dislike event:', error) - throw error - } finally { - this._isLoading.value = false - } - } - - /** - * Remove a dislike from an event using NIP-09 deletion events - */ - async undislikeEvent(eventId: string): Promise { - if (!this.authService?.isAuthenticated?.value) { - throw new Error('Must be authenticated to remove reaction') - } - - if (!this.relayHub?.isConnected) { - throw new Error('Not connected to relays') - } - - const userPubkey = this.authService.user.value?.pubkey - const userPrivkey = this.authService.user.value?.prvkey - - if (!userPubkey || !userPrivkey) { - throw new Error('User keys not available') - } - - // Get the user's reaction ID to delete - const eventReactions = this.getEventReactions(eventId) - - if (!eventReactions.userHasDisliked || !eventReactions.userReactionId) { - throw new Error('No dislike reaction to remove') - } - - try { - this._isLoading.value = true - - // Create deletion event according to NIP-09 - const eventTemplate: EventTemplate = { - kind: 5, // Deletion request - content: '', // Empty content or reason - tags: [ - ['e', eventReactions.userReactionId], // The reaction event to delete - ['k', '7'] // Kind of event being deleted (reaction) - ], - created_at: Math.floor(Date.now() / 1000) - } - - // Sign the event - const privkeyBytes = this.hexToUint8Array(userPrivkey) - const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) - - // Publish the deletion - const result = await this.relayHub.publishEvent(signedEvent) - - console.log(`ReactionService: Dislike deletion published to ${result.success}/${result.total} relays`) - - // Optimistically update local state - this.handleDeletionEvent(signedEvent) - - } catch (error) { - console.error('Failed to remove dislike:', error) - throw error - } finally { - this._isLoading.value = false - } - } - - /** - * Helper function to convert hex string to Uint8Array - */ - private hexToUint8Array(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2) - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substr(i, 2), 16) - } - return bytes - } - - /** - * Get all event reactions - */ - get eventReactions(): Map { - return this._eventReactions - } - - /** - * Check if currently loading - */ - get isLoading(): boolean { - return this._isLoading.value - } - - /** - * Cleanup - */ - protected async onDestroy(): Promise { - if (this.currentUnsubscribe) { - this.currentUnsubscribe() - } - // deletionUnsubscribe is no longer used - deletions handled by FeedService - this._eventReactions.clear() - this.monitoredEvents.clear() - this.deletedReactions.clear() - } -} \ No newline at end of file diff --git a/src/modules/nostr-feed/services/ScheduledEventService.ts b/src/modules/nostr-feed/services/ScheduledEventService.ts deleted file mode 100644 index d2ef6b4..0000000 --- a/src/modules/nostr-feed/services/ScheduledEventService.ts +++ /dev/null @@ -1,678 +0,0 @@ -import { ref, reactive } from 'vue' -import { BaseService } from '@/core/base/BaseService' -import { injectService, SERVICE_TOKENS } from '@/core/di-container' -import { finalizeEvent, type EventTemplate } from 'nostr-tools' -import type { Event as NostrEvent } from 'nostr-tools' - -export interface RecurrencePattern { - frequency: 'daily' | 'weekly' - dayOfWeek?: string // For weekly: 'monday', 'tuesday', etc. - endDate?: string // ISO date string - when to stop recurring (optional) -} - -export interface ScheduledEvent { - id: string - pubkey: string - created_at: number - dTag: string // Unique identifier from 'd' tag - title: string - start: string // ISO date string (YYYY-MM-DD or ISO datetime) - end?: string - description?: string - location?: string - status: string - eventType?: string // 'task' for completable events, 'announcement' for informational - participants?: Array<{ pubkey: string; type?: string }> // 'required', 'optional', 'organizer' - content: string - tags: string[][] - recurrence?: RecurrencePattern // Optional: for recurring events -} - -export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled' - -export interface EventCompletion { - id: string - eventAddress: string // "31922:pubkey:d-tag" - occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD) - pubkey: string // Who claimed/completed it - created_at: number - taskStatus: TaskStatus - completedAt?: number // Unix timestamp when completed - notes: string -} - -export class ScheduledEventService extends BaseService { - protected readonly metadata = { - name: 'ScheduledEventService', - version: '1.0.0', - dependencies: [] - } - - protected relayHub: any = null - protected authService: any = null - - // Scheduled events state - indexed by event address - private _scheduledEvents = reactive(new Map()) - private _completions = reactive(new Map()) - private _isLoading = ref(false) - - protected async onInitialize(): Promise { - console.log('ScheduledEventService: Starting initialization...') - - this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) - this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) - - if (!this.relayHub) { - throw new Error('RelayHub service not available') - } - - console.log('ScheduledEventService: Initialization complete') - } - - /** - * Handle incoming scheduled event (kind 31922) - * Made public so FeedService can route kind 31922 events to this service - */ - public handleScheduledEvent(event: NostrEvent): void { - try { - // Extract event data from tags - const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] - if (!dTag) { - console.warn('Scheduled event missing d tag:', event.id) - return - } - - const title = event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled Event' - const start = event.tags.find(tag => tag[0] === 'start')?.[1] - const end = event.tags.find(tag => tag[0] === 'end')?.[1] - const description = event.tags.find(tag => tag[0] === 'description')?.[1] - const location = event.tags.find(tag => tag[0] === 'location')?.[1] - const status = event.tags.find(tag => tag[0] === 'status')?.[1] || 'pending' - const eventType = event.tags.find(tag => tag[0] === 'event-type')?.[1] - - // Parse participant tags: ["p", "", "", ""] - const participantTags = event.tags.filter(tag => tag[0] === 'p') - const participants = participantTags.map(tag => ({ - pubkey: tag[1], - type: tag[3] // 'required', 'optional', 'organizer' - })) - - // Parse recurrence tags - const recurrenceFreq = event.tags.find(tag => tag[0] === 'recurrence')?.[1] as 'daily' | 'weekly' | undefined - const recurrenceDayOfWeek = event.tags.find(tag => tag[0] === 'recurrence-day')?.[1] - const recurrenceEndDate = event.tags.find(tag => tag[0] === 'recurrence-end')?.[1] - - let recurrence: RecurrencePattern | undefined - if (recurrenceFreq === 'daily' || recurrenceFreq === 'weekly') { - recurrence = { - frequency: recurrenceFreq, - dayOfWeek: recurrenceDayOfWeek, - endDate: recurrenceEndDate - } - } - - if (!start) { - console.warn('Scheduled event missing start date:', event.id) - return - } - - // Create event address: "kind:pubkey:d-tag" - const eventAddress = `31922:${event.pubkey}:${dTag}` - - const scheduledEvent: ScheduledEvent = { - id: event.id, - pubkey: event.pubkey, - created_at: event.created_at, - dTag, - title, - start, - end, - description, - location, - status, - eventType, - participants: participants.length > 0 ? participants : undefined, - content: event.content, - tags: event.tags, - recurrence - } - - // Store or update the event (replaceable by d-tag) - this._scheduledEvents.set(eventAddress, scheduledEvent) - - } catch (error) { - console.error('Failed to handle scheduled event:', error) - } - } - - /** - * Handle RSVP/completion event (kind 31925) - * Made public so FeedService can route kind 31925 events to this service - */ - public handleCompletionEvent(event: NostrEvent): void { - console.log('🔔 ScheduledEventService: Received completion event (kind 31925)', event.id) - - try { - // Find the event being responded to - const aTag = event.tags.find(tag => tag[0] === 'a')?.[1] - if (!aTag) { - console.warn('Completion event missing a tag:', event.id) - return - } - - // Parse task status (new approach) - const taskStatusTag = event.tags.find(tag => tag[0] === 'task-status')?.[1] as TaskStatus | undefined - - // Backward compatibility: check old 'completed' tag if task-status not present - let taskStatus: TaskStatus - if (taskStatusTag) { - taskStatus = taskStatusTag - } else { - // Legacy support: convert old 'completed' tag to new taskStatus - const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true' - taskStatus = completed ? 'completed' : 'claimed' - } - - const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1] - const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined - const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string - - console.log('📋 Completion details:', { - aTag, - occurrence, - taskStatus, - pubkey: event.pubkey, - eventId: event.id - }) - - const completion: EventCompletion = { - id: event.id, - eventAddress: aTag, - occurrence, - pubkey: event.pubkey, - created_at: event.created_at, - taskStatus, - completedAt, - notes: event.content - } - - // Store completion (most recent one wins) - // For recurring events, include occurrence in the key: "eventAddress:occurrence" - // For non-recurring, just use eventAddress - const completionKey = occurrence ? `${aTag}:${occurrence}` : aTag - const existing = this._completions.get(completionKey) - if (!existing || event.created_at > existing.created_at) { - this._completions.set(completionKey, completion) - console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus) - } else { - console.log('⏭️ Skipped older completion for:', completionKey) - } - - } catch (error) { - console.error('Failed to handle completion event:', error) - } - } - - /** - * Handle deletion event (kind 5) for completion events - * Made public so FeedService can route deletion events to this service - */ - public handleDeletionEvent(event: NostrEvent): void { - console.log('🗑️ ScheduledEventService: Received deletion event (kind 5)', event.id) - - try { - // Extract event IDs to delete from 'e' tags - const eventIdsToDelete = event.tags - ?.filter((tag: string[]) => tag[0] === 'e') - .map((tag: string[]) => tag[1]) || [] - - if (eventIdsToDelete.length === 0) { - console.warn('Deletion event missing e tags:', event.id) - return - } - - console.log('🔍 Looking for completions to delete:', eventIdsToDelete) - - // Find and remove completions that match the deleted event IDs - let deletedCount = 0 - for (const [completionKey, completion] of this._completions.entries()) { - // Only delete if: - // 1. The completion event ID matches one being deleted - // 2. The deletion request comes from the same author (NIP-09 validation) - if (eventIdsToDelete.includes(completion.id) && completion.pubkey === event.pubkey) { - this._completions.delete(completionKey) - console.log('✅ Deleted completion:', completionKey, 'event ID:', completion.id) - deletedCount++ - } - } - - console.log(`🗑️ Deleted ${deletedCount} completion(s) from deletion event`) - - } catch (error) { - console.error('Failed to handle deletion event:', error) - } - } - - /** - * 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 - */ - getScheduledEvents(): ScheduledEvent[] { - return Array.from(this._scheduledEvents.values()) - } - - /** - * Get events scheduled for a specific date (YYYY-MM-DD) - */ - getEventsForDate(date: string): ScheduledEvent[] { - return this.getScheduledEvents().filter(event => { - // Simple date matching (start date) - // For ISO datetime strings, extract just the date part - const eventDate = event.start.split('T')[0] - return eventDate === date - }) - } - - /** - * Check if a recurring event occurs on a specific date - */ - private doesRecurringEventOccurOnDate(event: ScheduledEvent, targetDate: string): boolean { - if (!event.recurrence) return false - - const target = new Date(targetDate) - const eventStart = new Date(event.start.split('T')[0]) // Get date part only - - // Check if target date is before the event start date - if (target < eventStart) return false - - // Check if target date is after the event end date (if specified) - if (event.recurrence.endDate) { - const endDate = new Date(event.recurrence.endDate) - if (target > endDate) return false - } - - // Check frequency-specific rules - if (event.recurrence.frequency === 'daily') { - // Daily events occur every day within the range - return true - } else if (event.recurrence.frequency === 'weekly') { - // Weekly events occur on specific day of week - const targetDayOfWeek = target.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase() - const eventDayOfWeek = event.recurrence.dayOfWeek?.toLowerCase() - return targetDayOfWeek === eventDayOfWeek - } - - return false - } - - /** - * Get events for a specific date, optionally filtered by user participation - * @param date - ISO date string (YYYY-MM-DD). Defaults to today. - * @param userPubkey - Optional user pubkey to filter by participation - */ - getEventsForSpecificDate(date?: string, userPubkey?: string): ScheduledEvent[] { - const targetDate = date || new Date().toISOString().split('T')[0] - - // Get one-time events for the date (exclude recurring events to avoid duplicates) - const oneTimeEvents = this.getEventsForDate(targetDate).filter(event => !event.recurrence) - - // Get all events and check for recurring events that occur on this date - const allEvents = this.getScheduledEvents() - const recurringEventsOnDate = allEvents.filter(event => - event.recurrence && this.doesRecurringEventOccurOnDate(event, targetDate) - ) - - // Combine one-time and recurring events - let events = [...oneTimeEvents, ...recurringEventsOnDate] - - // Filter events based on participation (if user pubkey provided) - if (userPubkey) { - events = events.filter(event => { - // If event has no participants, it's community-wide (show to everyone) - if (!event.participants || event.participants.length === 0) return true - - // Otherwise, only show if user is a participant - return event.participants.some(p => p.pubkey === userPubkey) - }) - } - - // Sort by start time (ascending order) - events.sort((a, b) => { - // ISO datetime strings can be compared lexicographically - return a.start.localeCompare(b.start) - }) - - return events - } - - /** - * Get events for today, optionally filtered by user participation - */ - getTodaysEvents(userPubkey?: string): ScheduledEvent[] { - return this.getEventsForSpecificDate(undefined, userPubkey) - } - - /** - * Get completion status for an event (optionally for a specific occurrence) - */ - getCompletion(eventAddress: string, occurrence?: string): EventCompletion | undefined { - const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress - return this._completions.get(completionKey) - } - - /** - * Check if an event is completed (optionally for a specific occurrence) - */ - isCompleted(eventAddress: string, occurrence?: string): boolean { - const completion = this.getCompletion(eventAddress, occurrence) - return completion?.taskStatus === 'completed' - } - - /** - * Get task status for an event - */ - getTaskStatus(eventAddress: string, occurrence?: string): TaskStatus | null { - const completion = this.getCompletion(eventAddress, occurrence) - return completion?.taskStatus || null - } - - /** - * Claim a task (mark as claimed) - */ - async claimTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise { - await this.updateTaskStatus(event, 'claimed', notes, occurrence) - } - - /** - * Start a task (mark as in-progress) - */ - async startTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise { - await this.updateTaskStatus(event, 'in-progress', notes, occurrence) - } - - /** - * Mark an event as complete (optionally for a specific occurrence) - */ - async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise { - await this.updateTaskStatus(event, 'completed', notes, occurrence) - } - - /** - * Internal method to update task status - */ - private async updateTaskStatus( - event: ScheduledEvent, - taskStatus: TaskStatus, - notes: string = '', - occurrence?: string - ): Promise { - if (!this.authService?.isAuthenticated?.value) { - throw new Error('Must be authenticated to update task status') - } - - if (!this.relayHub?.isConnected) { - throw new Error('Not connected to relays') - } - - const userPrivkey = this.authService.user.value?.prvkey - if (!userPrivkey) { - throw new Error('User private key not available') - } - - try { - this._isLoading.value = true - - const eventAddress = `31922:${event.pubkey}:${event.dTag}` - - // Create RSVP event with task-status tag - const tags: string[][] = [ - ['a', eventAddress], - ['task-status', taskStatus] - ] - - // Add completed_at timestamp if task is completed - if (taskStatus === 'completed') { - tags.push(['completed_at', Math.floor(Date.now() / 1000).toString()]) - } - - // Add occurrence tag if provided (for recurring events) - if (occurrence) { - tags.push(['occurrence', occurrence]) - } - - const eventTemplate: EventTemplate = { - kind: 31925, // Calendar Event RSVP - content: notes, - tags, - created_at: Math.floor(Date.now() / 1000) - } - - // Sign the event - const privkeyBytes = this.hexToUint8Array(userPrivkey) - const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) - - // Publish the status update - console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress) - const result = await this.relayHub.publishEvent(signedEvent) - console.log('✅ Task status published to', result.success, '/', result.total, 'relays') - - // Update local state (publishEvent throws if no relays accepted) - console.log('🔄 Updating local state (event published successfully)') - this.handleCompletionEvent(signedEvent) - - } catch (error) { - console.error('Failed to update task status:', error) - throw error - } finally { - this._isLoading.value = false - } - } - - /** - * Unclaim/reset a task (removes task status - makes it unclaimed) - * Note: In Nostr, we can't truly "delete" an event, but we can publish - * a deletion request (kind 5) to ask relays to remove our RSVP - */ - async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise { - if (!this.authService?.isAuthenticated?.value) { - throw new Error('Must be authenticated to unclaim tasks') - } - - if (!this.relayHub?.isConnected) { - throw new Error('Not connected to relays') - } - - const userPrivkey = this.authService.user.value?.prvkey - if (!userPrivkey) { - throw new Error('User private key not available') - } - - try { - this._isLoading.value = true - - const eventAddress = `31922:${event.pubkey}:${event.dTag}` - const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress - const completion = this._completions.get(completionKey) - - if (!completion) { - console.log('No completion to unclaim') - return - } - - // Create deletion event (kind 5) for the RSVP - const deletionEvent: EventTemplate = { - kind: 5, - content: 'Task unclaimed', - tags: [ - ['e', completion.id], // Reference to the RSVP event being deleted - ['k', '31925'] // 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 RSVP:', completion.id) - const result = await this.relayHub.publishEvent(signedEvent) - console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays') - - // Remove from local state (publishEvent throws if no relays accepted) - this._completions.delete(completionKey) - console.log('🗑️ Removed completion from local state:', completionKey) - - } catch (error) { - console.error('Failed to unclaim task:', error) - throw error - } finally { - this._isLoading.value = false - } - } - - /** - * 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') - - // Remove from local state (publishEvent throws if no relays accepted) - 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 - */ - private hexToUint8Array(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2) - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substr(i, 2), 16) - } - return bytes - } - - /** - * Get all scheduled events - */ - get scheduledEvents(): Map { - return this._scheduledEvents - } - - /** - * Get all completions - */ - get completions(): Map { - return this._completions - } - - /** - * Check if currently loading - */ - get isLoading(): boolean { - return this._isLoading.value - } - - /** - * Cleanup - */ - protected async onDestroy(): Promise { - this._scheduledEvents.clear() - this._completions.clear() - } -}