web-app/src/modules/nostr-feed/services/FeedService.ts
padreug 4050b33d0e Enables marking scheduled events as complete
Implements a feature to mark scheduled events as complete, replacing the checkbox with a button for improved UX.

This commit enhances the Scheduled Events functionality by allowing users to mark events as complete. It also includes:

- Replaces the checkbox with a "Mark Complete" button for better usability.
- Adds logging for debugging purposes during event completion toggling.
- Routes completion events (kind 31925) to the ScheduledEventService.
- Optimistically updates the local state after publishing completion events.
2025-11-06 11:30:42 +01:00

672 lines
No EOL
22 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 // Direct parent ID
rootId?: string // Thread root ID
replies?: FeedPost[] // Child replies
depth?: number // Depth in reply tree (0 for root posts)
}
export interface ContentFilter {
id: string
label: string
kinds: number[]
description: string
requiresAuth?: boolean
filterByAuthor?: 'admin' | 'exclude-admin' | 'none'
tags?: string[] // NIP-12 tags to filter by
keywords?: string[] // Content keywords to search for
}
export interface FeedConfig {
feedType: 'all' | 'announcements' | 'rideshare' | '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
protected reactionService: any = null
protected scheduledEventService: 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)
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
this.scheduledEventService = injectService(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
console.log('FeedService: RelayHub injected:', !!this.relayHub)
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
console.log('FeedService: ReactionService injected:', !!this.reactionService)
console.log('FeedService: ScheduledEventService injected:', !!this.scheduledEventService)
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
}
// Apply tag filtering if specified (NIP-12)
if (contentFilter.tags && contentFilter.tags.length > 0) {
filter['#t'] = contentFilter.tags
}
filters.push(filter)
}
} else {
// Handle default feed types (all, announcements, general, etc.)
const filter: Filter = {
kinds: [1], // Text notes
limit: config.maxPosts || 50
}
// Apply feed-specific filtering
switch (config.feedType) {
case 'announcements':
if (config.adminPubkeys?.length) {
filter.authors = config.adminPubkeys
}
break
case 'rideshare':
// Rideshare posts handled via content filters
break
case 'all':
default:
// All posts - no specific filtering
break
}
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)
})
// Add scheduled events (kind 31922) and RSVPs (kind 31925)
filters.push({
kinds: [31922, 31925], // Calendar events and RSVPs
limit: 200
})
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
// Subscribe to all events (posts, reactions, deletions) 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 {
// 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
}
// Route scheduled events (kind 31922) to ScheduledEventService
if (event.kind === 31922) {
if (this.scheduledEventService) {
this.scheduledEventService.handleScheduledEvent(event)
}
return
}
// Route RSVP/completion events (kind 31925) to ScheduledEventService
if (event.kind === 31925) {
console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleCompletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return
}
// Skip if event already seen (for posts only, kind 1)
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
}
// Extract reply information according to NIP-10
let rootId: string | undefined
let replyTo: string | undefined
let isReply = false
// Look for marked e-tags first (preferred method)
const markedRootTag = event.tags?.find((tag: string[]) => tag[0] === 'e' && tag[3] === 'root')
const markedReplyTag = event.tags?.find((tag: string[]) => tag[0] === 'e' && tag[3] === 'reply')
if (markedRootTag || markedReplyTag) {
// Using marked tags (NIP-10 preferred method)
rootId = markedRootTag?.[1]
replyTo = markedReplyTag?.[1] || markedRootTag?.[1] // Direct reply to root if no reply tag
isReply = true
} else {
// Fallback to positional tags (deprecated but still in use)
const eTags = event.tags?.filter((tag: string[]) => tag[0] === 'e') || []
if (eTags.length === 1) {
// Single e-tag means this is a direct reply
replyTo = eTags[0][1]
rootId = eTags[0][1]
isReply = true
} else if (eTags.length >= 2) {
// Multiple e-tags: first is root, last is direct reply
rootId = eTags[0][1]
replyTo = eTags[eTags.length - 1][1]
isReply = true
}
}
// 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,
replyTo,
rootId,
replies: [],
depth: 0
}
// 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')
}
/**
* 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) 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 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)
// 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)
if (!isAdminPost) return false
} else if (filter.filterByAuthor === 'exclude-admin') {
console.log('FeedService: Exclude admin filter, isAdminPost:', isAdminPost)
if (isAdminPost) 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
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
}
}
console.log('FeedService: Filter passed all checks')
return true
})
console.log('FeedService: Custom filter result:', result)
return result
}
// Feed type handling
switch (config.feedType) {
case 'announcements':
return isAdminPost
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
}
}
/**
* 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)
}
/**
* Build threaded reply structure from flat posts
*/
buildThreadedPosts(posts: FeedPost[]): FeedPost[] {
console.log('FeedService.buildThreadedPosts: Input posts count:', posts.length)
// Create a map for quick lookup
const postMap = new Map<string, FeedPost>()
posts.forEach(post => {
postMap.set(post.id, { ...post, replies: [], depth: 0 })
})
// Build the tree structure
const rootPosts: FeedPost[] = []
posts.forEach(post => {
const currentPost = postMap.get(post.id)!
if (post.isReply && post.replyTo) {
// This is a reply, attach it to its parent if parent exists
const parentPost = postMap.get(post.replyTo)
if (parentPost) {
currentPost.depth = (parentPost.depth || 0) + 1
parentPost.replies = parentPost.replies || []
parentPost.replies.push(currentPost)
} else {
// Parent not found, treat as root post
rootPosts.push(currentPost)
}
} else {
// This is a root post
rootPosts.push(currentPost)
}
})
// Sort posts using like count and timestamp
this.sortPostsByLikesAndTime(rootPosts)
// Sort all reply threads recursively
rootPosts.forEach(post => this.sortRepliesRecursively(post))
return rootPosts
}
/**
* Sort posts by likes first, then by time (newest first)
*/
private sortPostsByLikesAndTime(posts: FeedPost[]): void {
posts.sort((a, b) => {
// Get like counts from reaction service if available
const aLikes = this.getLikeCount(a.id)
const bLikes = this.getLikeCount(b.id)
// Sort by likes first (descending)
if (aLikes !== bLikes) {
return bLikes - aLikes
}
// If likes are equal, sort by time (newest first)
return b.created_at - a.created_at
})
}
/**
* Recursively sort replies within each thread
*/
private sortRepliesRecursively(post: FeedPost): void {
if (post.replies && post.replies.length > 0) {
// Sort replies by likes first, then time
this.sortPostsByLikesAndTime(post.replies)
// Recursively sort nested replies
post.replies.forEach(reply => this.sortRepliesRecursively(reply))
}
}
/**
* Get like count for a post from ReactionService
*/
private getLikeCount(postId: string): number {
try {
if (this.reactionService && typeof this.reactionService.getEventReactions === 'function') {
const reactions = this.reactionService.getEventReactions(postId)
return reactions?.likes || 0
}
} catch (error) {
// Silently fail if reaction service is not available
console.debug('FeedService: Could not get like count for post', postId, error)
}
return 0
}
/**
* 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 = []
}
}