- 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.
400 lines
No EOL
13 KiB
TypeScript
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 = []
|
|
}
|
|
} |