chore(nostr-feed): delete dead-code ReactionService + useReactions duplicates #80
2 changed files with 0 additions and 687 deletions
chore(nostr-feed): delete dead-code ReactionService + useReactions duplicates
The nostr-feed module had its own copies of ReactionService and useReactions that were never wired in — the live implementations live in src/modules/base/. The nostr-feed copy of ReactionService was a strict subset of the base copy (missing toggleLikeEvent / toggleDislikeEvent) and was never registered in DI. The nostr-feed copy of useReactions was identical to the base copy modulo the type import path; the one consumer (NostrFeed.vue) already imports from the base path. Closes #78. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit
99ca0bf64a
|
|
@ -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<ReactionService>(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<void> => {
|
|
||||||
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<void> => {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<string, EventReactions>())
|
|
||||||
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<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')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<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) 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<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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a dislike reaction to an event
|
|
||||||
*/
|
|
||||||
async dislikeEvent(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 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<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.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<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()
|
|
||||||
}
|
|
||||||
// deletionUnsubscribe is no longer used - deletions handled by FeedService
|
|
||||||
this._eventReactions.clear()
|
|
||||||
this.monitoredEvents.clear()
|
|
||||||
this.deletedReactions.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue