diff --git a/src/app.config.ts b/src/app.config.ts index e461872..05a0034 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -25,15 +25,34 @@ export const appConfig: AppConfig = { }, 'nostr-feed': { name: 'nostr-feed', - enabled: true, + enabled: false, // Disabled - replaced by links module lazy: false, config: { - refreshInterval: 30000, // 30 seconds + refreshInterval: 30000, maxPosts: 100, adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]'), feedTypes: ['announcements', 'general'] } }, + links: { + name: 'links', + enabled: true, + lazy: false, + config: { + maxSubmissions: 50, + corsProxyUrl: import.meta.env.VITE_CORS_PROXY_URL || '', + adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]') + } + }, + tasks: { + name: 'tasks', + enabled: true, + lazy: false, + config: { + maxTasks: 200, + adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]') + } + }, market: { name: 'market', enabled: true, diff --git a/src/app.ts b/src/app.ts index 6878880..7aa5452 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,8 @@ import eventsModule from './modules/events' import marketModule from './modules/market' import walletModule from './modules/wallet' import expensesModule from './modules/expenses' +import linksModule from './modules/links' +import tasksModule from './modules/tasks' // Root component import App from './App.vue' @@ -45,7 +47,9 @@ export async function createAppInstance() { ...eventsModule.routes || [], ...marketModule.routes || [], ...walletModule.routes || [], - ...expensesModule.routes || [] + ...expensesModule.routes || [], + ...linksModule.routes || [], + ...tasksModule.routes || [] ].filter(Boolean) // Create router with all routes available immediately @@ -135,6 +139,20 @@ export async function createAppInstance() { ) } + // Register links module + if (appConfig.modules.links?.enabled) { + moduleRegistrations.push( + pluginManager.register(linksModule, appConfig.modules.links) + ) + } + + // Register tasks module + if (appConfig.modules.tasks?.enabled) { + moduleRegistrations.push( + pluginManager.register(tasksModule, appConfig.modules.tasks) + ) + } + // Wait for all modules to register await Promise.all(moduleRegistrations) diff --git a/src/composables/useModularNavigation.ts b/src/composables/useModularNavigation.ts index 5ff695f..b03bbe8 100644 --- a/src/composables/useModularNavigation.ts +++ b/src/composables/useModularNavigation.ts @@ -42,11 +42,19 @@ export function useModularNavigation() { }) } + if (appConfig.modules.tasks?.enabled) { + items.push({ + name: 'Tasks', + href: '/tasks', + requiresAuth: true + }) + } + if (appConfig.modules.chat.enabled) { - items.push({ - name: t('nav.chat'), - href: '/chat', - requiresAuth: true + items.push({ + name: t('nav.chat'), + href: '/chat', + requiresAuth: true }) } diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 0d27524..f2cf091 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -136,8 +136,16 @@ export const SERVICE_TOKENS = { FEED_SERVICE: Symbol('feedService'), PROFILE_SERVICE: Symbol('profileService'), REACTION_SERVICE: Symbol('reactionService'), + + // Tasks services + TASK_SERVICE: Symbol('taskService'), + /** @deprecated Use TASK_SERVICE instead */ SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'), + // Links services + SUBMISSION_SERVICE: Symbol('submissionService'), + LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'), + // Nostr metadata services NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'), diff --git a/src/modules/base/composables/useProfiles.ts b/src/modules/base/composables/useProfiles.ts new file mode 100644 index 0000000..c79472f --- /dev/null +++ b/src/modules/base/composables/useProfiles.ts @@ -0,0 +1,90 @@ +import { ref, computed } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { ProfileService } from '../nostr/ProfileService' + +/** + * Composable for managing user profiles + */ +export function useProfiles() { + const profileService = injectService(SERVICE_TOKENS.PROFILE_SERVICE) + + // Reactive state + const isLoading = ref(false) + const error = ref(null) + + /** + * Get display name for a pubkey + */ + const getDisplayName = (pubkey: string): string => { + if (!profileService) return formatPubkey(pubkey) + return profileService.getDisplayName(pubkey) + } + + /** + * Fetch profiles for a list of pubkeys + */ + const fetchProfiles = async (pubkeys: string[]): Promise => { + if (!profileService || pubkeys.length === 0) return + + try { + isLoading.value = true + error.value = null + await profileService.fetchProfiles(pubkeys) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to fetch profiles' + console.error('Failed to fetch profiles:', err) + } finally { + isLoading.value = false + } + } + + /** + * Subscribe to profile updates for active users + */ + const subscribeToProfileUpdates = async (pubkeys: string[]): Promise => { + if (!profileService) return + + try { + await profileService.subscribeToProfileUpdates(pubkeys) + } catch (err) { + console.error('Failed to subscribe to profile updates:', err) + } + } + + /** + * Get full profile for a pubkey + */ + const getProfile = async (pubkey: string) => { + if (!profileService) return null + return await profileService.getProfile(pubkey) + } + + /** + * Format pubkey as fallback display name + */ + const formatPubkey = (pubkey: string): string => { + return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}` + } + + /** + * Get all cached profiles + */ + const profiles = computed(() => { + if (!profileService) return new Map() + return profileService.profiles + }) + + return { + // State + isLoading, + error, + profiles, + + // Methods + getDisplayName, + fetchProfiles, + subscribeToProfileUpdates, + getProfile, + formatPubkey + } +} diff --git a/src/modules/base/composables/useReactions.ts b/src/modules/base/composables/useReactions.ts new file mode 100644 index 0000000..a67fcb1 --- /dev/null +++ b/src/modules/base/composables/useReactions.ts @@ -0,0 +1,102 @@ +import { computed } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { ReactionService, EventReactions } from '../nostr/ReactionService' +import { useToast } from '@/core/composables/useToast' + +/** + * Composable for managing reactions + */ +export function useReactions() { + const reactionService = injectService(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 => { + 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 => { + 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 + } +} diff --git a/src/modules/base/index.ts b/src/modules/base/index.ts index d70a81f..ad9cf20 100644 --- a/src/modules/base/index.ts +++ b/src/modules/base/index.ts @@ -3,6 +3,8 @@ import type { ModulePlugin } from '@/core/types' import { container, SERVICE_TOKENS } from '@/core/di-container' import { relayHub } from './nostr/relay-hub' import { NostrMetadataService } from './nostr/nostr-metadata-service' +import { ProfileService } from './nostr/ProfileService' +import { ReactionService } from './nostr/ReactionService' // Import auth services import { auth } from './auth/auth-service' @@ -28,6 +30,8 @@ const invoiceService = new InvoiceService() const lnbitsAPI = new LnbitsAPI() const imageUploadService = new ImageUploadService() const nostrMetadataService = new NostrMetadataService() +const profileService = new ProfileService() +const reactionService = new ReactionService() /** * Base Module Plugin @@ -68,6 +72,10 @@ export const baseModule: ModulePlugin = { // Register image upload service container.provide(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE, imageUploadService) + // Register shared Nostr services (used by multiple modules) + container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService) + container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService) + // Register PWA service container.provide('pwaService', pwaService) @@ -106,6 +114,14 @@ export const baseModule: ModulePlugin = { waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub maxRetries: 3 }) + await profileService.initialize({ + waitForDependencies: true, // ProfileService depends on RelayHub + maxRetries: 3 + }) + await reactionService.initialize({ + waitForDependencies: true, // ReactionService depends on RelayHub and AuthService + maxRetries: 3 + }) // InvoiceService doesn't need initialization as it's not a BaseService console.log('✅ Base module installed successfully') @@ -123,6 +139,8 @@ export const baseModule: ModulePlugin = { await toastService.dispose() await imageUploadService.dispose() await nostrMetadataService.dispose() + await profileService.dispose() + await reactionService.dispose() // InvoiceService doesn't need disposal as it's not a BaseService await lnbitsAPI.dispose() @@ -131,6 +149,8 @@ export const baseModule: ModulePlugin = { container.remove(SERVICE_TOKENS.INVOICE_SERVICE) container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) container.remove(SERVICE_TOKENS.NOSTR_METADATA_SERVICE) + container.remove(SERVICE_TOKENS.PROFILE_SERVICE) + container.remove(SERVICE_TOKENS.REACTION_SERVICE) console.log('✅ Base module uninstalled') }, @@ -145,7 +165,9 @@ export const baseModule: ModulePlugin = { invoiceService, pwaService, imageUploadService, - nostrMetadataService + nostrMetadataService, + profileService, + reactionService }, // No routes - base module is pure infrastructure diff --git a/src/modules/base/nostr/ProfileService.ts b/src/modules/base/nostr/ProfileService.ts new file mode 100644 index 0000000..18b3301 --- /dev/null +++ b/src/modules/base/nostr/ProfileService.ts @@ -0,0 +1,274 @@ +import { reactive } from 'vue' +import { BaseService } from '@/core/base/BaseService' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { Event as NostrEvent, Filter } from 'nostr-tools' + +export interface UserProfile { + pubkey: string + name?: string + display_name?: string + about?: string + picture?: string + nip05?: string + updated_at: number +} + +export class ProfileService extends BaseService { + protected readonly metadata = { + name: 'ProfileService', + version: '1.0.0', + dependencies: [] + } + + protected relayHub: any = null + + // Profile cache - reactive for UI updates + private _profiles = reactive(new Map()) + private currentSubscription: string | null = null + private currentUnsubscribe: (() => void) | null = null + + // Track which profiles we've requested to avoid duplicate requests + private requestedProfiles = new Set() + + protected async onInitialize(): Promise { + console.log('ProfileService: Starting initialization...') + + this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) + + if (!this.relayHub) { + throw new Error('RelayHub service not available') + } + + console.log('ProfileService: Initialization complete') + } + + /** + * Get profile for a pubkey, fetching if not cached + */ + async getProfile(pubkey: string): Promise { + // Return cached profile if available + if (this._profiles.has(pubkey)) { + return this._profiles.get(pubkey)! + } + + // If not requested yet, fetch it + if (!this.requestedProfiles.has(pubkey)) { + await this.fetchProfile(pubkey) + } + + return this._profiles.get(pubkey) || null + } + + /** + * Get display name for a pubkey (returns formatted pubkey if no profile) + */ + getDisplayName(pubkey: string): string { + const profile = this._profiles.get(pubkey) + if (profile?.display_name) return profile.display_name + if (profile?.name) return profile.name + + // Return formatted pubkey as fallback + return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}` + } + + /** + * Fetch profile for specific pubkey + */ + private async fetchProfile(pubkey: string): Promise { + if (!this.relayHub || this.requestedProfiles.has(pubkey)) { + return + } + + this.requestedProfiles.add(pubkey) + + try { + if (!this.relayHub.isConnected) { + await this.relayHub.connect() + } + + const subscriptionId = `profile-${pubkey}-${Date.now()}` + + const filter: Filter = { + kinds: [0], // Profile metadata + authors: [pubkey], + limit: 1 + } + + console.log(`ProfileService: Fetching profile for ${pubkey.slice(0, 8)}...`) + + const unsubscribe = this.relayHub.subscribe({ + id: subscriptionId, + filters: [filter], + onEvent: (event: NostrEvent) => { + this.handleProfileEvent(event) + }, + onEose: () => { + console.log(`Profile subscription ${subscriptionId} complete`) + // Clean up subscription after getting the profile + if (unsubscribe) { + unsubscribe() + } + } + }) + + } catch (error) { + console.error(`Failed to fetch profile for ${pubkey}:`, error) + this.requestedProfiles.delete(pubkey) // Allow retry + } + } + + /** + * Handle incoming profile event + */ + private handleProfileEvent(event: NostrEvent): void { + try { + const metadata = JSON.parse(event.content) + + const profile: UserProfile = { + pubkey: event.pubkey, + name: metadata.name, + display_name: metadata.display_name, + about: metadata.about, + picture: metadata.picture, + nip05: metadata.nip05, + updated_at: event.created_at + } + + // Only update if this is newer than what we have + const existing = this._profiles.get(event.pubkey) + if (!existing || event.created_at > existing.updated_at) { + this._profiles.set(event.pubkey, profile) + console.log(`ProfileService: Updated profile for ${event.pubkey.slice(0, 8)}...`, profile.display_name || profile.name) + } + + } catch (error) { + console.error('Failed to parse profile metadata:', error) + } + } + + /** + * Bulk fetch profiles for multiple pubkeys + */ + async fetchProfiles(pubkeys: string[]): Promise { + const unfetchedPubkeys = pubkeys.filter(pubkey => + !this._profiles.has(pubkey) && !this.requestedProfiles.has(pubkey) + ) + + if (unfetchedPubkeys.length === 0) return + + console.log(`ProfileService: Bulk fetching ${unfetchedPubkeys.length} profiles`) + + try { + if (!this.relayHub?.isConnected) { + await this.relayHub?.connect() + } + + const subscriptionId = `profiles-bulk-${Date.now()}` + + // Mark all as requested + unfetchedPubkeys.forEach(pubkey => this.requestedProfiles.add(pubkey)) + + const filter: Filter = { + kinds: [0], + authors: unfetchedPubkeys, + limit: unfetchedPubkeys.length + } + + const unsubscribe = this.relayHub.subscribe({ + id: subscriptionId, + filters: [filter], + onEvent: (event: NostrEvent) => { + this.handleProfileEvent(event) + }, + onEose: () => { + console.log(`Bulk profile subscription ${subscriptionId} complete`) + if (unsubscribe) { + unsubscribe() + } + } + }) + + } catch (error) { + console.error('Failed to bulk fetch profiles:', error) + // Remove from requested so they can be retried + unfetchedPubkeys.forEach(pubkey => this.requestedProfiles.delete(pubkey)) + } + } + + /** + * Subscribe to real-time profile updates for active users + */ + async subscribeToProfileUpdates(pubkeys: string[]): Promise { + if (this.currentSubscription) { + await this.unsubscribeFromProfiles() + } + + if (pubkeys.length === 0) return + + try { + if (!this.relayHub?.isConnected) { + await this.relayHub?.connect() + } + + const subscriptionId = `profile-updates-${Date.now()}` + + const filter: Filter = { + kinds: [0], + authors: pubkeys + } + + console.log(`ProfileService: Subscribing to profile updates for ${pubkeys.length} users`) + + const unsubscribe = this.relayHub.subscribe({ + id: subscriptionId, + filters: [filter], + onEvent: (event: NostrEvent) => { + this.handleProfileEvent(event) + }, + onEose: () => { + console.log(`Profile updates subscription ${subscriptionId} ready`) + } + }) + + this.currentSubscription = subscriptionId + this.currentUnsubscribe = unsubscribe + + } catch (error) { + console.error('Failed to subscribe to profile updates:', error) + } + } + + /** + * Unsubscribe from profile updates + */ + async unsubscribeFromProfiles(): Promise { + if (this.currentUnsubscribe) { + this.currentUnsubscribe() + this.currentSubscription = null + this.currentUnsubscribe = null + } + } + + /** + * Clear profile cache + */ + clearCache(): void { + this._profiles.clear() + this.requestedProfiles.clear() + } + + /** + * Get all cached profiles + */ + get profiles(): Map { + return this._profiles + } + + /** + * Cleanup + */ + protected async onDestroy(): Promise { + await this.unsubscribeFromProfiles() + this.clearCache() + } +} diff --git a/src/modules/base/nostr/ReactionService.ts b/src/modules/base/nostr/ReactionService.ts new file mode 100644 index 0000000..6058c70 --- /dev/null +++ b/src/modules/base/nostr/ReactionService.ts @@ -0,0 +1,581 @@ +import { ref, reactive } from 'vue' +import { BaseService } from '@/core/base/BaseService' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import { finalizeEvent, type EventTemplate } from 'nostr-tools' +import type { Event as NostrEvent } from 'nostr-tools' + +export interface Reaction { + id: string + eventId: string // The event being reacted to + pubkey: string // Who reacted + content: string // The reaction content ('+', '-', emoji) + created_at: number +} + +export interface EventReactions { + eventId: string + likes: number + dislikes: number + totalReactions: number + userHasLiked: boolean + userHasDisliked: boolean + userReactionId?: string // Track the user's reaction ID for deletion + reactions: Reaction[] +} + +export class ReactionService extends BaseService { + protected readonly metadata = { + name: 'ReactionService', + version: '1.0.0', + dependencies: [] + } + + protected relayHub: any = null + protected authService: any = null + + // Reaction state - indexed by event ID + private _eventReactions = reactive(new Map()) + private _isLoading = ref(false) + + // Track reaction subscription + private currentSubscription: string | null = null + private currentUnsubscribe: (() => void) | null = null + + // Track which events we're monitoring + private monitoredEvents = new Set() + + // Track deleted reactions to hide them + private deletedReactions = new Set() + + protected async onInitialize(): Promise { + console.log('ReactionService: Starting initialization...') + + this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) + this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) + + if (!this.relayHub) { + throw new Error('RelayHub service not available') + } + + console.log('ReactionService: Initialization complete') + } + + /** + * Get reactions for a specific event + */ + getEventReactions(eventId: string): EventReactions { + if (!this._eventReactions.has(eventId)) { + this._eventReactions.set(eventId, { + eventId, + likes: 0, + dislikes: 0, + totalReactions: 0, + userHasLiked: false, + userHasDisliked: false, + reactions: [] + }) + } + return this._eventReactions.get(eventId)! + } + + /** + * Subscribe to reactions for a list of event IDs + */ + async subscribeToReactions(eventIds: string[]): Promise { + if (eventIds.length === 0) return + + // Filter out events we're already monitoring + const newEventIds = eventIds.filter(id => !this.monitoredEvents.has(id)) + if (newEventIds.length === 0) return + + console.log(`ReactionService: Subscribing to reactions for ${newEventIds.length} events`) + + try { + if (!this.relayHub?.isConnected) { + await this.relayHub?.connect() + } + + // Add to monitored set + newEventIds.forEach(id => this.monitoredEvents.add(id)) + + const subscriptionId = `reactions-${Date.now()}` + + // Subscribe to reactions (kind 7) for these events + 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 + */ + 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 when a kind 5 event with k=7 is received) + */ + 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() + const dislikedUsers = new Set() + let userHasLiked = false + let userHasDisliked = false + let userReactionId: string | undefined + + // Group reactions by user, keeping only the most recent + const latestReactionsByUser = new Map() + + for (const reaction of eventReactions.reactions) { + // Skip deleted reactions + if (this.deletedReactions.has(reaction.id)) { + continue + } + + // Keep only the latest reaction from each user + const existing = latestReactionsByUser.get(reaction.pubkey) + if (!existing || reaction.created_at > existing.created_at) { + latestReactionsByUser.set(reaction.pubkey, reaction) + } + } + + // Now count unique reactions + for (const reaction of latestReactionsByUser.values()) { + const isLike = reaction.content === '+' || reaction.content === '❤️' || reaction.content === '' + const isDislike = reaction.content === '-' + + if (isLike) { + likedUsers.add(reaction.pubkey) + if (userPubkey && reaction.pubkey === userPubkey) { + userHasLiked = true + userReactionId = reaction.id + } + } else if (isDislike) { + dislikedUsers.add(reaction.pubkey) + if (userPubkey && reaction.pubkey === userPubkey) { + userHasDisliked = true + userReactionId = reaction.id + } + } + } + + // Update the reactive state with unique user counts + eventReactions.likes = likedUsers.size + eventReactions.dislikes = dislikedUsers.size + eventReactions.totalReactions = latestReactionsByUser.size + eventReactions.userHasLiked = userHasLiked + eventReactions.userHasDisliked = userHasDisliked + eventReactions.userReactionId = userReactionId + } + + /** + * Send a heart reaction (like) to an event + */ + async likeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise { + if (!this.authService?.isAuthenticated?.value) { + throw new Error('Must be authenticated to react') + } + + if (!this.relayHub?.isConnected) { + throw new Error('Not connected to relays') + } + + const userPubkey = this.authService.user.value?.pubkey + const userPrivkey = this.authService.user.value?.prvkey + + if (!userPubkey || !userPrivkey) { + throw new Error('User keys not available') + } + + // Check if user already liked this event + const eventReactions = this.getEventReactions(eventId) + if (eventReactions.userHasLiked) { + throw new Error('Already liked this event') + } + + try { + this._isLoading.value = true + + // Create reaction event template according to NIP-25 + const eventTemplate: EventTemplate = { + kind: 7, // Reaction + content: '+', // Like reaction + tags: [ + ['e', eventId, '', eventPubkey], // Event being reacted to + ['p', eventPubkey], // Author of the event being reacted to + ['k', eventKind.toString()] // Kind of the event being reacted to + ], + created_at: Math.floor(Date.now() / 1000) + } + + // 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 + } + } + + /** + * Send a dislike reaction to an event + */ + async dislikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise { + if (!this.authService?.isAuthenticated?.value) { + throw new Error('Must be authenticated to react') + } + + if (!this.relayHub?.isConnected) { + throw new Error('Not connected to relays') + } + + const userPubkey = this.authService.user.value?.pubkey + const userPrivkey = this.authService.user.value?.prvkey + + if (!userPubkey || !userPrivkey) { + throw new Error('User keys not available') + } + + const eventReactions = this.getEventReactions(eventId) + if (eventReactions.userHasDisliked) { + throw new Error('Already disliked this event') + } + + try { + this._isLoading.value = true + + const eventTemplate: EventTemplate = { + kind: 7, + content: '-', // Dislike reaction + tags: [ + ['e', eventId, '', eventPubkey], + ['p', eventPubkey], + ['k', eventKind.toString()] + ], + created_at: Math.floor(Date.now() / 1000) + } + + const privkeyBytes = this.hexToUint8Array(userPrivkey) + const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) + + await this.relayHub.publishEvent(signedEvent) + this.handleReactionEvent(signedEvent) + + } catch (error) { + console.error('Failed to dislike event:', error) + throw error + } finally { + this._isLoading.value = false + } + } + + /** + * Remove a like from an event (unlike) using NIP-09 deletion events + */ + async unlikeEvent(eventId: string): Promise { + if (!this.authService?.isAuthenticated?.value) { + throw new Error('Must be authenticated to remove reaction') + } + + if (!this.relayHub?.isConnected) { + throw new Error('Not connected to relays') + } + + const userPubkey = this.authService.user.value?.pubkey + const userPrivkey = this.authService.user.value?.prvkey + + if (!userPubkey || !userPrivkey) { + throw new Error('User keys not available') + } + + // Get the user's reaction ID to delete + const eventReactions = this.getEventReactions(eventId) + + if (!eventReactions.userHasLiked || !eventReactions.userReactionId) { + throw new Error('No reaction to remove') + } + + try { + this._isLoading.value = true + + // Create deletion event according to NIP-09 + const eventTemplate: EventTemplate = { + kind: 5, // Deletion request + content: '', // Empty content or reason + tags: [ + ['e', eventReactions.userReactionId], // The reaction event to delete + ['k', '7'] // Kind of event being deleted (reaction) + ], + created_at: Math.floor(Date.now() / 1000) + } + + // 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 + } + } + + /** + * Remove a dislike from an event using NIP-09 deletion events + */ + async undislikeEvent(eventId: string): Promise { + if (!this.authService?.isAuthenticated?.value) { + throw new Error('Must be authenticated to remove reaction') + } + + if (!this.relayHub?.isConnected) { + throw new Error('Not connected to relays') + } + + const userPubkey = this.authService.user.value?.pubkey + const userPrivkey = this.authService.user.value?.prvkey + + if (!userPubkey || !userPrivkey) { + throw new Error('User keys not available') + } + + const eventReactions = this.getEventReactions(eventId) + + if (!eventReactions.userHasDisliked || !eventReactions.userReactionId) { + throw new Error('No dislike to remove') + } + + try { + this._isLoading.value = true + + const eventTemplate: EventTemplate = { + kind: 5, + content: '', + tags: [ + ['e', eventReactions.userReactionId], + ['k', '7'] + ], + created_at: Math.floor(Date.now() / 1000) + } + + const privkeyBytes = this.hexToUint8Array(userPrivkey) + const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) + + const result = await this.relayHub.publishEvent(signedEvent) + console.log(`ReactionService: Dislike deletion published to ${result.success}/${result.total} relays`) + + this.handleDeletionEvent(signedEvent) + + } catch (error) { + console.error('Failed to undislike event:', error) + throw error + } finally { + this._isLoading.value = false + } + } + + /** + * Toggle like on an event - like if not liked, unlike if already liked + */ + async toggleLikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise { + const eventReactions = this.getEventReactions(eventId) + + if (eventReactions.userHasLiked) { + // Unlike the event + await this.unlikeEvent(eventId) + } else { + // Like the event + await this.likeEvent(eventId, eventPubkey, eventKind) + } + } + + /** + * Toggle dislike on an event + */ + async toggleDislikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise { + const eventReactions = this.getEventReactions(eventId) + + if (eventReactions.userHasDisliked) { + await this.undislikeEvent(eventId) + } else { + await this.dislikeEvent(eventId, eventPubkey, eventKind) + } + } + + /** + * Helper function to convert hex string to Uint8Array + */ + private hexToUint8Array(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + } + return bytes + } + + /** + * Get all event reactions + */ + get eventReactions(): Map { + return this._eventReactions + } + + /** + * Check if currently loading + */ + get isLoading(): boolean { + return this._isLoading.value + } + + /** + * Cleanup + */ + protected async onDestroy(): Promise { + if (this.currentUnsubscribe) { + this.currentUnsubscribe() + } + this._eventReactions.clear() + this.monitoredEvents.clear() + this.deletedReactions.clear() + } +} diff --git a/src/modules/links/components/SortTabs.vue b/src/modules/links/components/SortTabs.vue new file mode 100644 index 0000000..84a174f --- /dev/null +++ b/src/modules/links/components/SortTabs.vue @@ -0,0 +1,107 @@ + + + diff --git a/src/modules/links/components/SubmissionComment.vue b/src/modules/links/components/SubmissionComment.vue new file mode 100644 index 0000000..ab52a9e --- /dev/null +++ b/src/modules/links/components/SubmissionComment.vue @@ -0,0 +1,275 @@ + + +