537 lines
No EOL
16 KiB
TypeScript
537 lines
No EOL
16 KiB
TypeScript
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<string, EventReactions>())
|
|
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<string>()
|
|
|
|
// Track deleted reactions to hide them
|
|
private deletedReactions = new Set<string>()
|
|
|
|
protected async onInitialize(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string>()
|
|
const dislikedUsers = new Set<string>()
|
|
let userHasLiked = false
|
|
let userHasDisliked = false
|
|
let userReactionId: string | undefined
|
|
|
|
// Group reactions by user, keeping only the most recent
|
|
const latestReactionsByUser = new Map<string, Reaction>()
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, EventReactions> {
|
|
return this._eventReactions
|
|
}
|
|
|
|
/**
|
|
* Check if currently loading
|
|
*/
|
|
get isLoading(): boolean {
|
|
return this._isLoading.value
|
|
}
|
|
|
|
/**
|
|
* Cleanup
|
|
*/
|
|
protected async onDestroy(): Promise<void> {
|
|
if (this.currentUnsubscribe) {
|
|
this.currentUnsubscribe()
|
|
}
|
|
if (this.deletionUnsubscribe) {
|
|
this.deletionUnsubscribe()
|
|
}
|
|
this._eventReactions.clear()
|
|
this.monitoredEvents.clear()
|
|
this.deletedReactions.clear()
|
|
}
|
|
} |