diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 32221f5..da71624 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -136,6 +136,7 @@ export const SERVICE_TOKENS = { FEED_SERVICE: Symbol('feedService'), PROFILE_SERVICE: Symbol('profileService'), REACTION_SERVICE: Symbol('reactionService'), + SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'), // Nostr metadata services NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'), diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index 64790ef..6031a0e 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -13,9 +13,12 @@ import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next' import { useFeed } from '../composables/useFeed' import { useProfiles } from '../composables/useProfiles' import { useReactions } from '../composables/useReactions' +import { useScheduledEvents } from '../composables/useScheduledEvents' import ThreadedPost from './ThreadedPost.vue' +import ScheduledEventCard from './ScheduledEventCard.vue' import appConfig from '@/app.config' import type { ContentFilter, FeedPost } from '../services/FeedService' +import type { ScheduledEvent } from '../services/ScheduledEventService' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { AuthService } from '@/modules/base/auth/auth-service' import type { RelayHub } from '@/modules/base/nostr/relay-hub' @@ -95,6 +98,12 @@ const { getDisplayName, fetchProfiles } = useProfiles() // Use reactions service for likes/hearts const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() +// Use scheduled events service +const { getTodaysEvents, getCompletion, toggleComplete } = useScheduledEvents() + +// Get today's scheduled events (reactive) +const todaysScheduledEvents = computed(() => getTodaysEvents()) + // Watch for new posts and fetch their profiles and reactions watch(notes, async (newNotes) => { if (newNotes.length > 0) { @@ -158,6 +167,15 @@ async function onToggleLike(note: FeedPost) { } } +// Handle scheduled event completion toggle +async function onToggleComplete(event: ScheduledEvent) { + try { + await toggleComplete(event) + } catch (error) { + console.error('Failed to toggle event completion:', error) + } +} + // Handle collapse toggle with cascading behavior function onToggleCollapse(postId: string) { const newCollapsed = new Set(collapsedPosts.value) @@ -369,7 +387,29 @@ function cancelDelete() {
-
+ +
+

+ 📅 Today's Events +

+
+ +
+
+ + +
+

+ 💬 Posts +

+import { computed } from 'vue' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' +import { Calendar, MapPin, Clock } from 'lucide-vue-next' +import type { ScheduledEvent, EventCompletion } from '../services/ScheduledEventService' + +interface Props { + event: ScheduledEvent + completion?: EventCompletion + getDisplayName: (pubkey: string) => string + adminPubkeys?: string[] +} + +interface Emits { + (e: 'toggle-complete', event: ScheduledEvent): void +} + +const props = withDefaults(defineProps(), { + adminPubkeys: () => [] +}) + +const emit = defineEmits() + +// Check if this is an admin event +const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubkey)) + +// Check if event is completed +const isCompleted = computed(() => props.completion?.completed || false) + +// Format the date/time +const formattedDate = computed(() => { + try { + const date = new Date(props.event.start) + + // Check if it's a datetime or just date + if (props.event.start.includes('T')) { + // Full datetime - show date and time + return date.toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) + } else { + // Just date + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + }) + } + } catch (error) { + return props.event.start + } +}) + +// Format the time range if end time exists +const formattedTimeRange = computed(() => { + if (!props.event.end || !props.event.start.includes('T')) return null + + try { + const start = new Date(props.event.start) + const end = new Date(props.event.end) + + const startTime = start.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit' + }) + const endTime = end.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit' + }) + + return `${startTime} - ${endTime}` + } catch (error) { + return null + } +}) + +// Handle checkbox toggle +function handleToggleComplete() { + emit('toggle-complete', props.event) +} + + + diff --git a/src/modules/nostr-feed/composables/useScheduledEvents.ts b/src/modules/nostr-feed/composables/useScheduledEvents.ts new file mode 100644 index 0000000..54a9db8 --- /dev/null +++ b/src/modules/nostr-feed/composables/useScheduledEvents.ts @@ -0,0 +1,143 @@ +import { computed } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { ScheduledEventService, ScheduledEvent, EventCompletion } from '../services/ScheduledEventService' +import { useToast } from '@/core/composables/useToast' + +/** + * Composable for managing scheduled events in the feed + */ +export function useScheduledEvents() { + const scheduledEventService = injectService(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE) + const toast = useToast() + + /** + * Get all scheduled events + */ + const getScheduledEvents = (): ScheduledEvent[] => { + if (!scheduledEventService) return [] + return scheduledEventService.getScheduledEvents() + } + + /** + * Get events for a specific date (YYYY-MM-DD) + */ + const getEventsForDate = (date: string): ScheduledEvent[] => { + if (!scheduledEventService) return [] + return scheduledEventService.getEventsForDate(date) + } + + /** + * Get today's scheduled events + */ + const getTodaysEvents = (): ScheduledEvent[] => { + if (!scheduledEventService) return [] + return scheduledEventService.getTodaysEvents() + } + + /** + * Get completion status for an event + */ + const getCompletion = (eventAddress: string): EventCompletion | undefined => { + if (!scheduledEventService) return undefined + return scheduledEventService.getCompletion(eventAddress) + } + + /** + * Check if an event is completed + */ + const isCompleted = (eventAddress: string): boolean => { + if (!scheduledEventService) return false + return scheduledEventService.isCompleted(eventAddress) + } + + /** + * Toggle completion status of an event + */ + const toggleComplete = async (event: ScheduledEvent, notes: string = ''): Promise => { + if (!scheduledEventService) { + toast.error('Scheduled event service not available') + return + } + + try { + const eventAddress = `31922:${event.pubkey}:${event.dTag}` + const currentlyCompleted = scheduledEventService.isCompleted(eventAddress) + + if (currentlyCompleted) { + await scheduledEventService.uncompleteEvent(event) + toast.success('Event marked as incomplete') + } else { + await scheduledEventService.completeEvent(event, notes) + toast.success('Event completed!') + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to toggle completion' + + if (message.includes('authenticated')) { + toast.error('Please sign in to complete events') + } else if (message.includes('Not connected')) { + toast.error('Not connected to relays') + } else { + toast.error(message) + } + + console.error('Failed to toggle completion:', error) + } + } + + /** + * Complete an event with optional notes + */ + const completeEvent = async (event: ScheduledEvent, notes: string = ''): Promise => { + if (!scheduledEventService) { + toast.error('Scheduled event service not available') + return + } + + try { + await scheduledEventService.completeEvent(event, notes) + toast.success('Event completed!') + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to complete event' + toast.error(message) + console.error('Failed to complete event:', error) + } + } + + /** + * Get loading state + */ + const isLoading = computed(() => { + return scheduledEventService?.isLoading ?? false + }) + + /** + * Get all scheduled events (reactive) + */ + const allScheduledEvents = computed(() => { + return scheduledEventService?.scheduledEvents ?? new Map() + }) + + /** + * Get all completions (reactive) + */ + const allCompletions = computed(() => { + return scheduledEventService?.completions ?? new Map() + }) + + return { + // Methods + getScheduledEvents, + getEventsForDate, + getTodaysEvents, + getCompletion, + isCompleted, + toggleComplete, + completeEvent, + + // State + isLoading, + allScheduledEvents, + allCompletions + } +} diff --git a/src/modules/nostr-feed/config/content-filters.ts b/src/modules/nostr-feed/config/content-filters.ts index 46808ee..903131d 100644 --- a/src/modules/nostr-feed/config/content-filters.ts +++ b/src/modules/nostr-feed/config/content-filters.ts @@ -88,6 +88,14 @@ export const CONTENT_FILTERS: Record = { description: 'Rideshare requests, offers, and coordination', tags: ['rideshare', 'carpool'], // NIP-12 tags keywords: ['rideshare', 'ride share', 'carpool', '🚗', '🚶'] + }, + + // Scheduled events (NIP-52) + scheduledEvents: { + id: 'scheduled-events', + label: 'Scheduled Events', + kinds: [31922], // NIP-52: Calendar Events + description: 'Calendar-based tasks and scheduled activities' } } @@ -110,6 +118,11 @@ export const FILTER_PRESETS: Record = { // Rideshare only rideshare: [ CONTENT_FILTERS.rideshare + ], + + // Scheduled events only + scheduledEvents: [ + CONTENT_FILTERS.scheduledEvents ] } diff --git a/src/modules/nostr-feed/index.ts b/src/modules/nostr-feed/index.ts index 9967476..e3a7191 100644 --- a/src/modules/nostr-feed/index.ts +++ b/src/modules/nostr-feed/index.ts @@ -6,6 +6,7 @@ import { useFeed } from './composables/useFeed' import { FeedService } from './services/FeedService' import { ProfileService } from './services/ProfileService' import { ReactionService } from './services/ReactionService' +import { ScheduledEventService } from './services/ScheduledEventService' /** * Nostr Feed Module Plugin @@ -23,10 +24,12 @@ export const nostrFeedModule: ModulePlugin = { const feedService = new FeedService() const profileService = new ProfileService() const reactionService = new ReactionService() + const scheduledEventService = new ScheduledEventService() container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService) container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService) container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService) + container.provide(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE, scheduledEventService) console.log('nostr-feed module: Services registered in DI container') // Initialize services @@ -43,6 +46,10 @@ export const nostrFeedModule: ModulePlugin = { reactionService.initialize({ waitForDependencies: true, maxRetries: 3 + }), + scheduledEventService.initialize({ + waitForDependencies: true, + maxRetries: 3 }) ]) console.log('nostr-feed module: Services initialized') diff --git a/src/modules/nostr-feed/services/FeedService.ts b/src/modules/nostr-feed/services/FeedService.ts index 377186b..667bc82 100644 --- a/src/modules/nostr-feed/services/FeedService.ts +++ b/src/modules/nostr-feed/services/FeedService.ts @@ -47,6 +47,7 @@ export class FeedService extends BaseService { 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() @@ -72,10 +73,12 @@ export class FeedService extends BaseService { 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') @@ -199,6 +202,12 @@ export class FeedService extends BaseService { 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 @@ -257,6 +266,22 @@ export class FeedService extends BaseService { 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) { + if (this.scheduledEventService) { + this.scheduledEventService.handleCompletionEvent(event) + } + return + } + // Skip if event already seen (for posts only, kind 1) if (this.seenEventIds.has(event.id)) { return diff --git a/src/modules/nostr-feed/services/ScheduledEventService.ts b/src/modules/nostr-feed/services/ScheduledEventService.ts new file mode 100644 index 0000000..136e29a --- /dev/null +++ b/src/modules/nostr-feed/services/ScheduledEventService.ts @@ -0,0 +1,335 @@ +import { ref, reactive } from 'vue' +import { BaseService } from '@/core/base/BaseService' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import { finalizeEvent, type EventTemplate } from 'nostr-tools' +import type { Event as NostrEvent } from 'nostr-tools' + +export interface ScheduledEvent { + id: string + pubkey: string + created_at: number + dTag: string // Unique identifier from 'd' tag + title: string + start: string // ISO date string (YYYY-MM-DD or ISO datetime) + end?: string + description?: string + location?: string + status: string + content: string + tags: string[][] +} + +export interface EventCompletion { + id: string + eventAddress: string // "31922:pubkey:d-tag" + pubkey: string // Who completed it + created_at: number + completed: boolean + completedAt?: number + notes: string +} + +export class ScheduledEventService extends BaseService { + protected readonly metadata = { + name: 'ScheduledEventService', + version: '1.0.0', + dependencies: [] + } + + protected relayHub: any = null + protected authService: any = null + + // Scheduled events state - indexed by event address + private _scheduledEvents = reactive(new Map()) + private _completions = reactive(new Map()) + private _isLoading = ref(false) + + protected async onInitialize(): Promise { + console.log('ScheduledEventService: Starting initialization...') + + this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) + this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) + + if (!this.relayHub) { + throw new Error('RelayHub service not available') + } + + console.log('ScheduledEventService: Initialization complete') + } + + /** + * Handle incoming scheduled event (kind 31922) + * Made public so FeedService can route kind 31922 events to this service + */ + public handleScheduledEvent(event: NostrEvent): void { + try { + // Extract event data from tags + const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] + if (!dTag) { + console.warn('Scheduled event missing d tag:', event.id) + return + } + + const title = event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled Event' + const start = event.tags.find(tag => tag[0] === 'start')?.[1] + const end = event.tags.find(tag => tag[0] === 'end')?.[1] + const description = event.tags.find(tag => tag[0] === 'description')?.[1] + const location = event.tags.find(tag => tag[0] === 'location')?.[1] + const status = event.tags.find(tag => tag[0] === 'status')?.[1] || 'pending' + + if (!start) { + console.warn('Scheduled event missing start date:', event.id) + return + } + + // Create event address: "kind:pubkey:d-tag" + const eventAddress = `31922:${event.pubkey}:${dTag}` + + const scheduledEvent: ScheduledEvent = { + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + dTag, + title, + start, + end, + description, + location, + status, + content: event.content, + tags: event.tags + } + + // Store or update the event (replaceable by d-tag) + this._scheduledEvents.set(eventAddress, scheduledEvent) + + } catch (error) { + console.error('Failed to handle scheduled event:', error) + } + } + + /** + * Handle RSVP/completion event (kind 31925) + * Made public so FeedService can route kind 31925 events to this service + */ + public handleCompletionEvent(event: NostrEvent): void { + try { + // Find the event being responded to + const aTag = event.tags.find(tag => tag[0] === 'a')?.[1] + if (!aTag) { + console.warn('Completion event missing a tag:', event.id) + return + } + + const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true' + const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1] + const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined + + const completion: EventCompletion = { + id: event.id, + eventAddress: aTag, + pubkey: event.pubkey, + created_at: event.created_at, + completed, + completedAt, + notes: event.content + } + + // Store completion (most recent one wins) + const existing = this._completions.get(aTag) + if (!existing || event.created_at > existing.created_at) { + this._completions.set(aTag, completion) + } + + } catch (error) { + console.error('Failed to handle completion event:', error) + } + } + + /** + * Get all scheduled events + */ + getScheduledEvents(): ScheduledEvent[] { + return Array.from(this._scheduledEvents.values()) + } + + /** + * Get events scheduled for a specific date (YYYY-MM-DD) + */ + getEventsForDate(date: string): ScheduledEvent[] { + return this.getScheduledEvents().filter(event => { + // Simple date matching (start date) + // For ISO datetime strings, extract just the date part + const eventDate = event.start.split('T')[0] + return eventDate === date + }) + } + + /** + * Get events for today + */ + getTodaysEvents(): ScheduledEvent[] { + const today = new Date().toISOString().split('T')[0] + return this.getEventsForDate(today) + } + + /** + * Get completion status for an event + */ + getCompletion(eventAddress: string): EventCompletion | undefined { + return this._completions.get(eventAddress) + } + + /** + * Check if an event is completed + */ + isCompleted(eventAddress: string): boolean { + const completion = this.getCompletion(eventAddress) + return completion?.completed || false + } + + /** + * Mark an event as complete + */ + async completeEvent(event: ScheduledEvent, notes: string = ''): Promise { + if (!this.authService?.isAuthenticated?.value) { + throw new Error('Must be authenticated to complete events') + } + + if (!this.relayHub?.isConnected) { + throw new Error('Not connected to relays') + } + + const userPrivkey = this.authService.user.value?.prvkey + if (!userPrivkey) { + throw new Error('User private key not available') + } + + try { + this._isLoading.value = true + + const eventAddress = `31922:${event.pubkey}:${event.dTag}` + + // Create RSVP/completion event (NIP-52) + const eventTemplate: EventTemplate = { + kind: 31925, // Calendar Event RSVP + content: notes, + tags: [ + ['a', eventAddress], + ['status', 'accepted'], + ['completed', 'true'], + ['completed_at', Math.floor(Date.now() / 1000).toString()] + ], + created_at: Math.floor(Date.now() / 1000) + } + + // Sign the event + const privkeyBytes = this.hexToUint8Array(userPrivkey) + const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) + + // Publish the completion + await this.relayHub.publishEvent(signedEvent) + + // Optimistically update local state + this.handleCompletionEvent(signedEvent) + + } catch (error) { + console.error('Failed to complete event:', error) + throw error + } finally { + this._isLoading.value = false + } + } + + /** + * Uncomplete an event (publish new RSVP with completed=false) + */ + async uncompleteEvent(event: ScheduledEvent): Promise { + if (!this.authService?.isAuthenticated?.value) { + throw new Error('Must be authenticated to uncomplete events') + } + + if (!this.relayHub?.isConnected) { + throw new Error('Not connected to relays') + } + + const userPrivkey = this.authService.user.value?.prvkey + if (!userPrivkey) { + throw new Error('User private key not available') + } + + try { + this._isLoading.value = true + + const eventAddress = `31922:${event.pubkey}:${event.dTag}` + + // Create RSVP event with completed=false + const eventTemplate: EventTemplate = { + kind: 31925, + content: '', + tags: [ + ['a', eventAddress], + ['status', 'tentative'], + ['completed', 'false'] + ], + created_at: Math.floor(Date.now() / 1000) + } + + // Sign the event + const privkeyBytes = this.hexToUint8Array(userPrivkey) + const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) + + // Publish the uncomplete + await this.relayHub.publishEvent(signedEvent) + + // Optimistically update local state + this.handleCompletionEvent(signedEvent) + + } catch (error) { + console.error('Failed to uncomplete event:', error) + throw error + } finally { + this._isLoading.value = false + } + } + + /** + * Helper function to convert hex string to Uint8Array + */ + private hexToUint8Array(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + } + return bytes + } + + /** + * Get all scheduled events + */ + get scheduledEvents(): Map { + return this._scheduledEvents + } + + /** + * Get all completions + */ + get completions(): Map { + return this._completions + } + + /** + * Check if currently loading + */ + get isLoading(): boolean { + return this._isLoading.value + } + + /** + * Cleanup + */ + protected async onDestroy(): Promise { + this._scheduledEvents.clear() + this._completions.clear() + } +}