web-app/src/modules/nostr-feed/services/FeedService.ts
padreug f05398fa9e Enhance FeedService filtering logic
- Added a watch function in useFeed to log updates to filtered posts,
aiding in debugging.
- Updated content filters to include text notes in announcements and
marketplace presets, ensuring broader content visibility.
- Enhanced FeedService to log detailed information during custom
filtering, improving traceability of filtering logic.
- Modified Home.vue to always use custom filters for better consistency
in feed display.

These changes improve the reactivity, flexibility, and clarity of the
feed system, enhancing the overall user experience.
2025-09-23 23:59:37 +02:00

400 lines
No EOL
13 KiB
TypeScript

import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { eventBus } from '@/core/event-bus'
import type { Event as NostrEvent, Filter } from 'nostr-tools'
export interface FeedPost {
id: string
pubkey: string
content: string
created_at: number
kind: number
tags: string[][]
mentions: string[]
isReply: boolean
replyTo?: string
}
export interface ContentFilter {
id: string
label: string
kinds: number[]
description: string
requiresAuth?: boolean
filterByAuthor?: 'admin' | 'exclude-admin' | 'none'
}
export interface FeedConfig {
feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' | 'custom'
maxPosts?: number
adminPubkeys?: string[]
contentFilters?: ContentFilter[]
}
export class FeedService extends BaseService {
protected readonly metadata = {
name: 'FeedService',
version: '1.0.0',
dependencies: []
}
protected relayHub: any = null
protected visibilityService: any = null
// Event ID tracking for deduplication
private seenEventIds = new Set<string>()
// Feed state
private _posts = ref<FeedPost[]>([])
private _isLoading = ref(false)
private _error = ref<string | null>(null)
// Current subscription state
private currentSubscription: string | null = null
private currentUnsubscribe: (() => void) | null = null
private currentConfig: FeedConfig | null = null
// Public reactive state
public readonly posts = computed(() => this._posts.value)
public readonly isLoading = computed(() => this._isLoading.value)
public readonly error = computed(() => this._error.value)
protected async onInitialize(): Promise<void> {
console.log('FeedService: Starting initialization...')
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
console.log('FeedService: RelayHub injected:', !!this.relayHub)
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
// Register with visibility service for proper connection management
if (this.visibilityService) {
this.visibilityService.registerService(
'FeedService',
this.onResume.bind(this),
this.onPause.bind(this)
)
}
console.log('FeedService: Initialization complete')
}
/**
* Subscribe to feed with deduplication
*/
async subscribeFeed(config: FeedConfig): Promise<void> {
// If already subscribed with same config, don't resubscribe
if (this.currentSubscription && this.currentConfig &&
JSON.stringify(this.currentConfig) === JSON.stringify(config)) {
return
}
// Unsubscribe from previous feed if exists
await this.unsubscribeFeed()
this.currentConfig = config
this._isLoading.value = true
this._error.value = null
try {
// Check if RelayHub is connected
if (!this.relayHub) {
throw new Error('RelayHub not available')
}
if (!this.relayHub.isConnected) {
console.log('RelayHub not connected, attempting to connect...')
await this.relayHub.connect()
}
if (!this.relayHub.isConnected) {
throw new Error('Unable to connect to relays')
}
// Create subscription ID
const subscriptionId = `feed-service-${config.feedType}-${Date.now()}`
// Create filters based on feed type and content filters
const filters: Filter[] = []
if (config.feedType === 'custom' && config.contentFilters) {
// Use custom content filters
// Using custom content filters
for (const contentFilter of config.contentFilters) {
const filter: Filter = {
kinds: contentFilter.kinds,
limit: Math.floor((config.maxPosts || 50) / config.contentFilters.length)
}
// Apply author filtering if specified
if (contentFilter.filterByAuthor === 'admin') {
if (config.adminPubkeys?.length) {
filter.authors = config.adminPubkeys
// Using admin authors for filtering
} else {
// No admin pubkeys configured - include all authors for admin filters
// No admin pubkeys configured - include all authors
}
} else if (contentFilter.filterByAuthor === 'exclude-admin' && config.adminPubkeys?.length) {
// Note: Nostr doesn't support negative filters natively,
// we'll filter these out in post-processing
// Will exclude admin in post-processing
}
filters.push(filter)
}
} else {
// Use legacy feed types
const filter: Filter = {
kinds: [1], // Text notes by default
limit: config.maxPosts || 50
}
// Handle legacy feed types
if (config.feedType === 'announcements') {
if (config.adminPubkeys && config.adminPubkeys.length > 0) {
filter.authors = config.adminPubkeys
} else {
// No admin pubkeys configured - fall back to all text posts
console.log('No admin pubkeys configured for announcements feed, showing all posts')
// filter.authors remains undefined, so all authors are included
}
}
filters.push(filter)
}
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
// Subscribe to events with deduplication
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: filters,
onEvent: (event: NostrEvent) => {
this.handleNewEvent(event, config)
},
onEose: () => {
console.log(`Feed subscription ${subscriptionId} end of stored events`)
console.log('FeedService: Setting isLoading to false')
this._isLoading.value = false
console.log('FeedService: isLoading is now:', this._isLoading.value)
},
onClose: () => {
console.log(`Feed subscription ${subscriptionId} closed`)
}
})
// Store the subscription info for later cleanup
this.currentSubscription = subscriptionId
this.currentUnsubscribe = unsubscribe
// Set a timeout to stop loading if no EOSE is received
setTimeout(() => {
console.log(`Feed subscription ${subscriptionId} timeout check: isLoading=${this._isLoading.value}, currentSub=${this.currentSubscription}`)
if (this._isLoading.value && this.currentSubscription === subscriptionId) {
console.log(`Feed subscription ${subscriptionId} timeout, stopping loading`)
this._isLoading.value = false
}
}, 5000) // 5 second timeout (reduced for testing)
} catch (err) {
console.error('Failed to subscribe to feed:', err)
this._error.value = err instanceof Error ? err.message : 'Failed to subscribe to feed'
this._isLoading.value = false
}
}
/**
* Handle new event with robust deduplication
*/
private handleNewEvent(event: NostrEvent, config: FeedConfig): void {
// Skip if event already seen
if (this.seenEventIds.has(event.id)) {
return
}
// Add to seen events
this.seenEventIds.add(event.id)
// Check if event should be included based on feed type
if (!this.shouldIncludeEvent(event, config)) {
return
}
// Transform to FeedPost
const post: FeedPost = {
id: event.id,
pubkey: event.pubkey,
content: event.content,
created_at: event.created_at,
kind: event.kind,
tags: event.tags || [],
mentions: event.tags?.filter((tag: string[]) => tag[0] === 'p').map((tag: string[]) => tag[1]) || [],
isReply: event.tags?.some((tag: string[]) => tag[0] === 'e' && tag[3] === 'reply') || false,
replyTo: event.tags?.find((tag: string[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1]
}
// Add to posts (newest first)
this._posts.value = [post, ...this._posts.value]
// Limit array size and clean up seen IDs
const maxPosts = config.maxPosts || 100
if (this._posts.value.length > maxPosts) {
const removedPosts = this._posts.value.slice(maxPosts)
this._posts.value = this._posts.value.slice(0, maxPosts)
// Clean up seen IDs for removed posts
removedPosts.forEach(post => {
this.seenEventIds.delete(post.id)
})
}
// Emit event for other modules
eventBus.emit('nostr-feed:new-post', {
event,
feedType: config.feedType
}, 'nostr-feed')
}
/**
* Check if event should be included in feed
*/
private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean {
const isAdminPost = config.adminPubkeys?.includes(event.pubkey) || false
// For custom content filters, check if event matches any active filter
if (config.feedType === 'custom' && config.contentFilters) {
console.log('FeedService: Using custom filters, count:', config.contentFilters.length)
const result = config.contentFilters.some(filter => {
console.log('FeedService: Checking filter:', filter.id, 'kinds:', filter.kinds, 'filterByAuthor:', filter.filterByAuthor)
// Check if event kind matches
if (!filter.kinds.includes(event.kind)) {
console.log('FeedService: Kind mismatch, event kind:', event.kind, 'filter kinds:', filter.kinds)
return false
}
// Apply author filtering
if (filter.filterByAuthor === 'admin') {
console.log('FeedService: Admin filter, isAdminPost:', isAdminPost)
return isAdminPost
} else if (filter.filterByAuthor === 'exclude-admin') {
console.log('FeedService: Exclude admin filter, isAdminPost:', isAdminPost)
return !isAdminPost
}
console.log('FeedService: Filter passed all checks')
return true
})
console.log('FeedService: Custom filter result:', result)
return result
}
// Legacy feed type handling
switch (config.feedType) {
case 'announcements':
return isAdminPost
case 'general':
return !isAdminPost
case 'events':
// Events feed could show all posts for now, or implement event-specific filtering
return true
case 'mentions':
// TODO: Implement mention detection if needed
return true
case 'all':
default:
return true
}
}
/**
* Unsubscribe from current feed
*/
async unsubscribeFeed(): Promise<void> {
if (this.currentUnsubscribe) {
this.currentUnsubscribe()
this.currentSubscription = null
this.currentUnsubscribe = null
this.currentConfig = null
}
}
/**
* Refresh feed (clear and reload)
*/
async refreshFeed(): Promise<void> {
if (!this.currentConfig) return
// Clear existing state
this._posts.value = []
this.seenEventIds.clear()
// Resubscribe
await this.subscribeFeed(this.currentConfig)
}
/**
* Get filtered posts for specific feed type
*/
getFilteredPosts(config: FeedConfig): FeedPost[] {
console.log('FeedService: getFilteredPosts called, total posts:', this._posts.value.length, 'config:', config.feedType)
const filtered = this._posts.value
.filter(post => {
const shouldInclude = this.shouldIncludeEvent({
id: post.id,
pubkey: post.pubkey,
content: post.content,
created_at: post.created_at,
kind: post.kind,
tags: post.tags
} as NostrEvent, config)
if (!shouldInclude) {
console.log('FeedService: Post filtered out in getFilteredPosts:', post.id)
}
return shouldInclude
})
.sort((a, b) => b.created_at - a.created_at)
.slice(0, config.maxPosts || 100)
console.log('FeedService: getFilteredPosts returning', filtered.length, 'posts')
return filtered
}
/**
* Visibility service callbacks
*/
private async onResume(): Promise<void> {
console.log('FeedService: App resumed, checking connections')
// Check if we need to reconnect
if (this.currentConfig && this.relayHub) {
const isConnected = this.relayHub.isConnected.value
console.log('FeedService: RelayHub connection status:', isConnected)
if (!isConnected) {
console.log('FeedService: Reconnecting after resume')
await this.subscribeFeed(this.currentConfig)
}
}
}
private onPause(): void {
console.log('FeedService: App paused, maintaining state')
// Don't clear state, just log for debugging
}
/**
* Cleanup
*/
protected async onDestroy(): Promise<void> {
await this.unsubscribeFeed()
this.seenEventIds.clear()
this._posts.value = []
}
}