// lib/social/SocialFeedService.ts import NDK, { NDKEvent, NDKFilter, NDKSubscription, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk-mobile'; import { POWR_EVENT_KINDS } from '@/types/nostr-workout'; import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService'; import { Workout } from '@/types/workout'; import { SQLiteDatabase } from 'expo-sqlite'; import { getSocialFeedCache } from '@/lib/db/services/SocialFeedCache'; import { ConnectivityService } from '@/lib/db/services/ConnectivityService'; import { POWR_PUBKEY_HEX } from '@/lib/hooks/useFeedHooks'; export class SocialFeedService { private ndk: NDK; private socialFeedCache: ReturnType | null = null; private db: SQLiteDatabase | null = null; constructor(ndk: NDK, db?: SQLiteDatabase) { this.ndk = ndk; if (db) { this.db = db; try { this.socialFeedCache = getSocialFeedCache(db); this.socialFeedCache.setNDK(ndk); } catch (error) { console.error('[SocialFeedService] Error initializing SocialFeedCache:', error); // Continue without cache - we'll still be able to fetch from network this.socialFeedCache = null; } } } /** * Get cached events for a feed * @param feedType Type of feed (following, powr, global) * @param limit Maximum number of events to return * @param since Timestamp to fetch events since (inclusive) * @param until Timestamp to fetch events until (inclusive) * @returns Array of cached events */ async getCachedEvents( feedType: string, limit: number = 20, since?: number, until?: number ): Promise { if (!this.socialFeedCache) { return []; } try { return await this.socialFeedCache.getCachedEvents(feedType, limit, since, until); } catch (error) { console.error('[SocialFeedService] Error retrieving cached events:', error); // Return empty array on error return []; } } /** * Build filters for a feed subscription with improved error handling * @param options Filter options * @returns NDK filter object or array of filters */ buildFilters(options: { feedType: 'following' | 'powr' | 'global' | 'profile'; since?: number; until?: number; limit?: number; authors?: string[]; kinds?: number[]; }): NDKFilter | NDKFilter[] { try { const { feedType, since, until, limit, authors, kinds } = options; // Default to events in the last 24 hours if no since provided const defaultSince = Math.floor(Date.now() / 1000) - 24 * 60 * 60; // Fitness-related tags for filtering const tagFilter = [ 'workout', 'fitness', 'powr', '31days', 'crossfit', 'wod', 'gym', 'strength', 'cardio', 'training', 'exercise' ]; // Determine which kinds to include const workoutKinds: number[] = []; const socialKinds: number[] = []; // Add workout-specific kinds (1301, 33401, 33402) if (!kinds || kinds.some(k => [1301, 33401, 33402].includes(k))) { [1301, 33401, 33402] .filter(k => !kinds || kinds.includes(k)) .forEach(k => workoutKinds.push(k)); } // Add social post kind (1) and article kind (30023) if (!kinds || kinds.includes(1)) { socialKinds.push(1); } if (!kinds || kinds.includes(30023)) { socialKinds.push(30023); } // Base filter properties const baseFilter: Record = { since: since || defaultSince, limit: limit || 30, }; if (until) { baseFilter.until = until; } // Special handling for different feed types if (feedType === 'profile') { // Profile feed: Show all of a user's posts if (!Array.isArray(authors) || authors.length === 0) { console.error('[SocialFeedService] Profile feed requires authors'); return { ...baseFilter, kinds: [] }; // Return empty filter if no authors } // For profile feed, we create two filters: // 1. All workout-related kinds from the user // 2. Social posts and articles from the user (with or without tags) return [ // Workout-related kinds (no tag filtering) { ...baseFilter, kinds: workoutKinds, authors: authors, }, // Social posts and articles (no tag filtering for profile) { ...baseFilter, kinds: socialKinds, authors: authors, } ]; } else if (feedType === 'powr') { // POWR feed: Show all content from POWR account(s) if (!Array.isArray(authors) || authors.length === 0) { console.error('[SocialFeedService] POWR feed requires authors'); // For POWR feed, if no authors provided, use the POWR_PUBKEY_HEX as fallback if (POWR_PUBKEY_HEX) { console.log('[SocialFeedService] Using POWR account as fallback for POWR feed'); const fallbackAuthors = [POWR_PUBKEY_HEX]; return { ...baseFilter, kinds: [...workoutKinds, ...socialKinds], authors: fallbackAuthors, }; } else { return { ...baseFilter, kinds: [] }; // Return empty filter if no authors and no fallback } } // For POWR feed, we don't apply tag filtering return { ...baseFilter, kinds: [...workoutKinds, ...socialKinds], authors: authors, }; } else if (feedType === 'following') { // Following feed: Show content from followed users if (!Array.isArray(authors) || authors.length === 0) { console.error('[SocialFeedService] Following feed requires authors'); // For following feed, if no authors provided, use the POWR_PUBKEY_HEX as fallback // This ensures at least some content is shown if (POWR_PUBKEY_HEX) { console.log('[SocialFeedService] Using POWR account as fallback for Following feed'); const fallbackAuthors = [POWR_PUBKEY_HEX]; return [ // Workout-related kinds (no tag filtering) { ...baseFilter, kinds: workoutKinds, authors: fallbackAuthors, }, // Social posts and articles (with tag filtering) { ...baseFilter, kinds: socialKinds, authors: fallbackAuthors, '#t': tagFilter, } ]; } else { return { ...baseFilter, kinds: [] }; // Return empty filter if no authors and no fallback } } // For following feed, we create two filters: // 1. All workout-related kinds from followed users // 2. Social posts and articles from followed users with fitness tags // Log the authors to help with debugging console.log(`[SocialFeedService] Following feed with ${authors.length} authors:`, authors.length > 5 ? authors.slice(0, 5).join(', ') + '...' : authors.join(', ')); // Always include POWR account in following feed let followingAuthors = [...authors]; if (POWR_PUBKEY_HEX && !followingAuthors.includes(POWR_PUBKEY_HEX)) { followingAuthors.push(POWR_PUBKEY_HEX); console.log('[SocialFeedService] Added POWR account to following feed authors'); } return [ // Workout-related kinds (no tag filtering) { ...baseFilter, kinds: workoutKinds, authors: followingAuthors, }, // Social posts and articles (with tag filtering) { ...baseFilter, kinds: socialKinds, authors: followingAuthors, '#t': tagFilter, } ]; } else { // Global feed: Show content from anyone // For global feed, we create two filters: // 1. All workout-related kinds from anyone // 2. Social posts and articles from anyone with fitness tags return [ // Workout-related kinds (no tag filtering) { ...baseFilter, kinds: workoutKinds, }, // Social posts and articles (with tag filtering) { ...baseFilter, kinds: socialKinds, '#t': tagFilter, } ]; } } catch (error) { console.error('[SocialFeedService] Error building filters:', error); // Return a safe default filter that won't crash but also won't return much return { kinds: [1], // Just social posts limit: 10, since: Math.floor(Date.now() / 1000) - 24 * 60 * 60 // Last 24 hours }; } } /** * Subscribe to a feed of workout-related events * @param options Subscription options * @returns Subscription object with unsubscribe method */ subscribeFeed(options: { feedType: 'following' | 'powr' | 'global' | 'profile'; since?: number; until?: number; limit?: number; authors?: string[]; kinds?: number[]; onEvent: (event: NDKEvent) => void; onEose?: () => void; }): Promise<{ unsubscribe: () => void }> { const { feedType, onEvent, onEose } = options; // Build the filter using our buildFilters method const consolidatedFilter = this.buildFilters(options); // Log the consolidated filter console.log(`[SocialFeedService] Subscribing to ${feedType} feed with filter:`, consolidatedFilter); // Create a single subscription with the consolidated filter const subscription = this.ndk.subscribe(consolidatedFilter, { closeOnEose: false // Keep subscription open for real-time updates }); // Set up event handler subscription.on('event', (event: NDKEvent) => { // Cache the event if we have a cache if (this.socialFeedCache) { try { this.socialFeedCache.cacheEvent(event, feedType) .catch(err => console.error('[SocialFeedService] Error caching event:', err)); } catch (error) { console.error('[SocialFeedService] Exception while caching event:', error); // Continue even if caching fails - we'll still pass the event to the callback } } // Pass the event to the callback onEvent(event); }); // Set up EOSE handler subscription.on('eose', () => { console.log(`[SocialFeedService] Received EOSE for ${feedType} feed`); if (onEose) onEose(); }); // Return a Promise with the unsubscribe object return Promise.resolve({ unsubscribe: () => { console.log(`[SocialFeedService] Unsubscribing from ${feedType} feed`); subscription.stop(); } }); } /** * Get comments for an event * @param eventId Event ID to get comments for * @returns Array of comment events */ async getComments(eventId: string): Promise { const filter: NDKFilter = { kinds: [POWR_EVENT_KINDS.COMMENT], "#e": [eventId], }; const events = await this.ndk.fetchEvents(filter); return Array.from(events); } /** * Post a comment on an event * @param parentEvent Parent event to comment on * @param content Comment text * @param replyTo Optional comment to reply to * @returns The created comment event */ async postComment( parentEvent: NDKEvent, content: string, replyTo?: NDKEvent ): Promise { const comment = new NDKEvent(this.ndk); comment.kind = POWR_EVENT_KINDS.COMMENT; comment.content = content; // Add tag for the root event comment.tags.push(['e', parentEvent.id, '', 'root']); // If this is a reply to another comment, add that reference if (replyTo) { comment.tags.push(['e', replyTo.id, '', 'reply']); } // Add author reference comment.tags.push(['p', parentEvent.pubkey]); // Sign and publish await comment.sign(); await comment.publish(); return comment; } /** * React to an event (like, etc.) * @param event Event to react to * @param reaction Reaction content ('+' for like) * @returns The created reaction event */ async reactToEvent(event: NDKEvent, reaction: string = '+'): Promise { const reactionEvent = new NDKEvent(this.ndk); reactionEvent.kind = POWR_EVENT_KINDS.REACTION; reactionEvent.content = reaction; // Add event and author references reactionEvent.tags.push(['e', event.id]); reactionEvent.tags.push(['p', event.pubkey]); // Sign and publish await reactionEvent.sign(); await reactionEvent.publish(); return reactionEvent; } /** * Get the referenced content for a social post * @param eventId ID of the referenced event * @param kind Kind of the referenced event * @returns The referenced event or null */ async getReferencedContent(eventId: string, kind: number): Promise { // First check if we have it in the cache if (this.socialFeedCache) { try { const cachedEvent = await this.socialFeedCache.getCachedEvent(eventId); if (cachedEvent) { return cachedEvent; } } catch (error) { console.error('[SocialFeedService] Error retrieving cached event:', error); // Continue to network fetch if cache fails } } // If not in cache or no cache available, try to fetch from network // Handle addressable content (a-tag references) if (eventId.includes(':')) { const parts = eventId.split(':'); if (parts.length >= 3) { // Format is kind:pubkey:identifier const filter: NDKFilter = { kinds: [parseInt(parts[0])], authors: [parts[1]], "#d": [parts[2]], }; const events = await this.ndk.fetchEvents(filter, { cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST }); if (events.size > 0) { const event = Array.from(events)[0]; // Cache the event if we have a cache if (this.socialFeedCache) { try { await this.socialFeedCache.cacheEvent(event, 'referenced'); } catch (error) { console.error('[SocialFeedService] Error caching referenced event:', error); // Continue even if caching fails - we can still return the event } } return event; } return null; } } // Standard event reference (direct ID) const filter: NDKFilter = { ids: [eventId] as string[], kinds: [kind] as number[], }; const events = await this.ndk.fetchEvents(filter, { cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST }); if (events.size > 0) { const event = Array.from(events)[0]; // Cache the event if we have a cache if (this.socialFeedCache) { try { await this.socialFeedCache.cacheEvent(event, 'referenced'); } catch (error) { console.error('[SocialFeedService] Error caching referenced event:', error); // Continue even if caching fails - we can still return the event } } return event; } return null; } /** * Resolve quoted content in a social post * @param event Social post event * @returns Referenced event or null */ async resolveQuotedContent(event: NDKEvent): Promise { if (event.kind !== POWR_EVENT_KINDS.SOCIAL_POST) return null; // Find the quoted event ID const quoteTag = event.tags.find(tag => tag[0] === 'q'); if (!quoteTag) return null; // Find the kind tag const kindTag = event.tags.find(tag => tag[0] === 'k' && ['1301', '33401', '33402', '30023'].includes(tag[1]) ); if (!kindTag) return null; const quotedEventId = quoteTag[1]; const quotedEventKind = parseInt(kindTag[1]); return this.getReferencedContent(quotedEventId, quotedEventKind); } /** * Publish a workout record to Nostr * @param workout Workout data * @param options Publishing options * @returns The published event */ async publishWorkoutRecord( workout: Workout, options: { shareAsSocialPost?: boolean; socialText?: string; limited?: boolean; } = {} ): Promise { // Get appropriate event data from NostrWorkoutService const eventData = options.limited ? NostrWorkoutService.createLimitedWorkoutEvent(workout) : NostrWorkoutService.createCompleteWorkoutEvent(workout); // Create and publish the event const event = new NDKEvent(this.ndk); event.kind = eventData.kind; event.content = eventData.content; event.tags = eventData.tags || []; event.created_at = eventData.created_at; await event.sign(); // Check if we're online before publishing const isOnline = await ConnectivityService.getInstance().checkNetworkStatus(); if (isOnline) { await event.publish(); } else { console.log('[SocialFeedService] Offline, event will be published when online'); } // Cache the event if we have a cache if (this.socialFeedCache) { try { await this.socialFeedCache.cacheEvent(event, 'workout'); } catch (error) { console.error('[SocialFeedService] Error caching workout event:', error); // Continue even if caching fails - the event was still published } } // Create social share if requested if (options.shareAsSocialPost && options.socialText) { const socialEventData = NostrWorkoutService.createSocialShareEvent( event.id, options.socialText ); await this.publishEvent(socialEventData); } return event; } /** * Helper to publish a generic event * @param eventData Event data to publish * @returns Published event */ async publishEvent(eventData: any): Promise { const event = new NDKEvent(this.ndk); event.kind = eventData.kind; event.content = eventData.content; event.tags = eventData.tags || []; event.created_at = eventData.created_at; await event.sign(); await event.publish(); return event; } /** * Get POWR team pubkeys - to be replaced with actual pubkeys * @returns Array of POWR team pubkeys */ private getPOWRTeamPubkeys(): string[] { // Replace with actual POWR team pubkeys return [ // TODO: Add actual POWR team pubkeys '55127fc9e1c03c6b459a3bab72fdb99def1644c5f239bdd09f3e5fb401ed9b21', ]; } }