diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 7a0a137..0b3d93a 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -131,7 +131,10 @@ export const SERVICE_TOKENS = { // Chat services CHAT_SERVICE: Symbol('chatService'), - + + // Feed services + FEED_SERVICE: Symbol('feedService'), + // Events services EVENTS_SERVICE: Symbol('eventsService'), diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index 2b031c7..fd77b26 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -1,29 +1,28 @@ @@ -232,19 +85,11 @@ onUnmounted(() => { - - - - - Not connected to relays - - - - Loading announcements... + Loading feed... @@ -252,9 +97,9 @@ onUnmounted(() => { - Failed to load announcements + Failed to load feed - {{ error.message }} + {{ error }} Try Again @@ -273,34 +118,34 @@ onUnmounted(() => { - No announcements yet + No posts yet - Check back later for community updates and announcements. + Check back later for community updates. - - Admin - Reply diff --git a/src/modules/nostr-feed/composables/useFeed.ts b/src/modules/nostr-feed/composables/useFeed.ts index 91259cd..111ff62 100644 --- a/src/modules/nostr-feed/composables/useFeed.ts +++ b/src/modules/nostr-feed/composables/useFeed.ts @@ -1,97 +1,55 @@ -import { ref, computed, onMounted, onUnmounted } from 'vue' +import { computed, ref, onMounted, onUnmounted } from 'vue' import { injectService, SERVICE_TOKENS } from '@/core/di-container' -import { eventBus } from '@/core/event-bus' -import type { Event as NostrEvent, Filter } from 'nostr-tools' +import type { FeedService, FeedConfig } from '../services/FeedService' -export interface FeedConfig { - feedType: 'announcements' | 'general' | 'mentions' +export interface UseFeedConfig { + feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' maxPosts?: number refreshInterval?: number adminPubkeys?: string[] } -export function useFeed(config: FeedConfig) { - const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) - const posts = ref([]) - const isLoading = ref(false) - const error = ref(null) - +export function useFeed(config: UseFeedConfig) { + const feedService = injectService(SERVICE_TOKENS.FEED_SERVICE) + + console.log('useFeed: FeedService injected:', !!feedService) + if (!feedService) { + console.error('useFeed: FeedService not available from DI container') + } + let refreshTimer: number | null = null - let unsubscribe: (() => void) | null = null + + // Convert to FeedService config + const feedConfig: FeedConfig = { + feedType: config.feedType, + maxPosts: config.maxPosts, + adminPubkeys: config.adminPubkeys + } const filteredPosts = computed(() => { - let filtered = posts.value - - // Filter by feed type - if (config.feedType === 'announcements' && config.adminPubkeys) { - filtered = filtered.filter(post => config.adminPubkeys!.includes(post.pubkey)) - } - - // Sort by created timestamp (newest first) - filtered = filtered.sort((a, b) => b.created_at - a.created_at) - - // Limit posts - if (config.maxPosts) { - filtered = filtered.slice(0, config.maxPosts) - } - - return filtered + if (!feedService) return [] + return feedService.getFilteredPosts(feedConfig) }) const loadFeed = async () => { - if (!relayHub) { - error.value = 'RelayHub not available' + console.log('useFeed: loadFeed called, feedService available:', !!feedService) + if (!feedService) { + console.error('FeedService not available') return } - isLoading.value = true - error.value = null - + console.log('useFeed: calling feedService.subscribeFeed with config:', feedConfig) try { - // Create filter based on feed type - const filter: Filter = { - kinds: [1], // Text notes - limit: config.maxPosts || 50 - } - - if (config.feedType === 'announcements' && config.adminPubkeys) { - filter.authors = config.adminPubkeys - } - - // Subscribe to events - await relayHub.subscribe('feed-subscription', [filter], { - onEvent: (event: NostrEvent) => { - // Add new event if not already present - if (!posts.value.some(p => p.id === event.id)) { - posts.value = [event, ...posts.value] - - // Emit event for other modules - eventBus.emit('nostr-feed:new-post', { event, feedType: config.feedType }, 'nostr-feed') - } - }, - onEose: () => { - console.log('Feed subscription end of stored events') - isLoading.value = false - }, - onClose: () => { - console.log('Feed subscription closed') - } - }) - - unsubscribe = () => { - relayHub.unsubscribe('feed-subscription') - } - + await feedService.subscribeFeed(feedConfig) + console.log('useFeed: subscribeFeed completed') } catch (err) { console.error('Failed to load feed:', err) - error.value = err instanceof Error ? err.message : 'Failed to load feed' - isLoading.value = false } } - const refreshFeed = () => { - posts.value = [] - loadFeed() + const refreshFeed = async () => { + if (!feedService) return + await feedService.refreshFeed() } const startAutoRefresh = () => { @@ -109,21 +67,19 @@ export function useFeed(config: FeedConfig) { // Lifecycle onMounted(() => { + console.log('useFeed: onMounted called') loadFeed() startAutoRefresh() }) onUnmounted(() => { stopAutoRefresh() - if (unsubscribe) { - unsubscribe() - } }) return { posts: filteredPosts, - isLoading, - error, + isLoading: feedService?.isLoading ?? ref(false), + error: feedService?.error ?? ref(null), refreshFeed, loadFeed } diff --git a/src/modules/nostr-feed/index.ts b/src/modules/nostr-feed/index.ts index d4f3b39..b62d961 100644 --- a/src/modules/nostr-feed/index.ts +++ b/src/modules/nostr-feed/index.ts @@ -1,27 +1,47 @@ -import { createModulePlugin } from '@/core/base/BaseModulePlugin' +import type { App } from 'vue' +import type { ModulePlugin } from '@/core/types' +import { container, SERVICE_TOKENS } from '@/core/di-container' import NostrFeed from './components/NostrFeed.vue' import { useFeed } from './composables/useFeed' +import { FeedService } from './services/FeedService' /** * Nostr Feed Module Plugin * Provides social feed functionality with admin announcements support */ -export const nostrFeedModule = createModulePlugin({ +export const nostrFeedModule: ModulePlugin = { name: 'nostr-feed', version: '1.0.0', dependencies: ['base'], - + + async install(app: App) { + console.log('nostr-feed module: Starting installation...') + + // Register FeedService in DI container + const feedService = new FeedService() + container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService) + console.log('nostr-feed module: FeedService registered in DI container') + + // Initialize the service + console.log('nostr-feed module: Initializing FeedService...') + await feedService.initialize({ + waitForDependencies: true, + maxRetries: 3 + }) + console.log('nostr-feed module: FeedService initialized') + + // Register components globally + app.component('NostrFeed', NostrFeed) + console.log('nostr-feed module: Installation complete') + }, + components: { NostrFeed }, - - routes: [], - - exports: { - composables: { - useFeed - } + + composables: { + useFeed } -}) +} export default nostrFeedModule \ No newline at end of file diff --git a/src/modules/nostr-feed/services/FeedService.ts b/src/modules/nostr-feed/services/FeedService.ts new file mode 100644 index 0000000..d922a5c --- /dev/null +++ b/src/modules/nostr-feed/services/FeedService.ts @@ -0,0 +1,314 @@ +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 = await this.relayHub.checkHealth() + 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 = [] + } +} \ No newline at end of file diff --git a/src/pages/Home.vue b/src/pages/Home.vue index bba9cfd..f880bf6 100644 --- a/src/pages/Home.vue +++ b/src/pages/Home.vue @@ -3,7 +3,7 @@ - +
{{ error.message }}
{{ error }}
- Check back later for community updates and announcements. + Check back later for community updates.