Add ReactionService and integrate reactions functionality into NostrFeed module
- Introduced ReactionService to manage event reactions, including likes and dislikes. - Updated NostrFeed module to register ReactionService in the DI container and initialize it during installation. - Enhanced NostrFeed.vue to utilize the reactions service for displaying and managing likes on posts. - Created useReactions composable for handling reactions, including fetching event reactions and subscribing to updates. These changes enhance user engagement by allowing users to interact with posts through reactions, improving the overall experience within the feed. Refactor reactions handling in NostrFeed module - Renamed `likeEvent` to `toggleLike` in the useReactions composable to better reflect its functionality of toggling likes. - Updated NostrFeed.vue to utilize the new `toggleLike` method for handling like interactions. - Enhanced ReactionService to support deletion of reactions and improved handling of reaction events, including subscription to deletion events. - Added logic to manage user reaction IDs for better tracking of likes and unlikes. These changes streamline the reactions functionality, improving user interaction and feedback within the NostrFeed. Refactor content filters and event inclusion logic in NostrFeed module - Removed market-related content filters from the presets, as they now have a dedicated section. - Updated the handling of reactions in content filters, clarifying that reactions are processed separately by the ReactionService. - Enhanced the FeedService to exclude reactions and deletions from the main feed, ensuring cleaner event management. These changes streamline the content filtering process and improve the clarity of event handling within the NostrFeed. Refactor content filters and FeedService logic for marketplace separation - Removed marketplace-related filters from the general presets in content-filters.ts, as they now have a dedicated section. - Updated FeedService to exclude marketplace events from the main feed, ensuring clearer event management. - Adjusted Home.vue to reflect the removal of the marketplace filter preset. These changes streamline content filtering and improve the organization of marketplace events within the NostrFeed module. Enhance ReactionService to support global deletion monitoring and improve reaction handling - Added functionality to monitor deletion events for reactions, ensuring accurate updates when reactions are deleted. - Implemented logic to handle deletion requests, processing only those from the original reaction authors as per NIP-09 spec. - Updated reaction management to ensure only the latest reaction from each user is counted, improving the accuracy of like/dislike tallies. - Refactored event reaction updates to clear deleted reactions and maintain a clean state. These changes enhance the reliability and user experience of the reactions feature within the NostrFeed module.
This commit is contained in:
parent
45391cbaa1
commit
005b78bf0e
8 changed files with 698 additions and 12 deletions
|
|
@ -135,6 +135,7 @@ export const SERVICE_TOKENS = {
|
||||||
// Feed services
|
// Feed services
|
||||||
FEED_SERVICE: Symbol('feedService'),
|
FEED_SERVICE: Symbol('feedService'),
|
||||||
PROFILE_SERVICE: Symbol('profileService'),
|
PROFILE_SERVICE: Symbol('profileService'),
|
||||||
|
REACTION_SERVICE: Symbol('reactionService'),
|
||||||
|
|
||||||
// Events services
|
// Events services
|
||||||
EVENTS_SERVICE: Symbol('eventsService'),
|
EVENTS_SERVICE: Symbol('eventsService'),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { formatDistanceToNow } from 'date-fns'
|
||||||
import { Megaphone, RefreshCw, AlertCircle, Reply, Heart, Share } from 'lucide-vue-next'
|
import { Megaphone, RefreshCw, AlertCircle, Reply, Heart, Share } from 'lucide-vue-next'
|
||||||
import { useFeed } from '../composables/useFeed'
|
import { useFeed } from '../composables/useFeed'
|
||||||
import { useProfiles } from '../composables/useProfiles'
|
import { useProfiles } from '../composables/useProfiles'
|
||||||
|
import { useReactions } from '../composables/useReactions'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
import type { ContentFilter } from '../services/FeedService'
|
import type { ContentFilter } from '../services/FeedService'
|
||||||
import MarketProduct from './MarketProduct.vue'
|
import MarketProduct from './MarketProduct.vue'
|
||||||
|
|
@ -39,11 +40,20 @@ const { posts: notes, isLoading, error, refreshFeed } = useFeed({
|
||||||
// Use profiles service for display names
|
// Use profiles service for display names
|
||||||
const { getDisplayName, fetchProfiles } = useProfiles()
|
const { getDisplayName, fetchProfiles } = useProfiles()
|
||||||
|
|
||||||
// Watch for new posts and fetch their profiles
|
// Use reactions service for likes/hearts
|
||||||
|
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
||||||
|
|
||||||
|
// Watch for new posts and fetch their profiles and reactions
|
||||||
watch(notes, async (newNotes) => {
|
watch(notes, async (newNotes) => {
|
||||||
if (newNotes.length > 0) {
|
if (newNotes.length > 0) {
|
||||||
const pubkeys = [...new Set(newNotes.map(note => note.pubkey))]
|
const pubkeys = [...new Set(newNotes.map(note => note.pubkey))]
|
||||||
await fetchProfiles(pubkeys)
|
const eventIds = newNotes.map(note => note.id)
|
||||||
|
|
||||||
|
// Fetch profiles and subscribe to reactions in parallel
|
||||||
|
await Promise.all([
|
||||||
|
fetchProfiles(pubkeys),
|
||||||
|
subscribeToReactions(eventIds)
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
|
@ -118,6 +128,15 @@ function onReplyToNote(note: any) {
|
||||||
pubkey: note.pubkey
|
pubkey: note.pubkey
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle like/heart reaction toggle
|
||||||
|
async function onToggleLike(note: any) {
|
||||||
|
try {
|
||||||
|
await toggleLike(note.id, note.pubkey, note.kind)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle like:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -303,9 +322,19 @@ function onReplyToNote(note: any) {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
|
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
|
||||||
|
:class="{ 'text-red-500 hover:text-red-600': getEventReactions(note.id).userHasLiked }"
|
||||||
|
@click="onToggleLike(note)"
|
||||||
>
|
>
|
||||||
<Heart class="h-3.5 w-3.5 sm:mr-1" />
|
<Heart
|
||||||
<span class="hidden sm:inline">Like</span>
|
class="h-3.5 w-3.5 sm:mr-1"
|
||||||
|
:class="{ 'fill-current': getEventReactions(note.id).userHasLiked }"
|
||||||
|
/>
|
||||||
|
<span class="hidden sm:inline">
|
||||||
|
{{ getEventReactions(note.id).userHasLiked ? 'Liked' : 'Like' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="getEventReactions(note.id).likes > 0" class="ml-1 text-xs">
|
||||||
|
{{ getEventReactions(note.id).likes }}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -109,11 +109,10 @@ export const FILTER_PRESETS: Record<string, ContentFilter[]> = {
|
||||||
// Basic presets
|
// Basic presets
|
||||||
all: [
|
all: [
|
||||||
CONTENT_FILTERS.textNotes,
|
CONTENT_FILTERS.textNotes,
|
||||||
CONTENT_FILTERS.marketStalls,
|
|
||||||
CONTENT_FILTERS.marketProducts,
|
|
||||||
CONTENT_FILTERS.marketGeneral,
|
|
||||||
CONTENT_FILTERS.calendarEvents,
|
CONTENT_FILTERS.calendarEvents,
|
||||||
CONTENT_FILTERS.longFormContent
|
CONTENT_FILTERS.longFormContent
|
||||||
|
// Note: reactions (kind 7) are handled separately by ReactionService
|
||||||
|
// Note: market items removed - they have their own dedicated section
|
||||||
],
|
],
|
||||||
|
|
||||||
announcements: [
|
announcements: [
|
||||||
|
|
@ -123,22 +122,22 @@ export const FILTER_PRESETS: Record<string, ContentFilter[]> = {
|
||||||
|
|
||||||
community: [
|
community: [
|
||||||
CONTENT_FILTERS.communityPosts,
|
CONTENT_FILTERS.communityPosts,
|
||||||
CONTENT_FILTERS.reactions,
|
|
||||||
CONTENT_FILTERS.reposts
|
CONTENT_FILTERS.reposts
|
||||||
|
// Note: reactions are handled separately for counts
|
||||||
],
|
],
|
||||||
|
|
||||||
marketplace: [
|
marketplace: [
|
||||||
CONTENT_FILTERS.marketStalls,
|
CONTENT_FILTERS.marketStalls,
|
||||||
CONTENT_FILTERS.marketProducts,
|
CONTENT_FILTERS.marketProducts,
|
||||||
CONTENT_FILTERS.marketGeneral,
|
CONTENT_FILTERS.marketGeneral
|
||||||
CONTENT_FILTERS.textNotes // Include text posts that may contain marketplace content
|
// Marketplace is a separate section - not mixed with regular feed
|
||||||
],
|
],
|
||||||
|
|
||||||
social: [
|
social: [
|
||||||
CONTENT_FILTERS.textNotes,
|
CONTENT_FILTERS.textNotes,
|
||||||
CONTENT_FILTERS.reactions,
|
|
||||||
CONTENT_FILTERS.reposts,
|
CONTENT_FILTERS.reposts,
|
||||||
CONTENT_FILTERS.chatMessages
|
CONTENT_FILTERS.chatMessages
|
||||||
|
// Note: reactions are for interaction counts, not displayed as posts
|
||||||
],
|
],
|
||||||
|
|
||||||
events: [
|
events: [
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import NostrFeed from './components/NostrFeed.vue'
|
||||||
import { useFeed } from './composables/useFeed'
|
import { useFeed } from './composables/useFeed'
|
||||||
import { FeedService } from './services/FeedService'
|
import { FeedService } from './services/FeedService'
|
||||||
import { ProfileService } from './services/ProfileService'
|
import { ProfileService } from './services/ProfileService'
|
||||||
|
import { ReactionService } from './services/ReactionService'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nostr Feed Module Plugin
|
* Nostr Feed Module Plugin
|
||||||
|
|
@ -21,9 +22,11 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
// Register services in DI container
|
// Register services in DI container
|
||||||
const feedService = new FeedService()
|
const feedService = new FeedService()
|
||||||
const profileService = new ProfileService()
|
const profileService = new ProfileService()
|
||||||
|
const reactionService = new ReactionService()
|
||||||
|
|
||||||
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
||||||
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
||||||
|
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
||||||
console.log('nostr-feed module: Services registered in DI container')
|
console.log('nostr-feed module: Services registered in DI container')
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
|
|
@ -36,6 +39,10 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
profileService.initialize({
|
profileService.initialize({
|
||||||
waitForDependencies: true,
|
waitForDependencies: true,
|
||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
|
}),
|
||||||
|
reactionService.initialize({
|
||||||
|
waitForDependencies: true,
|
||||||
|
maxRetries: 3
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
console.log('nostr-feed module: Services initialized')
|
console.log('nostr-feed module: Services initialized')
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,17 @@ export class FeedService extends BaseService {
|
||||||
* Check if event should be included in feed
|
* Check if event should be included in feed
|
||||||
*/
|
*/
|
||||||
private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean {
|
private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean {
|
||||||
|
// Never include reactions (kind 7) or deletions (kind 5) in the main feed
|
||||||
|
// These should only be processed by the ReactionService
|
||||||
|
if (event.kind === 7 || event.kind === 5) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude marketplace events (they have their own dedicated section)
|
||||||
|
if (event.kind === 30017 || event.kind === 30018 || event.kind === 30019) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const isAdminPost = config.adminPubkeys?.includes(event.pubkey) || false
|
const isAdminPost = config.adminPubkeys?.includes(event.pubkey) || false
|
||||||
|
|
||||||
// For custom content filters, check if event matches any active filter
|
// For custom content filters, check if event matches any active filter
|
||||||
|
|
|
||||||
538
src/modules/nostr-feed/services/ReactionService.ts
Normal file
538
src/modules/nostr-feed/services/ReactionService.ts
Normal file
|
|
@ -0,0 +1,538 @@
|
||||||
|
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 deletionSubscription: string | null = null
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.deletionSubscription = subscriptionId
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -125,7 +125,6 @@ const replyTo = ref<ReplyToNote | undefined>()
|
||||||
const quickFilterPresets = {
|
const quickFilterPresets = {
|
||||||
all: { label: 'All', filters: FILTER_PRESETS.all },
|
all: { label: 'All', filters: FILTER_PRESETS.all },
|
||||||
announcements: { label: 'News', filters: FILTER_PRESETS.announcements },
|
announcements: { label: 'News', filters: FILTER_PRESETS.announcements },
|
||||||
marketplace: { label: 'Market', filters: FILTER_PRESETS.marketplace },
|
|
||||||
social: { label: 'Social', filters: FILTER_PRESETS.social },
|
social: { label: 'Social', filters: FILTER_PRESETS.social },
|
||||||
events: { label: 'Events', filters: FILTER_PRESETS.events }
|
events: { label: 'Events', filters: FILTER_PRESETS.events }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue