diff --git a/src/app.config.ts b/src/app.config.ts new file mode 100644 index 0000000..1280fff --- /dev/null +++ b/src/app.config.ts @@ -0,0 +1,71 @@ +import type { AppConfig } from './core/types' + +export const appConfig: AppConfig = { + modules: { + base: { + name: 'base', + enabled: true, + lazy: false, + config: { + nostr: { + relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]') + }, + auth: { + sessionTimeout: 24 * 60 * 60 * 1000, // 24 hours + }, + pwa: { + autoPrompt: true + } + } + }, + 'nostr-feed': { + name: 'nostr-feed', + enabled: true, + lazy: false, + config: { + refreshInterval: 30000, // 30 seconds + maxPosts: 100, + adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]'), + feedTypes: ['announcements', 'general'] + } + }, + market: { + name: 'market', + enabled: true, + lazy: false, + config: { + defaultCurrency: 'sats', + paymentTimeout: 300000, // 5 minutes + maxOrderHistory: 50 + } + }, + chat: { + name: 'chat', + enabled: true, + lazy: true, // Load on demand + config: { + maxMessages: 500, + autoScroll: true, + showTimestamps: true + } + }, + events: { + name: 'events', + enabled: true, + lazy: false, + config: { + ticketValidationEndpoint: '/api/tickets/validate', + maxTicketsPerUser: 10 + } + } + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV + } +} + +export default appConfig \ No newline at end of file diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..e16bfe2 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,157 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' +// Core plugin system +import { pluginManager } from './core/plugin-manager' +import { eventBus } from './core/event-bus' +import { container, SERVICE_TOKENS } from './core/di-container' + +// App configuration +import appConfig from './app.config' + +// Base modules +import baseModule from './modules/base' +import nostrFeedModule from './modules/nostr-feed' + +// Root component +import App from './App.vue' + +// Styles +import './assets/index.css' + +// Use existing i18n setup +import { i18n } from './i18n' + +/** + * Initialize and start the modular application + */ +export async function createAppInstance() { + console.log('🚀 Starting modular application...') + + // Create Vue app + const app = createApp(App) + + // Create router + const router = createRouter({ + history: createWebHistory(), + routes: [ + // Default route - will be populated by modules + { + path: '/', + name: 'home', + component: () => import('./pages/Home.vue'), + meta: { requiresAuth: true } + }, + { + path: '/login', + name: 'login', + component: () => import('./pages/LoginDemo.vue'), + meta: { requiresAuth: false } + } + ] + }) + + // Use existing i18n setup + + // Create Pinia store + const pinia = createPinia() + + // Install core plugins + app.use(router) + app.use(pinia) + app.use(i18n) + + // Initialize plugin manager + pluginManager.init(app, router) + + // Register modules based on configuration + const moduleRegistrations = [] + + // Register base module first (required) + if (appConfig.modules.base.enabled) { + moduleRegistrations.push( + pluginManager.register(baseModule, appConfig.modules.base) + ) + } + + // Register nostr-feed module + if (appConfig.modules['nostr-feed'].enabled) { + moduleRegistrations.push( + pluginManager.register(nostrFeedModule, appConfig.modules['nostr-feed']) + ) + } + + // TODO: Register other modules as they're converted + // - market module + // - chat module + // - events module + + // Wait for all modules to register + await Promise.all(moduleRegistrations) + + // Install all enabled modules + await pluginManager.installAll() + + // Set up auth guard + router.beforeEach(async (to, _from, next) => { + const authService = container.inject(SERVICE_TOKENS.AUTH_SERVICE) as any + + if (to.meta.requiresAuth && authService && !authService.isAuthenticated?.value) { + next('/login') + } else if (to.path === '/login' && authService && authService.isAuthenticated?.value) { + next('/') + } else { + next() + } + }) + + // Global error handling + app.config.errorHandler = (err, _vm, info) => { + console.error('Global error:', err, info) + eventBus.emit('app:error', { error: err, info }, 'app') + } + + // Development helpers + if (appConfig.features.developmentMode) { + // Expose debugging helpers globally + ;(window as any).__pluginManager = pluginManager + ;(window as any).__eventBus = eventBus + ;(window as any).__container = container + + console.log('🔧 Development mode enabled') + console.log('Available globals: __pluginManager, __eventBus, __container') + } + + console.log('✅ Application initialized successfully') + + return { app, router } +} + +/** + * Start the application + */ +export async function startApp() { + try { + const { app } = await createAppInstance() + + // Mount the app + app.mount('#app') + + console.log('🎉 Application started!') + + // Emit app started event + eventBus.emit('app:started', {}, 'app') + + } catch (error) { + console.error('💥 Failed to start application:', error) + + // Show error to user + document.getElementById('app')!.innerHTML = ` +
+

Application Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page or contact support.

+
+ ` + } +} \ No newline at end of file diff --git a/src/core/di-container.ts b/src/core/di-container.ts new file mode 100644 index 0000000..c28d2d1 --- /dev/null +++ b/src/core/di-container.ts @@ -0,0 +1,137 @@ +import type { DIContainer, ServiceToken } from './types' + +interface ServiceRegistration { + service: any + scope: 'singleton' | 'transient' + instance?: any +} + +/** + * Dependency Injection Container + * Manages service registration and injection across modules + */ +export class DIContainerImpl implements DIContainer { + private services = new Map() + + /** + * Register a service in the container + */ + provide(token: ServiceToken, service: T, scope: 'singleton' | 'transient' = 'singleton'): void { + this.services.set(token, { + service, + scope, + instance: scope === 'singleton' ? service : undefined + }) + } + + /** + * Inject a service from the container + */ + inject(token: ServiceToken): T | undefined { + const registration = this.services.get(token) + + if (!registration) { + return undefined + } + + if (registration.scope === 'singleton') { + return registration.instance as T + } + + // For transient services, create new instance + // Note: This assumes the service is a constructor function + if (typeof registration.service === 'function') { + try { + return new registration.service() as T + } catch (error) { + console.error(`Error creating transient service for token ${String(token)}:`, error) + return undefined + } + } + + return registration.service as T + } + + /** + * Remove a service from the container + */ + remove(token: ServiceToken): boolean { + return this.services.delete(token) + } + + /** + * Clear all services + */ + clear(): void { + this.services.clear() + } + + /** + * Get all registered service tokens + */ + getRegisteredTokens(): ServiceToken[] { + return Array.from(this.services.keys()) + } + + /** + * Check if a service is registered + */ + has(token: ServiceToken): boolean { + return this.services.has(token) + } + + /** + * Get service registration info + */ + getServiceInfo(token: ServiceToken): { scope: string; hasInstance: boolean } | undefined { + const registration = this.services.get(token) + if (!registration) { + return undefined + } + + return { + scope: registration.scope, + hasInstance: registration.instance !== undefined + } + } +} + +// Global DI container instance +export const container = new DIContainerImpl() + +// Service token constants +export const SERVICE_TOKENS = { + // Core services + EVENT_BUS: Symbol('eventBus'), + ROUTER: Symbol('router'), + + // Nostr services + RELAY_HUB: Symbol('relayHub'), + NOSTR_CLIENT_HUB: Symbol('nostrClientHub'), + + // Auth services + AUTH_SERVICE: Symbol('authService'), + + // Market services + MARKET_STORE: Symbol('marketStore'), + PAYMENT_MONITOR: Symbol('paymentMonitor'), + + // Chat services + CHAT_SERVICE: Symbol('chatService'), + + // Events services + EVENTS_SERVICE: Symbol('eventsService'), +} as const + +// Type-safe injection helpers +export function injectService(token: ServiceToken): T { + const service = container.inject(token) + if (!service) { + throw new Error(`Service not found for token: ${String(token)}`) + } + return service +} + +export function tryInjectService(token: ServiceToken): T | undefined { + return container.inject(token) +} \ No newline at end of file diff --git a/src/core/event-bus.ts b/src/core/event-bus.ts new file mode 100644 index 0000000..c4b456a --- /dev/null +++ b/src/core/event-bus.ts @@ -0,0 +1,124 @@ +import type { ModuleEvent, ModuleEventHandler } from './types' + +/** + * Global event bus for inter-module communication + * Provides loose coupling between modules via event-driven architecture + */ +export class ModuleEventBus { + private listeners = new Map>() + private eventHistory: ModuleEvent[] = [] + private maxHistorySize = 1000 + + /** + * Emit an event to all registered listeners + */ + emit(type: string, data: any, source = 'unknown'): void { + const event: ModuleEvent = { + type, + source, + data, + timestamp: Date.now() + } + + // Store in history + this.eventHistory.push(event) + if (this.eventHistory.length > this.maxHistorySize) { + this.eventHistory.shift() + } + + // Notify listeners + const listeners = this.listeners.get(type) + if (listeners) { + listeners.forEach(handler => { + try { + handler(event) + } catch (error) { + console.error(`Error in event handler for ${type}:`, error) + } + }) + } + + // Also notify wildcard listeners + const wildcardListeners = this.listeners.get('*') + if (wildcardListeners) { + wildcardListeners.forEach(handler => { + try { + handler(event) + } catch (error) { + console.error(`Error in wildcard event handler:`, error) + } + }) + } + } + + /** + * Register an event listener + */ + on(type: string, handler: ModuleEventHandler): () => void { + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()) + } + + this.listeners.get(type)!.add(handler) + + // Return unsubscribe function + return () => this.off(type, handler) + } + + /** + * Remove an event listener + */ + off(type: string, handler: ModuleEventHandler): void { + const listeners = this.listeners.get(type) + if (listeners) { + listeners.delete(handler) + if (listeners.size === 0) { + this.listeners.delete(type) + } + } + } + + /** + * Remove all listeners for a given event type + */ + removeAllListeners(type: string): void { + this.listeners.delete(type) + } + + /** + * Clear all listeners + */ + clear(): void { + this.listeners.clear() + this.eventHistory = [] + } + + /** + * Get recent events (for debugging/monitoring) + */ + getEventHistory(count = 50): ModuleEvent[] { + return this.eventHistory.slice(-count) + } + + /** + * Get all registered event types + */ + getEventTypes(): string[] { + return Array.from(this.listeners.keys()) + } + + /** + * Get listener count for an event type + */ + getListenerCount(type: string): number { + return this.listeners.get(type)?.size || 0 + } +} + +// Global event bus instance +export const eventBus = new ModuleEventBus() + +// Convenience functions +export const emit = eventBus.emit.bind(eventBus) +export const on = eventBus.on.bind(eventBus) +export const off = eventBus.off.bind(eventBus) \ No newline at end of file diff --git a/src/core/plugin-manager.ts b/src/core/plugin-manager.ts new file mode 100644 index 0000000..cb54421 --- /dev/null +++ b/src/core/plugin-manager.ts @@ -0,0 +1,329 @@ +import type { App } from 'vue' +import type { Router } from 'vue-router' +import type { ModulePlugin, ModuleConfig, ModuleRegistration } from './types' +import { eventBus } from './event-bus' +import { container } from './di-container' + +/** + * Plugin Manager + * Handles module registration, dependency resolution, and lifecycle management + */ +export class PluginManager { + private modules = new Map() + private app: App | null = null + private router: Router | null = null + private installOrder: string[] = [] + + /** + * Initialize the plugin manager + */ + init(app: App, router: Router): void { + this.app = app + this.router = router + + // Register core services + container.provide('app', app) + container.provide('router', router) + container.provide('eventBus', eventBus) + + console.log('🔧 Plugin Manager initialized') + } + + /** + * Register a module plugin + */ + async register(plugin: ModulePlugin, config: ModuleConfig): Promise { + if (this.modules.has(plugin.name)) { + throw new Error(`Module ${plugin.name} is already registered`) + } + + // Validate dependencies + const missingDeps = this.validateDependencies(plugin) + if (missingDeps.length > 0) { + throw new Error(`Module ${plugin.name} has missing dependencies: ${missingDeps.join(', ')}`) + } + + // Register the module + const registration: ModuleRegistration = { + plugin, + config, + installed: false + } + + this.modules.set(plugin.name, registration) + console.log(`📦 Registered module: ${plugin.name} v${plugin.version}`) + + // Auto-install if enabled and not lazy + if (config.enabled && !config.lazy) { + await this.install(plugin.name) + } + + eventBus.emit('module:registered', { name: plugin.name, config }, 'plugin-manager') + } + + /** + * Install a module + */ + async install(moduleName: string): Promise { + const registration = this.modules.get(moduleName) + if (!registration) { + throw new Error(`Module ${moduleName} is not registered`) + } + + if (registration.installed) { + console.warn(`Module ${moduleName} is already installed`) + return + } + + const { plugin, config } = registration + + // Install dependencies first + if (plugin.dependencies) { + for (const dep of plugin.dependencies) { + const depRegistration = this.modules.get(dep) + if (!depRegistration) { + throw new Error(`Dependency ${dep} is not registered`) + } + if (!depRegistration.installed) { + await this.install(dep) + } + } + } + + try { + // Install the module + if (!this.app) { + throw new Error('Plugin manager not initialized') + } + + await plugin.install(this.app, config.config) + + // Register routes if provided + if (plugin.routes && this.router) { + for (const route of plugin.routes) { + this.router.addRoute(route) + } + } + + // Register services in DI container + if (plugin.services) { + for (const [name, service] of Object.entries(plugin.services)) { + container.provide(Symbol(name), service) + } + } + + // Mark as installed + registration.installed = true + registration.installTime = Date.now() + this.installOrder.push(moduleName) + + console.log(`✅ Installed module: ${moduleName}`) + eventBus.emit('module:installed', { name: moduleName, plugin, config }, 'plugin-manager') + + } catch (error) { + console.error(`❌ Failed to install module ${moduleName}:`, error) + eventBus.emit('module:install-failed', { name: moduleName, error }, 'plugin-manager') + throw error + } + } + + /** + * Uninstall a module + */ + async uninstall(moduleName: string): Promise { + const registration = this.modules.get(moduleName) + if (!registration) { + throw new Error(`Module ${moduleName} is not registered`) + } + + if (!registration.installed) { + console.warn(`Module ${moduleName} is not installed`) + return + } + + // Check for dependents + const dependents = this.findDependents(moduleName) + if (dependents.length > 0) { + throw new Error(`Cannot uninstall ${moduleName}: required by ${dependents.join(', ')}`) + } + + try { + // Call module's uninstall hook + if (registration.plugin.uninstall) { + await registration.plugin.uninstall() + } + + // Remove routes + if (registration.plugin.routes && this.router) { + // Note: Vue Router doesn't have removeRoute, so we'd need to track and rebuild + // For now, we'll just log this limitation + console.warn(`Routes from ${moduleName} cannot be removed (Vue Router limitation)`) + } + + // Remove services from DI container + if (registration.plugin.services) { + for (const name of Object.keys(registration.plugin.services)) { + container.remove(Symbol(name)) + } + } + + // Mark as uninstalled + registration.installed = false + registration.installTime = undefined + + const orderIndex = this.installOrder.indexOf(moduleName) + if (orderIndex !== -1) { + this.installOrder.splice(orderIndex, 1) + } + + console.log(`🗑️ Uninstalled module: ${moduleName}`) + eventBus.emit('module:uninstalled', { name: moduleName }, 'plugin-manager') + + } catch (error) { + console.error(`❌ Failed to uninstall module ${moduleName}:`, error) + eventBus.emit('module:uninstall-failed', { name: moduleName, error }, 'plugin-manager') + throw error + } + } + + /** + * Get module registration info + */ + getModule(name: string): ModuleRegistration | undefined { + return this.modules.get(name) + } + + /** + * Get all registered modules + */ + getModules(): Map { + return new Map(this.modules) + } + + /** + * Get installed modules in installation order + */ + getInstalledModules(): string[] { + return [...this.installOrder] + } + + /** + * Check if a module is installed + */ + isInstalled(name: string): boolean { + return this.modules.get(name)?.installed || false + } + + /** + * Get module status + */ + getStatus(): { + registered: number + installed: number + failed: number + modules: Array<{ name: string; version: string; installed: boolean; dependencies?: string[] }> + } { + const modules = Array.from(this.modules.values()) + + return { + registered: modules.length, + installed: modules.filter(m => m.installed).length, + failed: modules.filter(m => !m.installed && m.config.enabled).length, + modules: modules.map(({ plugin, installed }) => ({ + name: plugin.name, + version: plugin.version, + installed, + dependencies: plugin.dependencies + })) + } + } + + /** + * Validate module dependencies + */ + private validateDependencies(plugin: ModulePlugin): string[] { + if (!plugin.dependencies) { + return [] + } + + return plugin.dependencies.filter(dep => !this.modules.has(dep)) + } + + /** + * Find modules that depend on the given module + */ + private findDependents(moduleName: string): string[] { + const dependents: string[] = [] + + for (const [name, registration] of this.modules) { + if (registration.installed && + registration.plugin.dependencies?.includes(moduleName)) { + dependents.push(name) + } + } + + return dependents + } + + /** + * Install all enabled modules + */ + async installAll(): Promise { + const enabledModules = Array.from(this.modules.entries()) + .filter(([_, reg]) => reg.config.enabled && !reg.config.lazy) + .map(([name, _]) => name) + + // Sort by dependencies to ensure correct installation order + const sortedModules = this.topologicalSort(enabledModules) + + for (const moduleName of sortedModules) { + if (!this.isInstalled(moduleName)) { + await this.install(moduleName) + } + } + } + + /** + * Topological sort for dependency resolution + */ + private topologicalSort(moduleNames: string[]): string[] { + const visited = new Set() + const visiting = new Set() + const result: string[] = [] + + const visit = (name: string) => { + if (visiting.has(name)) { + throw new Error(`Circular dependency detected involving module: ${name}`) + } + if (visited.has(name)) { + return + } + + visiting.add(name) + + const registration = this.modules.get(name) + if (registration?.plugin.dependencies) { + for (const dep of registration.plugin.dependencies) { + if (moduleNames.includes(dep)) { + visit(dep) + } + } + } + + visiting.delete(name) + visited.add(name) + result.push(name) + } + + for (const name of moduleNames) { + if (!visited.has(name)) { + visit(name) + } + } + + return result + } +} + +// Global plugin manager instance +export const pluginManager = new PluginManager() \ No newline at end of file diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..9ce1e47 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,93 @@ +import type { App, Component } from 'vue' +import type { RouteRecordRaw } from 'vue-router' + +// Base module plugin interface +export interface ModulePlugin { + /** Unique module name */ + name: string + + /** Module version */ + version: string + + /** Required dependencies (other module names) */ + dependencies?: string[] + + /** Module configuration */ + config?: Record + + /** Install the module */ + install(app: App, options?: any): Promise | void + + /** Uninstall the module (cleanup) */ + uninstall?(): Promise | void + + /** Routes provided by this module */ + routes?: RouteRecordRaw[] + + /** Components provided by this module */ + components?: Record + + /** Services provided by this module */ + services?: Record + + /** Composables provided by this module */ + composables?: Record +} + +// Module configuration for app setup +export interface ModuleConfig { + /** Module name */ + name: string + + /** Whether module is enabled */ + enabled: boolean + + /** Module-specific configuration */ + config?: Record + + /** Load module dynamically */ + lazy?: boolean +} + +// App configuration +export interface AppConfig { + /** Configured modules */ + modules: Record + + /** Global app features */ + features: { + pwa: boolean + pushNotifications: boolean + electronApp: boolean + developmentMode: boolean + } +} + +// Module registration info +export interface ModuleRegistration { + plugin: ModulePlugin + config: ModuleConfig + installed: boolean + installTime?: number +} + +// Event system for inter-module communication +export interface ModuleEvent { + type: string + source: string + data: any + timestamp: number +} + +export type ModuleEventHandler = (event: ModuleEvent) => void + +// Service injection tokens +export type ServiceToken = string | symbol + +// Dependency injection container interface +export interface DIContainer { + provide(token: ServiceToken, service: T, scope?: 'singleton' | 'transient'): void + inject(token: ServiceToken): T | undefined + remove(token: ServiceToken): boolean + clear(): void +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 2302ee1..ee1eff7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,19 +1,8 @@ -import { createApp } from 'vue' -import { createPinia } from 'pinia' -import App from './App.vue' -import router from './router' -import { i18n } from './i18n' -import './assets/index.css' +// New modular application entry point +import { startApp } from './app' import { registerSW } from 'virtual:pwa-register' import 'vue-sonner/style.css' -const app = createApp(App) -const pinia = createPinia() - -app.use(router) -app.use(i18n) -app.use(pinia) - // Simple periodic service worker updates const intervalMS = 60 * 60 * 1000 // 1 hour registerSW({ @@ -27,4 +16,5 @@ registerSW({ } }) -app.mount('#app') +// Start the modular application +startApp() diff --git a/src/modules/base/auth/auth-service.ts b/src/modules/base/auth/auth-service.ts new file mode 100644 index 0000000..11b8d61 --- /dev/null +++ b/src/modules/base/auth/auth-service.ts @@ -0,0 +1,80 @@ +// Copy the existing auth logic into a service class +import { ref } from 'vue' +import { eventBus } from '@/core/event-bus' + +export class AuthService { + public isAuthenticated = ref(false) + public user = ref(null) + public isLoading = ref(false) + + async initialize(): Promise { + console.log('🔑 Initializing auth service...') + + // Check for existing auth state + this.checkAuth() + + if (this.isAuthenticated.value) { + eventBus.emit('auth:login', { user: this.user.value }, 'auth-service') + } + } + + checkAuth(): boolean { + // Implement your existing auth check logic here + // For now, we'll use a simple localStorage check + const authData = localStorage.getItem('auth') + if (authData) { + try { + const parsed = JSON.parse(authData) + this.isAuthenticated.value = true + this.user.value = parsed + return true + } catch (error) { + console.error('Invalid auth data in localStorage:', error) + this.logout() + } + } + + this.isAuthenticated.value = false + this.user.value = null + return false + } + + async login(credentials: any): Promise { + this.isLoading.value = true + + try { + // Implement your login logic here + // For demo purposes, we'll accept any credentials + this.user.value = credentials + this.isAuthenticated.value = true + + // Store auth state + localStorage.setItem('auth', JSON.stringify(credentials)) + + eventBus.emit('auth:login', { user: credentials }, 'auth-service') + + } catch (error) { + console.error('Login failed:', error) + eventBus.emit('auth:login-failed', { error }, 'auth-service') + throw error + } finally { + this.isLoading.value = false + } + } + + logout(): void { + this.user.value = null + this.isAuthenticated.value = false + localStorage.removeItem('auth') + + eventBus.emit('auth:logout', {}, 'auth-service') + } + + async refresh(): Promise { + // Implement token refresh logic if needed + console.log('Refreshing auth token...') + } +} + +// Export singleton instance +export const auth = new AuthService() \ No newline at end of file diff --git a/src/modules/base/index.ts b/src/modules/base/index.ts new file mode 100644 index 0000000..abf431e --- /dev/null +++ b/src/modules/base/index.ts @@ -0,0 +1,65 @@ +import type { App } from 'vue' +import type { ModulePlugin } from '@/core/types' +import { container, SERVICE_TOKENS } from '@/core/di-container' +import { relayHub } from './nostr/relay-hub' +import { nostrclientHub } from './nostr/nostrclient-hub' + +// Import auth services +import { auth } from './auth/auth-service' + +// Import PWA services +import { pwaService } from './pwa/pwa-service' + +/** + * Base Module Plugin + * Provides core infrastructure: Nostr, Auth, PWA, and UI components + */ +export const baseModule: ModulePlugin = { + name: 'base', + version: '1.0.0', + + async install(_app: App, options?: any) { + console.log('🔧 Installing base module...') + + // Register core Nostr services + container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub) + container.provide(SERVICE_TOKENS.NOSTR_CLIENT_HUB, nostrclientHub) + + // Register auth service + container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth) + + // Register PWA service + container.provide('pwaService', pwaService) + + // Initialize core services + await relayHub.initialize(options?.nostr?.relays || []) + await auth.initialize() + + console.log('✅ Base module installed successfully') + }, + + async uninstall() { + console.log('🗑️ Uninstalling base module...') + + // Cleanup Nostr connections + relayHub.disconnect() + nostrclientHub.disconnect?.() + + console.log('✅ Base module uninstalled') + }, + + services: { + relayHub, + nostrclientHub, + auth, + pwaService + }, + + // No routes - base module is pure infrastructure + routes: [], + + // No UI components at module level - they'll be imported as needed + components: {} +} + +export default baseModule \ No newline at end of file diff --git a/src/modules/base/nostr/index.ts b/src/modules/base/nostr/index.ts new file mode 100644 index 0000000..2cd4bdf --- /dev/null +++ b/src/modules/base/nostr/index.ts @@ -0,0 +1,16 @@ +// Re-export Nostr infrastructure from base module +export { RelayHub } from './relay-hub' +export { NostrclientHub } from './nostrclient-hub' +export { relayHub } from './relay-hub' +export { nostrclientHub } from './nostrclient-hub' + +// Re-export types +export type { + RelayConfig, + SubscriptionConfig, + RelayStatus +} from './relay-hub' + +export type { + NostrclientConfig +} from './nostrclient-hub' \ No newline at end of file diff --git a/src/modules/base/nostr/nostrclient-hub.ts b/src/modules/base/nostr/nostrclient-hub.ts new file mode 100644 index 0000000..0db1f6a --- /dev/null +++ b/src/modules/base/nostr/nostrclient-hub.ts @@ -0,0 +1,362 @@ +import type { Filter, Event } from 'nostr-tools' + +// Simple EventEmitter for browser compatibility +class EventEmitter { + private events: { [key: string]: Function[] } = {} + + on(event: string, listener: Function) { + if (!this.events[event]) { + this.events[event] = [] + } + this.events[event].push(listener) + } + + emit(event: string, ...args: any[]) { + if (this.events[event]) { + this.events[event].forEach(listener => listener(...args)) + } + } + + removeAllListeners(event?: string) { + if (event) { + delete this.events[event] + } else { + this.events = {} + } + } +} + +export interface NostrclientConfig { + url: string + privateKey?: string // For private WebSocket endpoint +} + +export interface SubscriptionConfig { + id: string + filters: Filter[] + onEvent?: (event: Event) => void + onEose?: () => void + onClose?: () => void +} + +export interface RelayStatus { + url: string + connected: boolean + lastSeen: number + error?: string +} + +export class NostrclientHub extends EventEmitter { + private ws: WebSocket | null = null + private config: NostrclientConfig + private subscriptions: Map = new Map() + private reconnectInterval?: number + private reconnectAttempts = 0 + private readonly maxReconnectAttempts = 5 + private readonly reconnectDelay = 5000 + + // Connection state + private _isConnected = false + private _isConnecting = false + + constructor(config: NostrclientConfig) { + super() + this.config = config + } + + get isConnected(): boolean { + return this._isConnected + } + + get isConnecting(): boolean { + return this._isConnecting + } + + get totalSubscriptionCount(): number { + return this.subscriptions.size + } + + get subscriptionDetails(): Array<{ id: string; filters: Filter[] }> { + return Array.from(this.subscriptions.values()).map(sub => ({ + id: sub.id, + filters: sub.filters + })) + } + + /** + * Initialize and connect to nostrclient WebSocket + */ + async initialize(): Promise { + console.log('🔧 NostrclientHub: Initializing connection to', this.config.url) + await this.connect() + } + + /** + * Connect to the nostrclient WebSocket + */ + async connect(): Promise { + if (this._isConnecting || this._isConnected) { + return + } + + this._isConnecting = true + this.reconnectAttempts++ + + try { + console.log('🔧 NostrclientHub: Connecting to nostrclient WebSocket') + + // Determine WebSocket endpoint + const wsUrl = this.config.privateKey + ? `${this.config.url}/${this.config.privateKey}` // Private endpoint + : `${this.config.url}/relay` // Public endpoint + + this.ws = new WebSocket(wsUrl) + + this.ws.onopen = () => { + console.log('🔧 NostrclientHub: WebSocket connected') + this._isConnected = true + this._isConnecting = false + this.reconnectAttempts = 0 + this.emit('connected') + + // Resubscribe to existing subscriptions + this.resubscribeAll() + } + + this.ws.onmessage = (event) => { + this.handleMessage(event.data) + } + + this.ws.onclose = (event) => { + console.log('🔧 NostrclientHub: WebSocket closed:', event.code, event.reason) + this._isConnected = false + this._isConnecting = false + this.emit('disconnected', event) + + // Schedule reconnection + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.scheduleReconnect() + } else { + this.emit('maxReconnectionAttemptsReached') + } + } + + this.ws.onerror = (error) => { + console.error('🔧 NostrclientHub: WebSocket error:', error) + this.emit('error', error) + } + + } catch (error) { + this._isConnecting = false + console.error('🔧 NostrclientHub: Connection failed:', error) + this.emit('connectionError', error) + + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.scheduleReconnect() + } + } + } + + /** + * Disconnect from the WebSocket + */ + disconnect(): void { + if (this.reconnectInterval) { + clearTimeout(this.reconnectInterval) + this.reconnectInterval = undefined + } + + if (this.ws) { + this.ws.close() + this.ws = null + } + + this._isConnected = false + this._isConnecting = false + this.subscriptions.clear() + this.emit('disconnected') + } + + /** + * Subscribe to events + */ + subscribe(config: SubscriptionConfig): () => void { + if (!this._isConnected) { + throw new Error('Not connected to nostrclient') + } + + // Store subscription + this.subscriptions.set(config.id, config) + + // Send REQ message + const reqMessage = JSON.stringify([ + 'REQ', + config.id, + ...config.filters + ]) + + this.ws?.send(reqMessage) + console.log('🔧 NostrclientHub: Subscribed to', config.id) + + // Return unsubscribe function + return () => { + this.unsubscribe(config.id) + } + } + + /** + * Unsubscribe from events + */ + unsubscribe(subscriptionId: string): void { + if (!this._isConnected) { + return + } + + // Send CLOSE message + const closeMessage = JSON.stringify(['CLOSE', subscriptionId]) + this.ws?.send(closeMessage) + + // Remove from subscriptions + this.subscriptions.delete(subscriptionId) + console.log('🔧 NostrclientHub: Unsubscribed from', subscriptionId) + } + + /** + * Publish an event + */ + async publishEvent(event: Event): Promise { + if (!this._isConnected) { + throw new Error('Not connected to nostrclient') + } + + const eventMessage = JSON.stringify(['EVENT', event]) + this.ws?.send(eventMessage) + + console.log('🔧 NostrclientHub: Published event', event.id) + this.emit('eventPublished', { eventId: event.id }) + } + + /** + * Query events (one-time fetch) + */ + async queryEvents(filters: Filter[]): Promise { + return new Promise((resolve, reject) => { + if (!this._isConnected) { + reject(new Error('Not connected to nostrclient')) + return + } + + const queryId = `query-${Date.now()}` + const events: Event[] = [] + let eoseReceived = false + + // Create temporary subscription for query + const tempSubscription = this.subscribe({ + id: queryId, + filters, + onEvent: (event) => { + events.push(event) + }, + onEose: () => { + eoseReceived = true + this.unsubscribe(queryId) + resolve(events) + }, + onClose: () => { + if (!eoseReceived) { + reject(new Error('Query subscription closed unexpectedly')) + } + } + }) + + // Timeout after 30 seconds + setTimeout(() => { + if (!eoseReceived) { + tempSubscription() + reject(new Error('Query timeout')) + } + }, 30000) + }) + } + + /** + * Handle incoming WebSocket messages + */ + private handleMessage(data: string): void { + try { + const message = JSON.parse(data) + + if (Array.isArray(message) && message.length >= 2) { + const [type, subscriptionId, ...rest] = message + + switch (type) { + case 'EVENT': + const event = rest[0] as Event + const subscription = this.subscriptions.get(subscriptionId) + if (subscription?.onEvent) { + subscription.onEvent(event) + } + this.emit('event', { subscriptionId, event }) + break + + case 'EOSE': + const eoseSubscription = this.subscriptions.get(subscriptionId) + if (eoseSubscription?.onEose) { + eoseSubscription.onEose() + } + this.emit('eose', { subscriptionId }) + break + + case 'NOTICE': + console.log('🔧 NostrclientHub: Notice from relay:', rest[0]) + this.emit('notice', { message: rest[0] }) + break + + default: + console.log('🔧 NostrclientHub: Unknown message type:', type) + } + } + } catch (error) { + console.error('🔧 NostrclientHub: Failed to parse message:', error) + } + } + + /** + * Resubscribe to all existing subscriptions after reconnection + */ + private resubscribeAll(): void { + for (const [id, config] of this.subscriptions) { + const reqMessage = JSON.stringify([ + 'REQ', + id, + ...config.filters + ]) + this.ws?.send(reqMessage) + } + console.log('🔧 NostrclientHub: Resubscribed to', this.subscriptions.size, 'subscriptions') + } + + /** + * Schedule automatic reconnection + */ + private scheduleReconnect(): void { + if (this.reconnectInterval) { + clearTimeout(this.reconnectInterval) + } + + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1) + console.log(`🔧 NostrclientHub: Scheduling reconnection in ${delay}ms`) + + this.reconnectInterval = setTimeout(async () => { + await this.connect() + }, delay) as unknown as number + } +} + +// Export singleton instance +export const nostrclientHub = new NostrclientHub({ + url: import.meta.env.VITE_NOSTRCLIENT_URL || 'wss://localhost:5000/nostrclient/api/v1' +}) + +// Ensure global export +;(globalThis as any).nostrclientHub = nostrclientHub diff --git a/src/modules/base/nostr/relay-hub.ts b/src/modules/base/nostr/relay-hub.ts new file mode 100644 index 0000000..162911e --- /dev/null +++ b/src/modules/base/nostr/relay-hub.ts @@ -0,0 +1,524 @@ +import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools' + +// Simple EventEmitter implementation for browser compatibility +class EventEmitter { + private events: { [key: string]: Function[] } = {} + + on(event: string, listener: Function): void { + if (!this.events[event]) { + this.events[event] = [] + } + this.events[event].push(listener) + } + + off(event: string, listener: Function): void { + if (!this.events[event]) return + const index = this.events[event].indexOf(listener) + if (index > -1) { + this.events[event].splice(index, 1) + } + } + + emit(event: string, ...args: any[]): void { + if (!this.events[event]) return + this.events[event].forEach(listener => listener(...args)) + } + + removeAllListeners(event?: string): void { + if (event) { + delete this.events[event] + } else { + this.events = {} + } + } +} + +export interface RelayConfig { + url: string + read: boolean + write: boolean + priority?: number // Lower number = higher priority +} + +export interface SubscriptionConfig { + id: string + filters: Filter[] + relays?: string[] // If not specified, uses all connected relays + onEvent?: (event: Event) => void + onEose?: () => void + onClose?: () => void +} + +export interface RelayStatus { + url: string + connected: boolean + lastSeen: number + error?: string + latency?: number +} + +export class RelayHub extends EventEmitter { + private pool: SimplePool + private relayConfigs: Map = new Map() + private connectedRelays: Map = new Map() + private subscriptions: Map = new Map() + public isInitialized = false + private reconnectInterval?: number + private healthCheckInterval?: number + private mobileVisibilityHandler?: () => void + + // Connection state + private _isConnected = false + private _connectionAttempts = 0 + private readonly maxReconnectAttempts = 5 + private readonly reconnectDelay = 5000 // 5 seconds + private readonly healthCheckIntervalMs = 30000 // 30 seconds + + constructor() { + super() + this.pool = new SimplePool() + this.setupMobileVisibilityHandling() + } + + get isConnected(): boolean { + return this._isConnected + } + + get connectedRelayCount(): number { + // Return the actual size of connectedRelays map + return this.connectedRelays.size + } + + get totalRelayCount(): number { + return this.relayConfigs.size + } + + get totalSubscriptionCount(): number { + return this.subscriptions.size + } + + get subscriptionDetails(): Array<{ id: string; filters: any[]; relays?: string[] }> { + return Array.from(this.subscriptions.entries()).map(([id, subscription]) => { + // Try to extract subscription details if available + return { + id, + filters: subscription.filters || [], + relays: subscription.relays || [] + } + }) + } + + get relayStatuses(): RelayStatus[] { + return Array.from(this.relayConfigs.values()).map(config => { + const relay = this.connectedRelays.get(config.url) + return { + url: config.url, + connected: !!relay, + lastSeen: relay ? Date.now() : 0, + error: relay ? undefined : 'Not connected', + latency: relay ? 0 : undefined // TODO: Implement actual latency measurement + } + }) + } + + /** + * Initialize the relay hub with relay configurations + */ + async initialize(relayUrls: string[]): Promise { + if (this.isInitialized) { + console.warn('RelayHub already initialized') + return + } + + console.log('🔧 RelayHub: Initializing with URLs:', relayUrls) + + // Convert URLs to relay configs + this.relayConfigs.clear() + relayUrls.forEach((url, index) => { + this.relayConfigs.set(url, { + url, + read: true, + write: true, + priority: index + }) + }) + + console.log('🔧 RelayHub: Relay configs created:', Array.from(this.relayConfigs.values())) + + // Start connection management + console.log('🔧 RelayHub: Starting connection...') + await this.connect() + this.startHealthCheck() + this.isInitialized = true + console.log('🔧 RelayHub: Initialization complete') + } + + /** + * Connect to all configured relays + */ + async connect(): Promise { + if (this.relayConfigs.size === 0) { + throw new Error('No relay configurations found. Call initialize() first.') + } + + console.log('🔧 RelayHub: Connecting to', this.relayConfigs.size, 'relays') + + try { + this._connectionAttempts++ + console.log('🔧 RelayHub: Connection attempt', this._connectionAttempts) + + // Connect to relays in priority order + const sortedRelays = Array.from(this.relayConfigs.values()) + .sort((a, b) => (a.priority || 0) - (b.priority || 0)) + + console.log('🔧 RelayHub: Attempting connections to:', sortedRelays.map(r => r.url)) + + const connectionPromises = sortedRelays.map(async (config) => { + try { + console.log('🔧 RelayHub: Connecting to relay:', config.url) + const relay = await this.pool.ensureRelay(config.url) + this.connectedRelays.set(config.url, relay) + console.log('🔧 RelayHub: Successfully connected to:', config.url) + + return { url: config.url, success: true } + } catch (error) { + console.error(`🔧 RelayHub: Failed to connect to relay ${config.url}:`, error) + return { url: config.url, success: false, error } + } + }) + + const results = await Promise.allSettled(connectionPromises) + const successfulConnections = results.filter( + result => result.status === 'fulfilled' && result.value.success + ) + + console.log('🔧 RelayHub: Connection results:', { + total: results.length, + successful: successfulConnections.length, + failed: results.length - successfulConnections.length + }) + + if (successfulConnections.length > 0) { + this._isConnected = true + this._connectionAttempts = 0 + console.log('🔧 RelayHub: Connection successful, connected to', successfulConnections.length, 'relays') + this.emit('connected', successfulConnections.length) + + } else { + console.error('🔧 RelayHub: Failed to connect to any relay') + throw new Error('Failed to connect to any relay') + } + } catch (error) { + this._isConnected = false + console.error('🔧 RelayHub: Connection failed with error:', error) + this.emit('connectionError', error) + + // Schedule reconnection if we haven't exceeded max attempts + if (this._connectionAttempts < this.maxReconnectAttempts) { + console.log('🔧 RelayHub: Scheduling reconnection attempt', this._connectionAttempts + 1) + this.scheduleReconnect() + } else { + this.emit('maxReconnectionAttemptsReached') + console.error('🔧 RelayHub: Max reconnection attempts reached') + } + } + } + + /** + * Disconnect from all relays + */ + disconnect(): void { + + + // Clear intervals + if (this.reconnectInterval) { + clearTimeout(this.reconnectInterval) + this.reconnectInterval = undefined + } + + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval) + this.healthCheckInterval = undefined + } + + // Close all subscriptions + this.subscriptions.forEach(sub => sub.close()) + this.subscriptions.clear() + + // Close all relay connections + this.pool.close(Array.from(this.relayConfigs.keys())) + this.connectedRelays.clear() + + this._isConnected = false + this.emit('disconnected') + } + + /** + * Subscribe to events from relays + */ + subscribe(config: SubscriptionConfig): () => void { + if (!this.isInitialized) { + throw new Error('RelayHub not initialized. Call initialize() first.') + } + + if (!this._isConnected) { + throw new Error('Not connected to any relays') + } + + // Determine which relays to use + const targetRelays = config.relays || Array.from(this.connectedRelays.keys()) + const availableRelays = targetRelays.filter(url => this.connectedRelays.has(url)) + + if (availableRelays.length === 0) { + throw new Error('No available relays for subscription') + } + + + + // Create subscription using the pool + const subscription = this.pool.subscribeMany(availableRelays, config.filters, { + onevent: (event: Event) => { + config.onEvent?.(event) + this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' }) + }, + oneose: () => { + config.onEose?.() + this.emit('eose', { subscriptionId: config.id }) + } + }) + + // Store subscription for cleanup + this.subscriptions.set(config.id, subscription) + + // Emit subscription created event + this.emit('subscriptionCreated', { id: config.id, count: this.subscriptions.size }) + + // Return unsubscribe function + return () => { + this.unsubscribe(config.id) + } + } + + /** + * Unsubscribe from a specific subscription + */ + unsubscribe(subscriptionId: string): void { + const subscription = this.subscriptions.get(subscriptionId) + if (subscription) { + subscription.close() + this.subscriptions.delete(subscriptionId) + + + // Emit subscription removed event + this.emit('subscriptionRemoved', { id: subscriptionId, count: this.subscriptions.size }) + } + } + + /** + * Publish an event to all connected relays + */ + async publishEvent(event: Event): Promise<{ success: number; total: number }> { + if (!this._isConnected) { + throw new Error('Not connected to any relays') + } + + const relayUrls = Array.from(this.connectedRelays.keys()) + const results = await Promise.allSettled( + relayUrls.map(relay => this.pool.publish([relay], event)) + ) + + const successful = results.filter(result => result.status === 'fulfilled').length + const total = results.length + + + this.emit('eventPublished', { eventId: event.id, success: successful, total }) + + return { success: successful, total } + } + + /** + * Query events from relays (one-time fetch) + */ + async queryEvents(filters: Filter[], relays?: string[]): Promise { + if (!this._isConnected) { + throw new Error('Not connected to any relays') + } + + const targetRelays = relays || Array.from(this.connectedRelays.keys()) + const availableRelays = targetRelays.filter(url => this.connectedRelays.has(url)) + + if (availableRelays.length === 0) { + throw new Error('No available relays for query') + } + + try { + // Query each filter separately and combine results + const allEvents: Event[] = [] + for (const filter of filters) { + const events = await this.pool.querySync(availableRelays, filter) + allEvents.push(...events) + } + + return allEvents + } catch (error) { + console.error('Query failed:', error) + throw error + } + } + + /** + * Get a specific relay instance + */ + getRelay(url: string): Relay | undefined { + return this.connectedRelays.get(url) + } + + /** + * Check if a specific relay is connected + */ + isRelayConnected(url: string): boolean { + return this.connectedRelays.has(url) + } + + /** + * Force reconnection to all relays + */ + async reconnect(): Promise { + + this.disconnect() + await this.connect() + } + + /** + * Schedule automatic reconnection + */ + private scheduleReconnect(): void { + if (this.reconnectInterval) { + clearTimeout(this.reconnectInterval) + } + + this.reconnectInterval = setTimeout(async () => { + + await this.connect() + }, this.reconnectDelay) as unknown as number + } + + /** + * Start health check monitoring + */ + private startHealthCheck(): void { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval) + } + + this.healthCheckInterval = setInterval(() => { + this.performHealthCheck() + }, this.healthCheckIntervalMs) as unknown as number + } + + /** + * Perform health check on all relays + */ + private async performHealthCheck(): Promise { + if (!this._isConnected) return + + + const disconnectedRelays: string[] = [] + + // Check each relay connection + for (const [url] of this.connectedRelays) { + try { + // Try to send a ping or check connection status + // For now, we'll just check if the relay is still in our connected relays map + if (!this.connectedRelays.has(url)) { + disconnectedRelays.push(url) + } + } catch (error) { + console.warn(`Health check failed for relay ${url}:`, error) + disconnectedRelays.push(url) + } + } + + // Remove disconnected relays + disconnectedRelays.forEach(url => { + this.connectedRelays.delete(url) + + }) + + // Update connection status + if (this.connectedRelays.size === 0) { + this._isConnected = false + this.emit('allRelaysDisconnected') + console.warn('All relays disconnected, attempting reconnection...') + await this.connect() + } else if (this.connectedRelays.size < this.relayConfigs.size) { + this.emit('partialDisconnection', { + connected: this.connectedRelays.size, + total: this.relayConfigs.size + }) + } + } + + /** + * Setup mobile visibility handling for better WebSocket management + */ + private setupMobileVisibilityHandling(): void { + // Handle page visibility changes (mobile app backgrounding) + if (typeof document !== 'undefined') { + this.mobileVisibilityHandler = () => { + if (document.hidden) { + + // Keep connections alive but reduce activity + } else { + console.log('Page visible, resuming normal WebSocket activity') + // Resume normal activity and check connections + this.performHealthCheck() + } + } + + document.addEventListener('visibilitychange', this.mobileVisibilityHandler) + } + + // Handle online/offline events + if (typeof window !== 'undefined') { + window.addEventListener('online', () => { + + this.performHealthCheck() + }) + + window.addEventListener('offline', () => { + console.log('Network offline, marking as disconnected...') + this._isConnected = false + this.emit('networkOffline') + }) + } + } + + /** + * Cleanup resources + */ + destroy(): void { + + + // Remove event listeners + if (this.mobileVisibilityHandler && typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', this.mobileVisibilityHandler) + } + + if (typeof window !== 'undefined') { + window.removeEventListener('online', () => {}) + window.removeEventListener('offline', () => {}) + } + + // Disconnect and cleanup + this.disconnect() + this.removeAllListeners() + this.isInitialized = false + } +} + +// Export singleton instance +export const relayHub = new RelayHub() + +// Ensure global export +;(globalThis as any).relayHub = relayHub diff --git a/src/modules/base/pwa/pwa-service.ts b/src/modules/base/pwa/pwa-service.ts new file mode 100644 index 0000000..c727b9e --- /dev/null +++ b/src/modules/base/pwa/pwa-service.ts @@ -0,0 +1,45 @@ +// PWA service for base module +export class PWAService { + private deferredPrompt: any = null + + async initialize(): Promise { + console.log('📱 Initializing PWA service...') + + // Listen for beforeinstallprompt event + window.addEventListener('beforeinstallprompt', (e) => { + console.log('PWA install prompt available') + e.preventDefault() + this.deferredPrompt = e + }) + + // Listen for app installed event + window.addEventListener('appinstalled', () => { + console.log('PWA was installed') + this.deferredPrompt = null + }) + } + + canInstall(): boolean { + return this.deferredPrompt !== null + } + + async install(): Promise { + if (!this.deferredPrompt) { + return false + } + + this.deferredPrompt.prompt() + const result = await this.deferredPrompt.userChoice + + if (result.outcome === 'accepted') { + console.log('User accepted PWA install') + } else { + console.log('User dismissed PWA install') + } + + this.deferredPrompt = null + return result.outcome === 'accepted' + } +} + +export const pwaService = new PWAService() \ No newline at end of file diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue new file mode 100644 index 0000000..1723445 --- /dev/null +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -0,0 +1,336 @@ + + + diff --git a/src/modules/nostr-feed/composables/useFeed.ts b/src/modules/nostr-feed/composables/useFeed.ts new file mode 100644 index 0000000..91259cd --- /dev/null +++ b/src/modules/nostr-feed/composables/useFeed.ts @@ -0,0 +1,130 @@ +import { ref, computed, 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' + +export interface FeedConfig { + feedType: 'announcements' | 'general' | 'mentions' + 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) + + let refreshTimer: number | null = null + let unsubscribe: (() => void) | null = null + + 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 + }) + + const loadFeed = async () => { + if (!relayHub) { + error.value = 'RelayHub not available' + return + } + + isLoading.value = true + error.value = null + + 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') + } + + } 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 startAutoRefresh = () => { + if (config.refreshInterval && config.refreshInterval > 0) { + refreshTimer = setInterval(refreshFeed, config.refreshInterval) as unknown as number + } + } + + const stopAutoRefresh = () => { + if (refreshTimer) { + clearInterval(refreshTimer) + refreshTimer = null + } + } + + // Lifecycle + onMounted(() => { + loadFeed() + startAutoRefresh() + }) + + onUnmounted(() => { + stopAutoRefresh() + if (unsubscribe) { + unsubscribe() + } + }) + + return { + posts: filteredPosts, + isLoading, + error, + refreshFeed, + loadFeed + } +} \ No newline at end of file diff --git a/src/modules/nostr-feed/index.ts b/src/modules/nostr-feed/index.ts new file mode 100644 index 0000000..95f8958 --- /dev/null +++ b/src/modules/nostr-feed/index.ts @@ -0,0 +1,47 @@ +import type { App } from 'vue' +import type { ModulePlugin } from '@/core/types' +import type { RouteRecordRaw } from 'vue-router' +import NostrFeed from './components/NostrFeed.vue' +import { useFeed } from './composables/useFeed' + +/** + * Nostr Feed Module Plugin + * Provides social feed functionality with admin announcements support + */ +export const nostrFeedModule: ModulePlugin = { + name: 'nostr-feed', + version: '1.0.0', + dependencies: ['base'], + + async install(app: App, _options?: any) { + console.log('📰 Installing nostr-feed module...') + + // Register global components + app.component('NostrFeed', NostrFeed) + + // Module-specific initialization + console.log('✅ Nostr-feed module installed successfully') + }, + + async uninstall() { + console.log('🗑️ Uninstalling nostr-feed module...') + // Cleanup if needed + console.log('✅ Nostr-feed module uninstalled') + }, + + // Routes - currently none, but feed could have its own page + routes: [] as RouteRecordRaw[], + + components: { + NostrFeed + }, + + composables: { + useFeed + }, + + // Services that other modules can use + services: {} +} + +export default nostrFeedModule \ No newline at end of file