Implement modular architecture with core services and Nostr integration
- Introduce a modular application structure with a new app configuration file to manage module settings and features. - Implement a dependency injection container for service management across modules. - Create a plugin manager to handle module registration, installation, and lifecycle management. - Develop a global event bus for inter-module communication, enhancing loose coupling between components. - Add core modules including base functionalities, Nostr feed, and PWA services, with support for dynamic loading and configuration. - Establish a Nostr client hub for managing WebSocket connections and event handling. - Enhance user experience with a responsive Nostr feed component, integrating admin announcements and community posts. - Refactor existing components to align with the new modular architecture, improving maintainability and scalability.
This commit is contained in:
parent
2d8215a35e
commit
519a9003d4
16 changed files with 2520 additions and 14 deletions
71
src/app.config.ts
Normal file
71
src/app.config.ts
Normal file
|
|
@ -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
|
||||||
157
src/app.ts
Normal file
157
src/app.ts
Normal file
|
|
@ -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 = `
|
||||||
|
<div style="padding: 20px; text-align: center; color: red;">
|
||||||
|
<h1>Application Failed to Start</h1>
|
||||||
|
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||||
|
<p>Please refresh the page or contact support.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/core/di-container.ts
Normal file
137
src/core/di-container.ts
Normal file
|
|
@ -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<ServiceToken, ServiceRegistration>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a service in the container
|
||||||
|
*/
|
||||||
|
provide<T>(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<T>(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<T>(token: ServiceToken): T {
|
||||||
|
const service = container.inject<T>(token)
|
||||||
|
if (!service) {
|
||||||
|
throw new Error(`Service not found for token: ${String(token)}`)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryInjectService<T>(token: ServiceToken): T | undefined {
|
||||||
|
return container.inject<T>(token)
|
||||||
|
}
|
||||||
124
src/core/event-bus.ts
Normal file
124
src/core/event-bus.ts
Normal file
|
|
@ -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<string, Set<ModuleEventHandler>>()
|
||||||
|
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)
|
||||||
329
src/core/plugin-manager.ts
Normal file
329
src/core/plugin-manager.ts
Normal file
|
|
@ -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<string, ModuleRegistration>()
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, ModuleRegistration> {
|
||||||
|
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<void> {
|
||||||
|
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<string>()
|
||||||
|
const visiting = new Set<string>()
|
||||||
|
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()
|
||||||
93
src/core/types.ts
Normal file
93
src/core/types.ts
Normal file
|
|
@ -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<string, any>
|
||||||
|
|
||||||
|
/** Install the module */
|
||||||
|
install(app: App, options?: any): Promise<void> | void
|
||||||
|
|
||||||
|
/** Uninstall the module (cleanup) */
|
||||||
|
uninstall?(): Promise<void> | void
|
||||||
|
|
||||||
|
/** Routes provided by this module */
|
||||||
|
routes?: RouteRecordRaw[]
|
||||||
|
|
||||||
|
/** Components provided by this module */
|
||||||
|
components?: Record<string, Component>
|
||||||
|
|
||||||
|
/** Services provided by this module */
|
||||||
|
services?: Record<string, any>
|
||||||
|
|
||||||
|
/** Composables provided by this module */
|
||||||
|
composables?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module configuration for app setup
|
||||||
|
export interface ModuleConfig {
|
||||||
|
/** Module name */
|
||||||
|
name: string
|
||||||
|
|
||||||
|
/** Whether module is enabled */
|
||||||
|
enabled: boolean
|
||||||
|
|
||||||
|
/** Module-specific configuration */
|
||||||
|
config?: Record<string, any>
|
||||||
|
|
||||||
|
/** Load module dynamically */
|
||||||
|
lazy?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// App configuration
|
||||||
|
export interface AppConfig {
|
||||||
|
/** Configured modules */
|
||||||
|
modules: Record<string, ModuleConfig>
|
||||||
|
|
||||||
|
/** 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<T>(token: ServiceToken, service: T, scope?: 'singleton' | 'transient'): void
|
||||||
|
inject<T>(token: ServiceToken): T | undefined
|
||||||
|
remove(token: ServiceToken): boolean
|
||||||
|
clear(): void
|
||||||
|
}
|
||||||
18
src/main.ts
18
src/main.ts
|
|
@ -1,19 +1,8 @@
|
||||||
import { createApp } from 'vue'
|
// New modular application entry point
|
||||||
import { createPinia } from 'pinia'
|
import { startApp } from './app'
|
||||||
import App from './App.vue'
|
|
||||||
import router from './router'
|
|
||||||
import { i18n } from './i18n'
|
|
||||||
import './assets/index.css'
|
|
||||||
import { registerSW } from 'virtual:pwa-register'
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
import 'vue-sonner/style.css'
|
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
|
// Simple periodic service worker updates
|
||||||
const intervalMS = 60 * 60 * 1000 // 1 hour
|
const intervalMS = 60 * 60 * 1000 // 1 hour
|
||||||
registerSW({
|
registerSW({
|
||||||
|
|
@ -27,4 +16,5 @@ registerSW({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.mount('#app')
|
// Start the modular application
|
||||||
|
startApp()
|
||||||
|
|
|
||||||
80
src/modules/base/auth/auth-service.ts
Normal file
80
src/modules/base/auth/auth-service.ts
Normal file
|
|
@ -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<any>(null)
|
||||||
|
public isLoading = ref(false)
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
// Implement token refresh logic if needed
|
||||||
|
console.log('Refreshing auth token...')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const auth = new AuthService()
|
||||||
65
src/modules/base/index.ts
Normal file
65
src/modules/base/index.ts
Normal file
|
|
@ -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
|
||||||
16
src/modules/base/nostr/index.ts
Normal file
16
src/modules/base/nostr/index.ts
Normal file
|
|
@ -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'
|
||||||
362
src/modules/base/nostr/nostrclient-hub.ts
Normal file
362
src/modules/base/nostr/nostrclient-hub.ts
Normal file
|
|
@ -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<string, SubscriptionConfig> = 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<void> {
|
||||||
|
console.log('🔧 NostrclientHub: Initializing connection to', this.config.url)
|
||||||
|
await this.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the nostrclient WebSocket
|
||||||
|
*/
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Event[]> {
|
||||||
|
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
|
||||||
524
src/modules/base/nostr/relay-hub.ts
Normal file
524
src/modules/base/nostr/relay-hub.ts
Normal file
|
|
@ -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<string, RelayConfig> = new Map()
|
||||||
|
private connectedRelays: Map<string, Relay> = new Map()
|
||||||
|
private subscriptions: Map<string, any> = 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Event[]> {
|
||||||
|
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<void> {
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
45
src/modules/base/pwa/pwa-service.ts
Normal file
45
src/modules/base/pwa/pwa-service.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// PWA service for base module
|
||||||
|
export class PWAService {
|
||||||
|
private deferredPrompt: any = null
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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()
|
||||||
336
src/modules/nostr-feed/components/NostrFeed.vue
Normal file
336
src/modules/nostr-feed/components/NostrFeed.vue
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||||
|
import { config, configUtils } from '@/lib/config'
|
||||||
|
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
relays?: string[]
|
||||||
|
feedType?: 'all' | 'announcements' | 'events' | 'general'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const relayHub = relayHubComposable
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
|
const notes = ref<any[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
const isConnected = ref(false)
|
||||||
|
|
||||||
|
// Get admin/moderator pubkeys from centralized config
|
||||||
|
const adminPubkeys = config.nostr.adminPubkeys
|
||||||
|
|
||||||
|
// Check if we have admin pubkeys configured
|
||||||
|
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
||||||
|
|
||||||
|
// Get feed title and description based on type
|
||||||
|
const feedTitle = computed(() => {
|
||||||
|
switch (props.feedType) {
|
||||||
|
case 'announcements':
|
||||||
|
return 'Community Announcements'
|
||||||
|
case 'events':
|
||||||
|
return 'Events & Calendar'
|
||||||
|
case 'general':
|
||||||
|
return 'General Discussion'
|
||||||
|
default:
|
||||||
|
return 'Community Feed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const feedDescription = computed(() => {
|
||||||
|
switch (props.feedType) {
|
||||||
|
case 'announcements':
|
||||||
|
return 'Important announcements from community administrators'
|
||||||
|
case 'events':
|
||||||
|
return 'Upcoming events and calendar updates'
|
||||||
|
case 'general':
|
||||||
|
return 'Community discussions and general posts'
|
||||||
|
default:
|
||||||
|
return 'Latest posts from the community'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if a post is from an admin
|
||||||
|
function isAdminPost(pubkey: string): boolean {
|
||||||
|
return configUtils.isAdminPubkey(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load notes from relays
|
||||||
|
async function loadNotes() {
|
||||||
|
if (!hasAdminPubkeys.value && props.feedType === 'announcements') {
|
||||||
|
notes.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
// Connect to relay hub if not already connected
|
||||||
|
if (!relayHub.isConnected.value) {
|
||||||
|
await relayHub.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected.value = relayHub.isConnected.value
|
||||||
|
|
||||||
|
if (!isConnected.value) {
|
||||||
|
throw new Error('Failed to connect to Nostr relays')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure filters based on feed type
|
||||||
|
const filters: any[] = [{
|
||||||
|
kinds: [1], // TEXT_NOTE
|
||||||
|
limit: 50
|
||||||
|
}]
|
||||||
|
|
||||||
|
// Filter by authors for announcements
|
||||||
|
if (props.feedType === 'announcements' && hasAdminPubkeys.value) {
|
||||||
|
filters[0].authors = adminPubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query events from relays
|
||||||
|
const events = await relayHub.queryEvents(filters)
|
||||||
|
|
||||||
|
// Process and filter events
|
||||||
|
let processedNotes = events.map(event => ({
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
content: event.content,
|
||||||
|
created_at: event.created_at,
|
||||||
|
tags: event.tags || [],
|
||||||
|
// Extract mentions from tags
|
||||||
|
mentions: event.tags?.filter((tag: any[]) => tag[0] === 'p').map((tag: any[]) => tag[1]) || [],
|
||||||
|
// Check if it's a reply
|
||||||
|
isReply: event.tags?.some((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply'),
|
||||||
|
replyTo: event.tags?.find((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Sort by creation time (newest first)
|
||||||
|
processedNotes.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
|
||||||
|
// For general feed, exclude admin posts
|
||||||
|
if (props.feedType === 'general' && hasAdminPubkeys.value) {
|
||||||
|
processedNotes = processedNotes.filter(note => !isAdminPost(note.pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.value = processedNotes
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorObj = err instanceof Error ? err : new Error('Failed to load notes')
|
||||||
|
error.value = errorObj
|
||||||
|
console.error('Failed to load notes:', errorObj)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the feed
|
||||||
|
async function refreshFeed() {
|
||||||
|
await loadNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to real-time updates
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
async function startRealtimeSubscription() {
|
||||||
|
if (!relayHub.isConnected.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filters: any[] = [{
|
||||||
|
kinds: [1], // TEXT_NOTE
|
||||||
|
limit: 10
|
||||||
|
}]
|
||||||
|
|
||||||
|
if (props.feedType === 'announcements' && hasAdminPubkeys.value) {
|
||||||
|
filters[0].authors = adminPubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe = relayHub.subscribe({
|
||||||
|
id: `feed-${props.feedType || 'all'}`,
|
||||||
|
filters,
|
||||||
|
onEvent: (event) => {
|
||||||
|
// Add new note to the beginning of the list
|
||||||
|
const newNote = {
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
content: event.content,
|
||||||
|
created_at: event.created_at,
|
||||||
|
tags: event.tags || [],
|
||||||
|
mentions: event.tags?.filter((tag: any[]) => tag[0] === 'p').map((tag: any[]) => tag[1]) || [],
|
||||||
|
isReply: event.tags?.some((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply'),
|
||||||
|
replyTo: event.tags?.find((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if note should be included
|
||||||
|
let shouldInclude = true
|
||||||
|
if (props.feedType === 'announcements' && !isAdminPost(event.pubkey)) {
|
||||||
|
shouldInclude = false
|
||||||
|
}
|
||||||
|
if (props.feedType === 'general' && isAdminPost(event.pubkey)) {
|
||||||
|
shouldInclude = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldInclude) {
|
||||||
|
notes.value.unshift(newNote)
|
||||||
|
// Limit array size
|
||||||
|
if (notes.value.length > 100) {
|
||||||
|
notes.value = notes.value.slice(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start real-time subscription:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup subscription
|
||||||
|
function cleanup() {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
unsubscribe = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadNotes()
|
||||||
|
await startRealtimeSubscription()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Megaphone class="h-5 w-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<CardTitle>{{ feedTitle }}</CardTitle>
|
||||||
|
<CardDescription>{{ feedDescription }}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="refreshFeed"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<!-- Connection Status -->
|
||||||
|
<div v-if="!isConnected && !isLoading" class="mb-4 p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||||
|
<div class="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
|
||||||
|
<AlertCircle class="h-4 w-4" />
|
||||||
|
<span class="text-sm">Not connected to relays</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<RefreshCw class="h-4 w-4 animate-spin" />
|
||||||
|
<span class="text-muted-foreground">Loading announcements...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="error" class="text-center py-8">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-destructive mb-4">
|
||||||
|
<AlertCircle class="h-5 w-5" />
|
||||||
|
<span>Failed to load announcements</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">{{ error.message }}</p>
|
||||||
|
<Button @click="refreshFeed" variant="outline">Try Again</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Admin Pubkeys Warning -->
|
||||||
|
<div v-else-if="!hasAdminPubkeys && props.feedType === 'announcements'" class="text-center py-8">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
||||||
|
<Megaphone class="h-5 w-5" />
|
||||||
|
<span>No admin pubkeys configured</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Community announcements will appear here once admin pubkeys are configured.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Notes -->
|
||||||
|
<div v-else-if="notes.length === 0" class="text-center py-8">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
||||||
|
<Megaphone class="h-5 w-5" />
|
||||||
|
<span>No announcements yet</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Check back later for community updates and announcements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes List -->
|
||||||
|
<ScrollArea v-else class="h-[400px] pr-4">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="note in notes"
|
||||||
|
:key="note.id"
|
||||||
|
class="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<!-- Note Header -->
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
v-if="isAdminPost(note.pubkey)"
|
||||||
|
variant="default"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="note.isReply"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</Badge>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note Content -->
|
||||||
|
<div class="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{{ note.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note Footer -->
|
||||||
|
<div v-if="note.mentions.length > 0" class="mt-2 pt-2 border-t">
|
||||||
|
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<span>Mentions:</span>
|
||||||
|
<span v-for="mention in note.mentions.slice(0, 3)" :key="mention" class="font-mono">
|
||||||
|
{{ mention.slice(0, 8) }}...
|
||||||
|
</span>
|
||||||
|
<span v-if="note.mentions.length > 3" class="text-muted-foreground">
|
||||||
|
+{{ note.mentions.length - 3 }} more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
130
src/modules/nostr-feed/composables/useFeed.ts
Normal file
130
src/modules/nostr-feed/composables/useFeed.ts
Normal file
|
|
@ -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<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
|
const posts = ref<NostrEvent[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/modules/nostr-feed/index.ts
Normal file
47
src/modules/nostr-feed/index.ts
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue