import { ref, computed } from 'vue' import { BaseService } from '@/core/base/BaseService' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { eventBus } from '@/core/event-bus' import type { Event as NostrEvent, Filter } from 'nostr-tools' export interface FeedPost { id: string pubkey: string content: string created_at: number kind: number tags: string[][] mentions: string[] isReply: boolean replyTo?: string // Direct parent ID rootId?: string // Thread root ID replies?: FeedPost[] // Child replies depth?: number // Depth in reply tree (0 for root posts) } export interface ContentFilter { id: string label: string kinds: number[] description: string requiresAuth?: boolean filterByAuthor?: 'admin' | 'exclude-admin' | 'none' tags?: string[] // NIP-12 tags to filter by keywords?: string[] // Content keywords to search for } export interface FeedConfig { feedType: 'all' | 'announcements' | 'rideshare' | 'custom' maxPosts?: number adminPubkeys?: string[] contentFilters?: ContentFilter[] } export class FeedService extends BaseService { protected readonly metadata = { name: 'FeedService', version: '1.0.0', dependencies: [] } protected relayHub: any = null protected visibilityService: any = null protected reactionService: any = null protected scheduledEventService: any = null // Event ID tracking for deduplication private seenEventIds = new Set() // Feed state private _posts = ref([]) private _isLoading = ref(false) private _error = ref(null) // Current subscription state private currentSubscription: string | null = null private currentUnsubscribe: (() => void) | null = null private currentConfig: FeedConfig | null = null // Public reactive state public readonly posts = computed(() => this._posts.value) public readonly isLoading = computed(() => this._isLoading.value) public readonly error = computed(() => this._error.value) protected async onInitialize(): Promise { console.log('FeedService: Starting initialization...') this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE) this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE) this.scheduledEventService = injectService(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE) console.log('FeedService: RelayHub injected:', !!this.relayHub) console.log('FeedService: VisibilityService injected:', !!this.visibilityService) console.log('FeedService: ReactionService injected:', !!this.reactionService) console.log('FeedService: ScheduledEventService injected:', !!this.scheduledEventService) if (!this.relayHub) { throw new Error('RelayHub service not available') } // Register with visibility service for proper connection management if (this.visibilityService) { this.visibilityService.registerService( 'FeedService', this.onResume.bind(this), this.onPause.bind(this) ) } console.log('FeedService: Initialization complete') } /** * Subscribe to feed with deduplication */ async subscribeFeed(config: FeedConfig): Promise { // If already subscribed with same config, don't resubscribe if (this.currentSubscription && this.currentConfig && JSON.stringify(this.currentConfig) === JSON.stringify(config)) { return } // Unsubscribe from previous feed if exists await this.unsubscribeFeed() this.currentConfig = config this._isLoading.value = true this._error.value = null try { // Check if RelayHub is connected if (!this.relayHub) { throw new Error('RelayHub not available') } if (!this.relayHub.isConnected) { console.log('RelayHub not connected, attempting to connect...') await this.relayHub.connect() } if (!this.relayHub.isConnected) { throw new Error('Unable to connect to relays') } // Create subscription ID const subscriptionId = `feed-service-${config.feedType}-${Date.now()}` // Create filters based on feed type and content filters const filters: Filter[] = [] if (config.feedType === 'custom' && config.contentFilters) { // Use custom content filters // Using custom content filters for (const contentFilter of config.contentFilters) { const filter: Filter = { kinds: contentFilter.kinds, limit: Math.floor((config.maxPosts || 50) / config.contentFilters.length) } // Apply author filtering if specified if (contentFilter.filterByAuthor === 'admin') { if (config.adminPubkeys?.length) { filter.authors = config.adminPubkeys // Using admin authors for filtering } else { // No admin pubkeys configured - include all authors for admin filters // No admin pubkeys configured - include all authors } } else if (contentFilter.filterByAuthor === 'exclude-admin' && config.adminPubkeys?.length) { // Note: Nostr doesn't support negative filters natively, // we'll filter these out in post-processing // Will exclude admin in post-processing } // Apply tag filtering if specified (NIP-12) if (contentFilter.tags && contentFilter.tags.length > 0) { filter['#t'] = contentFilter.tags } filters.push(filter) } } else { // Handle default feed types (all, announcements, general, etc.) const filter: Filter = { kinds: [1], // Text notes limit: config.maxPosts || 50 } // Apply feed-specific filtering switch (config.feedType) { case 'announcements': if (config.adminPubkeys?.length) { filter.authors = config.adminPubkeys } break case 'rideshare': // Rideshare posts handled via content filters break case 'all': default: // All posts - no specific filtering break } filters.push(filter) } // Add reactions (kind 7) to the filters filters.push({ kinds: [7], // Reactions limit: 500 }) // Add ALL deletion events (kind 5) - we'll route them based on the 'k' tag filters.push({ kinds: [5] // All deletion events (for both posts and reactions) }) // Add scheduled events (kind 31922) and RSVPs (kind 31925) filters.push({ kinds: [31922, 31925], // Calendar events and RSVPs limit: 200 }) console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters) // Subscribe to all events (posts, reactions, deletions) with deduplication const unsubscribe = this.relayHub.subscribe({ id: subscriptionId, filters: filters, onEvent: (event: NostrEvent) => { this.handleNewEvent(event, config) }, onEose: () => { console.log(`Feed subscription ${subscriptionId} end of stored events`) console.log('FeedService: Setting isLoading to false') this._isLoading.value = false console.log('FeedService: isLoading is now:', this._isLoading.value) }, onClose: () => { console.log(`Feed subscription ${subscriptionId} closed`) } }) // Store the subscription info for later cleanup this.currentSubscription = subscriptionId this.currentUnsubscribe = unsubscribe // Set a timeout to stop loading if no EOSE is received setTimeout(() => { console.log(`Feed subscription ${subscriptionId} timeout check: isLoading=${this._isLoading.value}, currentSub=${this.currentSubscription}`) if (this._isLoading.value && this.currentSubscription === subscriptionId) { console.log(`Feed subscription ${subscriptionId} timeout, stopping loading`) this._isLoading.value = false } }, 5000) // 5 second timeout (reduced for testing) } catch (err) { console.error('Failed to subscribe to feed:', err) this._error.value = err instanceof Error ? err.message : 'Failed to subscribe to feed' this._isLoading.value = false } } /** * Handle new event with robust deduplication */ private handleNewEvent(event: NostrEvent, config: FeedConfig): void { // Route deletion events (kind 5) based on what's being deleted if (event.kind === 5) { this.handleDeletionEvent(event) return } // Route reaction events (kind 7) to ReactionService if (event.kind === 7) { if (this.reactionService) { this.reactionService.handleReactionEvent(event) } return } // Route scheduled events (kind 31922) to ScheduledEventService if (event.kind === 31922) { if (this.scheduledEventService) { this.scheduledEventService.handleScheduledEvent(event) } return } // Route RSVP/completion events (kind 31925) to ScheduledEventService if (event.kind === 31925) { console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService') if (this.scheduledEventService) { this.scheduledEventService.handleCompletionEvent(event) } else { console.warn('⚠️ FeedService: ScheduledEventService not available') } return } // Skip if event already seen (for posts only, kind 1) if (this.seenEventIds.has(event.id)) { return } // Add to seen events this.seenEventIds.add(event.id) // Check if event should be included based on feed type if (!this.shouldIncludeEvent(event, config)) { return } // Extract reply information according to NIP-10 let rootId: string | undefined let replyTo: string | undefined let isReply = false // Look for marked e-tags first (preferred method) const markedRootTag = event.tags?.find((tag: string[]) => tag[0] === 'e' && tag[3] === 'root') const markedReplyTag = event.tags?.find((tag: string[]) => tag[0] === 'e' && tag[3] === 'reply') if (markedRootTag || markedReplyTag) { // Using marked tags (NIP-10 preferred method) rootId = markedRootTag?.[1] replyTo = markedReplyTag?.[1] || markedRootTag?.[1] // Direct reply to root if no reply tag isReply = true } else { // Fallback to positional tags (deprecated but still in use) const eTags = event.tags?.filter((tag: string[]) => tag[0] === 'e') || [] if (eTags.length === 1) { // Single e-tag means this is a direct reply replyTo = eTags[0][1] rootId = eTags[0][1] isReply = true } else if (eTags.length >= 2) { // Multiple e-tags: first is root, last is direct reply rootId = eTags[0][1] replyTo = eTags[eTags.length - 1][1] isReply = true } } // Transform to FeedPost const post: FeedPost = { id: event.id, pubkey: event.pubkey, content: event.content, created_at: event.created_at, kind: event.kind, tags: event.tags || [], mentions: event.tags?.filter((tag: string[]) => tag[0] === 'p').map((tag: string[]) => tag[1]) || [], isReply, replyTo, rootId, replies: [], depth: 0 } // Add to posts (newest first) this._posts.value = [post, ...this._posts.value] // Limit array size and clean up seen IDs const maxPosts = config.maxPosts || 100 if (this._posts.value.length > maxPosts) { const removedPosts = this._posts.value.slice(maxPosts) this._posts.value = this._posts.value.slice(0, maxPosts) // Clean up seen IDs for removed posts removedPosts.forEach(post => { this.seenEventIds.delete(post.id) }) } // Emit event for other modules eventBus.emit('nostr-feed:new-post', { event, feedType: config.feedType }, 'nostr-feed') } /** * Handle deletion events (NIP-09) * Routes deletions to appropriate service based on the 'k' tag */ private handleDeletionEvent(event: NostrEvent): void { // Check the 'k' tag to determine what kind of event is being deleted const kTag = event.tags?.find((tag: string[]) => tag[0] === 'k') const deletedKind = kTag ? kTag[1] : null // Route to ReactionService for reaction deletions (kind 7) if (deletedKind === '7') { if (this.reactionService) { this.reactionService.handleDeletionEvent(event) } return } // Handle post deletions (kind 1) in FeedService if (deletedKind === '1' || !deletedKind) { // Extract event IDs to delete from 'e' tags const eventIdsToDelete = event.tags ?.filter((tag: string[]) => tag[0] === 'e') .map((tag: string[]) => tag[1]) || [] if (eventIdsToDelete.length === 0) { return } // Remove deleted posts from the feed this._posts.value = this._posts.value.filter(post => { // Only delete if the deletion request comes from the same author (NIP-09 validation) if (eventIdsToDelete.includes(post.id) && post.pubkey === event.pubkey) { // Also remove from seen events so it won't be re-added this.seenEventIds.delete(post.id) return false } return true }) } } /** * Check if event should be included in feed */ private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean { // Never include reactions (kind 7) in the main feed // Reactions should only be processed by the ReactionService if (event.kind === 7) { return false } const isAdminPost = config.adminPubkeys?.includes(event.pubkey) || false // For custom content filters or specific feed types with filters, check if event matches any active filter if ((config.feedType === 'custom' || config.feedType === 'rideshare') && config.contentFilters) { console.log('FeedService: Using custom filters, count:', config.contentFilters.length) const result = config.contentFilters.some(filter => { console.log('FeedService: Checking filter:', filter.id, 'kinds:', filter.kinds, 'filterByAuthor:', filter.filterByAuthor) // Check if event kind matches if (!filter.kinds.includes(event.kind)) { console.log('FeedService: Kind mismatch, event kind:', event.kind, 'filter kinds:', filter.kinds) return false } // Apply author filtering if (filter.filterByAuthor === 'admin') { console.log('FeedService: Admin filter, isAdminPost:', isAdminPost) if (!isAdminPost) return false } else if (filter.filterByAuthor === 'exclude-admin') { console.log('FeedService: Exclude admin filter, isAdminPost:', isAdminPost) if (isAdminPost) return false } // Apply keyword and tag filtering (OR logic when both are specified) const hasKeywordFilter = filter.keywords && filter.keywords.length > 0 const hasTagFilter = filter.tags && filter.tags.length > 0 if (hasKeywordFilter || hasTagFilter) { let keywordMatch = false let tagMatch = false // Check keywords if (hasKeywordFilter) { const content = event.content.toLowerCase() keywordMatch = filter.keywords!.some(keyword => content.includes(keyword.toLowerCase()) ) } // Check tags if (hasTagFilter) { const eventTags = event.tags?.filter(tag => tag[0] === 't').map(tag => tag[1]) || [] tagMatch = filter.tags!.some(filterTag => eventTags.includes(filterTag) ) } // Must match at least one: keywords OR tags const hasMatch = (hasKeywordFilter && keywordMatch) || (hasTagFilter && tagMatch) if (!hasMatch) { console.log('FeedService: No matching keywords or tags found') return false } } console.log('FeedService: Filter passed all checks') return true }) console.log('FeedService: Custom filter result:', result) return result } // Feed type handling switch (config.feedType) { case 'announcements': return isAdminPost case 'rideshare': // Rideshare filtering handled via content filters above // If we reach here, contentFilters weren't provided - show nothing return false case 'all': default: return true } } /** * Unsubscribe from current feed */ async unsubscribeFeed(): Promise { if (this.currentUnsubscribe) { this.currentUnsubscribe() this.currentSubscription = null this.currentUnsubscribe = null this.currentConfig = null } } /** * Refresh feed (clear and reload) */ async refreshFeed(): Promise { if (!this.currentConfig) return // Clear existing state this._posts.value = [] this.seenEventIds.clear() // Resubscribe await this.subscribeFeed(this.currentConfig) } /** * Build threaded reply structure from flat posts */ buildThreadedPosts(posts: FeedPost[]): FeedPost[] { console.log('FeedService.buildThreadedPosts: Input posts count:', posts.length) // Create a map for quick lookup const postMap = new Map() posts.forEach(post => { postMap.set(post.id, { ...post, replies: [], depth: 0 }) }) // Build the tree structure const rootPosts: FeedPost[] = [] posts.forEach(post => { const currentPost = postMap.get(post.id)! if (post.isReply && post.replyTo) { // This is a reply, attach it to its parent if parent exists const parentPost = postMap.get(post.replyTo) if (parentPost) { currentPost.depth = (parentPost.depth || 0) + 1 parentPost.replies = parentPost.replies || [] parentPost.replies.push(currentPost) } else { // Parent not found, treat as root post rootPosts.push(currentPost) } } else { // This is a root post rootPosts.push(currentPost) } }) // Sort posts using like count and timestamp this.sortPostsByLikesAndTime(rootPosts) // Sort all reply threads recursively rootPosts.forEach(post => this.sortRepliesRecursively(post)) return rootPosts } /** * Sort posts by likes first, then by time (newest first) */ private sortPostsByLikesAndTime(posts: FeedPost[]): void { posts.sort((a, b) => { // Get like counts from reaction service if available const aLikes = this.getLikeCount(a.id) const bLikes = this.getLikeCount(b.id) // Sort by likes first (descending) if (aLikes !== bLikes) { return bLikes - aLikes } // If likes are equal, sort by time (newest first) return b.created_at - a.created_at }) } /** * Recursively sort replies within each thread */ private sortRepliesRecursively(post: FeedPost): void { if (post.replies && post.replies.length > 0) { // Sort replies by likes first, then time this.sortPostsByLikesAndTime(post.replies) // Recursively sort nested replies post.replies.forEach(reply => this.sortRepliesRecursively(reply)) } } /** * Get like count for a post from ReactionService */ private getLikeCount(postId: string): number { try { if (this.reactionService && typeof this.reactionService.getEventReactions === 'function') { const reactions = this.reactionService.getEventReactions(postId) return reactions?.likes || 0 } } catch (error) { // Silently fail if reaction service is not available console.debug('FeedService: Could not get like count for post', postId, error) } return 0 } /** * Get filtered posts for specific feed type */ getFilteredPosts(config: FeedConfig): FeedPost[] { console.log('FeedService: getFilteredPosts called, total posts:', this._posts.value.length, 'config:', config.feedType) const filtered = this._posts.value .filter(post => { const shouldInclude = this.shouldIncludeEvent({ id: post.id, pubkey: post.pubkey, content: post.content, created_at: post.created_at, kind: post.kind, tags: post.tags } as NostrEvent, config) if (!shouldInclude) { console.log('FeedService: Post filtered out in getFilteredPosts:', post.id) } return shouldInclude }) .sort((a, b) => b.created_at - a.created_at) .slice(0, config.maxPosts || 100) console.log('FeedService: getFilteredPosts returning', filtered.length, 'posts') return filtered } /** * Visibility service callbacks */ private async onResume(): Promise { console.log('FeedService: App resumed, checking connections') // Check if we need to reconnect if (this.currentConfig && this.relayHub) { const isConnected = this.relayHub.isConnected.value console.log('FeedService: RelayHub connection status:', isConnected) if (!isConnected) { console.log('FeedService: Reconnecting after resume') await this.subscribeFeed(this.currentConfig) } } } private onPause(): void { console.log('FeedService: App paused, maintaining state') // Don't clear state, just log for debugging } /** * Cleanup */ protected async onDestroy(): Promise { await this.unsubscribeFeed() this.seenEventIds.clear() this._posts.value = [] } }