Compare commits
No commits in common. "221c927c745a4e3e6033d425a35564c304034ec3" and "464ee642deca23b9832433ddf96107a658cc4607" have entirely different histories.
221c927c74
...
464ee642de
2 changed files with 687 additions and 0 deletions
102
src/modules/nostr-feed/composables/useReactions.ts
Normal file
102
src/modules/nostr-feed/composables/useReactions.ts
Normal file
|
|
@ -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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
585
src/modules/nostr-feed/services/ReactionService.ts
Normal file
585
src/modules/nostr-feed/services/ReactionService.ts
Normal file
|
|
@ -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<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