Squash merge nostrfeed-ui into main

This commit is contained in:
padreug 2025-10-21 21:31:25 +02:00
parent 5063a3e121
commit cc5e0dbef6
10 changed files with 379 additions and 258 deletions

View file

@ -31,7 +31,7 @@ export interface ContentFilter {
}
export interface FeedConfig {
feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' | 'custom'
feedType: 'all' | 'announcements' | 'rideshare' | 'custom'
maxPosts?: number
adminPubkeys?: string[]
contentFilters?: ContentFilter[]
@ -176,8 +176,8 @@ export class FeedService extends BaseService {
filter.authors = config.adminPubkeys
}
break
case 'general':
// General posts - no specific author filtering
case 'rideshare':
// Rideshare posts handled via content filters
break
case 'all':
default:
@ -188,9 +188,20 @@ export class FeedService extends BaseService {
filters.push(filter)
}
// Add reactions (kind 7) to the filters
filters.push({
kinds: [7], // Reactions
limit: 500
})
// Add ALL deletion events (kind 5) - we'll route them based on the 'k' tag
filters.push({
kinds: [5] // All deletion events (for both posts and reactions)
})
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
// Subscribe to events with deduplication
// Subscribe to all events (posts, reactions, deletions) with deduplication
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: filters,
@ -232,7 +243,21 @@ export class FeedService extends BaseService {
* Handle new event with robust deduplication
*/
private handleNewEvent(event: NostrEvent, config: FeedConfig): void {
// Skip if event already seen
// Route deletion events (kind 5) based on what's being deleted
if (event.kind === 5) {
this.handleDeletionEvent(event)
return
}
// Route reaction events (kind 7) to ReactionService
if (event.kind === 7) {
if (this.reactionService) {
this.reactionService.handleReactionEvent(event)
}
return
}
// Skip if event already seen (for posts only, kind 1)
if (this.seenEventIds.has(event.id)) {
return
}
@ -313,21 +338,62 @@ export class FeedService extends BaseService {
}, 'nostr-feed')
}
/**
* Handle deletion events (NIP-09)
* Routes deletions to appropriate service based on the 'k' tag
*/
private handleDeletionEvent(event: NostrEvent): void {
// Check the 'k' tag to determine what kind of event is being deleted
const kTag = event.tags?.find((tag: string[]) => tag[0] === 'k')
const deletedKind = kTag ? kTag[1] : null
// Route to ReactionService for reaction deletions (kind 7)
if (deletedKind === '7') {
if (this.reactionService) {
this.reactionService.handleDeletionEvent(event)
}
return
}
// Handle post deletions (kind 1) in FeedService
if (deletedKind === '1' || !deletedKind) {
// Extract event IDs to delete from 'e' tags
const eventIdsToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'e')
.map((tag: string[]) => tag[1]) || []
if (eventIdsToDelete.length === 0) {
return
}
// Remove deleted posts from the feed
this._posts.value = this._posts.value.filter(post => {
// Only delete if the deletion request comes from the same author (NIP-09 validation)
if (eventIdsToDelete.includes(post.id) && post.pubkey === event.pubkey) {
// Also remove from seen events so it won't be re-added
this.seenEventIds.delete(post.id)
return false
}
return true
})
}
}
/**
* Check if event should be included in feed
*/
private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean {
// Never include reactions (kind 7) or deletions (kind 5) in the main feed
// These should only be processed by the ReactionService
if (event.kind === 7 || event.kind === 5) {
// Never include reactions (kind 7) in the main feed
// Reactions should only be processed by the ReactionService
if (event.kind === 7) {
return false
}
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) {
// For custom content filters or specific feed types with filters, check if event matches any active filter
if ((config.feedType === 'custom' || config.feedType === 'rideshare') && 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)
@ -347,26 +413,34 @@ export class FeedService extends BaseService {
if (isAdminPost) return false
}
// Apply keyword filtering if specified
if (filter.keywords && filter.keywords.length > 0) {
const content = event.content.toLowerCase()
const hasMatchingKeyword = filter.keywords.some(keyword =>
content.includes(keyword.toLowerCase())
)
if (!hasMatchingKeyword) {
console.log('FeedService: No matching keywords found')
return false
}
}
// Apply keyword and tag filtering (OR logic when both are specified)
const hasKeywordFilter = filter.keywords && filter.keywords.length > 0
const hasTagFilter = filter.tags && filter.tags.length > 0
// Apply tag filtering if specified (check if event has any matching tags)
if (filter.tags && filter.tags.length > 0) {
const eventTags = event.tags?.filter(tag => tag[0] === 't').map(tag => tag[1]) || []
const hasMatchingTag = filter.tags.some(filterTag =>
eventTags.includes(filterTag)
)
if (!hasMatchingTag) {
console.log('FeedService: No matching tags found')
if (hasKeywordFilter || hasTagFilter) {
let keywordMatch = false
let tagMatch = false
// Check keywords
if (hasKeywordFilter) {
const content = event.content.toLowerCase()
keywordMatch = filter.keywords!.some(keyword =>
content.includes(keyword.toLowerCase())
)
}
// Check tags
if (hasTagFilter) {
const eventTags = event.tags?.filter(tag => tag[0] === 't').map(tag => tag[1]) || []
tagMatch = filter.tags!.some(filterTag =>
eventTags.includes(filterTag)
)
}
// Must match at least one: keywords OR tags
const hasMatch = (hasKeywordFilter && keywordMatch) || (hasTagFilter && tagMatch)
if (!hasMatch) {
console.log('FeedService: No matching keywords or tags found')
return false
}
}
@ -378,18 +452,14 @@ export class FeedService extends BaseService {
return result
}
// Legacy feed type handling
// 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 'rideshare':
// Rideshare filtering handled via content filters above
// If we reach here, contentFilters weren't provided - show nothing
return false
case 'all':
default:
return true

View file

@ -41,9 +41,6 @@ export class ReactionService extends BaseService {
private currentSubscription: string | null = null
private currentUnsubscribe: (() => void) | null = null
// Track deletion subscription separately
private deletionUnsubscribe: (() => void) | null = null
// Track which events we're monitoring
private monitoredEvents = new Set<string>()
@ -60,50 +57,8 @@ export class ReactionService extends BaseService {
throw new Error('RelayHub service not available')
}
// Start monitoring deletion events globally
await this.startDeletionMonitoring()
console.log('ReactionService: Initialization complete')
}
/**
* Start monitoring deletion events globally
*/
private async startDeletionMonitoring(): Promise<void> {
try {
if (!this.relayHub?.isConnected) {
await this.relayHub?.connect()
}
const subscriptionId = `reaction-deletions-${Date.now()}`
// Subscribe to ALL deletion events for reactions
const filter = {
kinds: [5], // Deletion requests
'#k': ['7'], // Only for reaction events
since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours
limit: 1000
}
console.log('ReactionService: Starting global deletion monitoring')
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: [filter],
onEvent: (event: NostrEvent) => {
this.handleDeletionEvent(event)
},
onEose: () => {
console.log('ReactionService: Initial deletion events loaded')
}
})
// Store subscription ID if needed for tracking
this.deletionUnsubscribe = unsubscribe
} catch (error) {
console.error('Failed to start deletion monitoring:', error)
}
// Deletion monitoring is now handled by FeedService's consolidated subscription
console.log('ReactionService: Initialization complete (deletion monitoring handled by FeedService)')
}
/**
@ -146,34 +101,24 @@ export class ReactionService extends BaseService {
const subscriptionId = `reactions-${Date.now()}`
// Subscribe to reactions (kind 7) and deletions (kind 5) for these events
// Subscribe to reactions (kind 7) for these events
// Deletions (kind 5) are now handled by FeedService's consolidated subscription
const filters = [
{
kinds: [7], // Reactions
'#e': newEventIds, // Events being reacted to
limit: 1000
},
{
kinds: [5], // Deletion requests for ALL users
'#k': ['7'], // Only deletions of reaction events (kind 7)
limit: 500
}
]
console.log('ReactionService: Creating reaction subscription', filters)
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: filters,
onEvent: (event: NostrEvent) => {
if (event.kind === 7) {
this.handleReactionEvent(event)
} else if (event.kind === 5) {
this.handleDeletionEvent(event)
}
this.handleReactionEvent(event)
},
onEose: () => {
console.log(`Reaction subscription ${subscriptionId} complete`)
console.log(`ReactionService: Subscription ${subscriptionId} ready`)
}
})
@ -190,8 +135,9 @@ export class ReactionService extends BaseService {
/**
* Handle incoming reaction event
* Made public so FeedService can route kind 7 events to this service
*/
private handleReactionEvent(event: NostrEvent): void {
public handleReactionEvent(event: NostrEvent): void {
try {
// Find the event being reacted to
const eTag = event.tags.find(tag => tag[0] === 'e')
@ -235,7 +181,6 @@ export class ReactionService extends BaseService {
if (previousReactionIndex >= 0) {
// Replace the old reaction with the new one
console.log(`ReactionService: Replacing previous reaction from ${reaction.pubkey.slice(0, 8)}...`)
eventReactions.reactions[previousReactionIndex] = reaction
} else {
// Add as new reaction
@ -245,17 +190,16 @@ export class ReactionService extends BaseService {
// Recalculate counts and user state
this.recalculateEventReactions(eventId)
console.log(`ReactionService: Added/updated reaction ${content} to event ${eventId.slice(0, 8)}...`)
} catch (error) {
console.error('Failed to handle reaction event:', error)
}
}
/**
* Handle deletion event
* Handle deletion event (called by FeedService when a kind 5 event with k=7 is received)
* Made public so FeedService can route deletion events to this service
*/
private handleDeletionEvent(event: NostrEvent): void {
public handleDeletionEvent(event: NostrEvent): void {
try {
// Process each deleted event
const eTags = event.tags.filter(tag => tag[0] === 'e')
@ -281,9 +225,6 @@ export class ReactionService extends BaseService {
eventReactions.reactions.splice(reactionIndex, 1)
// Recalculate counts for this event
this.recalculateEventReactions(eventId)
console.log(`ReactionService: Removed deleted reaction ${deletedEventId.slice(0, 8)}... from ${deletionAuthor.slice(0, 8)}...`)
} else {
console.log(`ReactionService: Ignoring deletion request from ${deletionAuthor.slice(0, 8)}... for reaction by ${reaction.pubkey.slice(0, 8)}...`)
}
}
}
@ -393,18 +334,12 @@ export class ReactionService extends BaseService {
created_at: Math.floor(Date.now() / 1000)
}
console.log('ReactionService: Creating like reaction:', eventTemplate)
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
console.log('ReactionService: Publishing like reaction:', signedEvent)
// Publish the reaction
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`ReactionService: Like published to ${result.success}/${result.total} relays`)
await this.relayHub.publishEvent(signedEvent)
// Optimistically update local state
this.handleReactionEvent(signedEvent)
@ -438,6 +373,7 @@ export class ReactionService extends BaseService {
// Get the user's reaction ID to delete
const eventReactions = this.getEventReactions(eventId)
if (!eventReactions.userHasLiked || !eventReactions.userReactionId) {
throw new Error('No reaction to remove')
}
@ -456,14 +392,10 @@ export class ReactionService extends BaseService {
created_at: Math.floor(Date.now() / 1000)
}
console.log('ReactionService: Creating deletion event for reaction:', eventReactions.userReactionId)
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
console.log('ReactionService: Publishing deletion event:', signedEvent)
// Publish the deletion
const result = await this.relayHub.publishEvent(signedEvent)
@ -527,9 +459,7 @@ export class ReactionService extends BaseService {
if (this.currentUnsubscribe) {
this.currentUnsubscribe()
}
if (this.deletionUnsubscribe) {
this.deletionUnsubscribe()
}
// deletionUnsubscribe is no longer used - deletions handled by FeedService
this._eventReactions.clear()
this.monitoredEvents.clear()
this.deletedReactions.clear()