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 deletion subscription separately private deletionUnsubscribe: (() => 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') } // Start monitoring deletion events globally await this.startDeletionMonitoring() console.log('ReactionService: Initialization complete') } /** * Start monitoring deletion events globally */ private async startDeletionMonitoring(): Promise { try { if (!this.relayHub?.isConnected) { await this.relayHub?.connect() } const subscriptionId = `reaction-deletions-${Date.now()}` // Subscribe to ALL deletion events for reactions const filter = { kinds: [5], // Deletion requests '#k': ['7'], // Only for reaction events since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours limit: 1000 } console.log('ReactionService: Starting global deletion monitoring') const unsubscribe = this.relayHub.subscribe({ id: subscriptionId, filters: [filter], onEvent: (event: NostrEvent) => { this.handleDeletionEvent(event) }, onEose: () => { console.log('ReactionService: Initial deletion events loaded') } }) // Store subscription ID if needed for tracking this.deletionUnsubscribe = unsubscribe } catch (error) { console.error('Failed to start deletion monitoring:', error) } } /** * 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) and deletions (kind 5) for these events const filters = [ { kinds: [7], // Reactions '#e': newEventIds, // Events being reacted to limit: 1000 }, { kinds: [5], // Deletion requests for ALL users '#k': ['7'], // Only deletions of reaction events (kind 7) limit: 500 } ] console.log('ReactionService: Creating reaction subscription', filters) const unsubscribe = this.relayHub.subscribe({ id: subscriptionId, filters: filters, onEvent: (event: NostrEvent) => { if (event.kind === 7) { this.handleReactionEvent(event) } else if (event.kind === 5) { this.handleDeletionEvent(event) } }, onEose: () => { console.log(`Reaction subscription ${subscriptionId} complete`) } }) // 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 */ private 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 console.log(`ReactionService: Replacing previous reaction from ${reaction.pubkey.slice(0, 8)}...`) eventReactions.reactions[previousReactionIndex] = reaction } else { // Add as new reaction eventReactions.reactions.push(reaction) } // Recalculate counts and user state this.recalculateEventReactions(eventId) console.log(`ReactionService: Added/updated reaction ${content} to event ${eventId.slice(0, 8)}...`) } catch (error) { console.error('Failed to handle reaction event:', error) } } /** * Handle deletion event */ private 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) console.log(`ReactionService: Removed deleted reaction ${deletedEventId.slice(0, 8)}... from ${deletionAuthor.slice(0, 8)}...`) } else { console.log(`ReactionService: Ignoring deletion request from ${deletionAuthor.slice(0, 8)}... for reaction by ${reaction.pubkey.slice(0, 8)}...`) } } } } } } 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) } console.log('ReactionService: Creating like reaction:', eventTemplate) // Sign the event const privkeyBytes = this.hexToUint8Array(userPrivkey) const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) console.log('ReactionService: Publishing like reaction:', signedEvent) // Publish the reaction const result = await this.relayHub.publishEvent(signedEvent) console.log(`ReactionService: Like published to ${result.success}/${result.total} relays`) // 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) } console.log('ReactionService: Creating deletion event for reaction:', eventReactions.userReactionId) // Sign the event const privkeyBytes = this.hexToUint8Array(userPrivkey) const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) console.log('ReactionService: Publishing deletion event:', signedEvent) // 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) } } /** * 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() } if (this.deletionUnsubscribe) { this.deletionUnsubscribe() } this._eventReactions.clear() this.monitoredEvents.clear() this.deletedReactions.clear() } }