diff --git a/src/core/di-container.ts b/src/core/di-container.ts index fa6e762..30df5ee 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -139,6 +139,8 @@ 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 85a91c4..ca2dfdf 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 { useTasks } from '@/modules/tasks/composables/useTasks' +import { useScheduledEvents } from '../composables/useScheduledEvents' import ThreadedPost from './ThreadedPost.vue' import ScheduledEventCard from './ScheduledEventCard.vue' import appConfig from '@/app.config' @@ -98,9 +98,7 @@ const { getDisplayName, fetchProfiles } = useProfiles() // Use reactions service for likes/hearts const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() -// 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. +// Use scheduled events service const { getEventsForSpecificDate, getCompletion, @@ -111,7 +109,7 @@ const { unclaimTask, deleteTask, allCompletions -} = useTasks({ autoSubscribe: false }) +} = useScheduledEvents() // Selected date for viewing scheduled tasks (defaults to today) const selectedDate = ref(new Date().toISOString().split('T')[0]) @@ -407,7 +405,7 @@ async function confirmDeletePost() { const userPrivkey = authService?.user.value?.prvkey if (!userPrivkey) { - toast.error("User private key not available") // pragma: allowlist secret + toast.error("User private key not available") 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 new file mode 100644 index 0000000..96e68ac --- /dev/null +++ b/src/modules/nostr-feed/composables/useReactions.ts @@ -0,0 +1,102 @@ +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 new file mode 100644 index 0000000..580a26b --- /dev/null +++ b/src/modules/nostr-feed/composables/useScheduledEvents.ts @@ -0,0 +1,261 @@ +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 6fba04d..707735d 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, SERVICE_TOKENS } from '@/core/di-container' +import { injectService, tryInjectService, 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 taskService: any = null + protected scheduledEventService: any = null // Event ID tracking for deduplication private seenEventIds = new Set() @@ -73,12 +73,13 @@ 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) - this.taskService = injectService(SERVICE_TOKENS.TASK_SERVICE) + // ScheduledEventService moved to tasks module - use tryInjectService for backward compat + this.scheduledEventService = tryInjectService(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.taskService) + console.log('FeedService: TaskService injected:', !!this.scheduledEventService) if (!this.relayHub) { throw new Error('RelayHub service not available') @@ -260,19 +261,28 @@ export class FeedService extends BaseService { // Route reaction events (kind 7) to ReactionService if (event.kind === 7) { - this.reactionService.handleReactionEvent(event) + if (this.reactionService) { + this.reactionService.handleReactionEvent(event) + } return } - // Route scheduled events (kind 31922) to TaskService + // Route scheduled events (kind 31922) to ScheduledEventService if (event.kind === 31922) { - this.taskService.handleScheduledEvent(event) + if (this.scheduledEventService) { + this.scheduledEventService.handleScheduledEvent(event) + } return } - // Route RSVP/completion events (kind 31925) to TaskService + // Route RSVP/completion events (kind 31925) to ScheduledEventService if (event.kind === 31925) { - this.taskService.handleCompletionEvent(event) + console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService') + if (this.scheduledEventService) { + this.scheduledEventService.handleCompletionEvent(event) + } else { + console.warn('⚠️ FeedService: ScheduledEventService not available') + } return } @@ -368,19 +378,31 @@ export class FeedService extends BaseService { // Route to ReactionService for reaction deletions (kind 7) if (deletedKind === '7') { - this.reactionService.handleDeletionEvent(event) + if (this.reactionService) { + this.reactionService.handleDeletionEvent(event) + } return } - // Route to TaskService for completion/RSVP deletions (kind 31925) + // Route to ScheduledEventService for completion/RSVP deletions (kind 31925) if (deletedKind === '31925') { - this.taskService.handleDeletionEvent(event) + 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') + } return } - // Route to TaskService for scheduled event deletions (kind 31922) + // Route to ScheduledEventService for scheduled event deletions (kind 31922) if (deletedKind === '31922') { - this.taskService.handleTaskDeletion(event) + 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 } @@ -601,8 +623,16 @@ export class FeedService extends BaseService { * Get like count for a post from ReactionService */ private getLikeCount(postId: string): number { - const reactions = this.reactionService.getEventReactions(postId) - return reactions?.likes || 0 + 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 } /** diff --git a/src/modules/nostr-feed/services/ReactionService.ts b/src/modules/nostr-feed/services/ReactionService.ts new file mode 100644 index 0000000..24adfb7 --- /dev/null +++ b/src/modules/nostr-feed/services/ReactionService.ts @@ -0,0 +1,585 @@ +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 new file mode 100644 index 0000000..d2ef6b4 --- /dev/null +++ b/src/modules/nostr-feed/services/ScheduledEventService.ts @@ -0,0 +1,678 @@ +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() + } +}