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 tags: string[][] mentions: string[] isReply: boolean replyTo?: string } export interface FeedConfig { feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' maxPosts?: number adminPubkeys?: string[] } export class FeedService extends BaseService { protected readonly metadata = { name: 'FeedService', version: '1.0.0', dependencies: [] } protected relayHub: any = null protected visibilityService: 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) console.log('FeedService: RelayHub injected:', !!this.relayHub) console.log('FeedService: VisibilityService injected:', !!this.visibilityService) 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 filter const filter: Filter = { kinds: [1], // Text notes limit: config.maxPosts || 50 } if (config.feedType === 'announcements') { if (config.adminPubkeys && config.adminPubkeys.length > 0) { filter.authors = config.adminPubkeys } else { // No admin pubkeys configured for announcements - don't subscribe console.log('No admin pubkeys configured for announcements feed') this._isLoading.value = false return } } console.log(`Creating feed subscription for ${config.feedType} with filter:`, filter) // Subscribe to events with deduplication const unsubscribe = this.relayHub.subscribe({ id: subscriptionId, filters: [filter], 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 { // Skip if event already seen 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 } // Transform to FeedPost const post: FeedPost = { id: event.id, pubkey: event.pubkey, content: event.content, created_at: event.created_at, tags: event.tags || [], mentions: event.tags?.filter((tag: string[]) => tag[0] === 'p').map((tag: string[]) => tag[1]) || [], isReply: event.tags?.some((tag: string[]) => tag[0] === 'e' && tag[3] === 'reply') || false, replyTo: event.tags?.find((tag: string[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1] } // 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') } /** * Check if event should be included in feed */ private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean { const isAdminPost = config.adminPubkeys?.includes(event.pubkey) || false switch (config.feedType) { case 'announcements': return isAdminPost case 'general': return !isAdminPost case 'events': // Events feed could show all posts for now, or implement event-specific filtering return true case 'mentions': // TODO: Implement mention detection if needed return true 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) } /** * Get filtered posts for specific feed type */ getFilteredPosts(config: FeedConfig): FeedPost[] { return this._posts.value .filter(post => this.shouldIncludeEvent({ id: post.id, pubkey: post.pubkey, content: post.content, created_at: post.created_at, tags: post.tags } as NostrEvent, config)) .sort((a, b) => b.created_at - a.created_at) .slice(0, config.maxPosts || 100) } /** * 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 = [] } }