Adds scheduled events to the feed
Implements NIP-52 scheduled events, allowing users to view and interact with calendar events. A new `ScheduledEventService` is introduced to manage fetching, storing, and completing scheduled events. A new `ScheduledEventCard` component is introduced for displaying the scheduled events.
This commit is contained in:
parent
b6d8a78cd8
commit
9b05bcc238
8 changed files with 716 additions and 1 deletions
|
|
@ -136,6 +136,7 @@ export const SERVICE_TOKENS = {
|
||||||
FEED_SERVICE: Symbol('feedService'),
|
FEED_SERVICE: Symbol('feedService'),
|
||||||
PROFILE_SERVICE: Symbol('profileService'),
|
PROFILE_SERVICE: Symbol('profileService'),
|
||||||
REACTION_SERVICE: Symbol('reactionService'),
|
REACTION_SERVICE: Symbol('reactionService'),
|
||||||
|
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
|
||||||
|
|
||||||
// Nostr metadata services
|
// Nostr metadata services
|
||||||
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,12 @@ import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||||
import { useFeed } from '../composables/useFeed'
|
import { useFeed } from '../composables/useFeed'
|
||||||
import { useProfiles } from '../composables/useProfiles'
|
import { useProfiles } from '../composables/useProfiles'
|
||||||
import { useReactions } from '../composables/useReactions'
|
import { useReactions } from '../composables/useReactions'
|
||||||
|
import { useScheduledEvents } from '../composables/useScheduledEvents'
|
||||||
import ThreadedPost from './ThreadedPost.vue'
|
import ThreadedPost from './ThreadedPost.vue'
|
||||||
|
import ScheduledEventCard from './ScheduledEventCard.vue'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
||||||
|
import type { ScheduledEvent } from '../services/ScheduledEventService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
import type { AuthService } from '@/modules/base/auth/auth-service'
|
||||||
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
||||||
|
|
@ -95,6 +98,12 @@ const { getDisplayName, fetchProfiles } = useProfiles()
|
||||||
// Use reactions service for likes/hearts
|
// Use reactions service for likes/hearts
|
||||||
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
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 for new posts and fetch their profiles and reactions
|
||||||
watch(notes, async (newNotes) => {
|
watch(notes, async (newNotes) => {
|
||||||
if (newNotes.length > 0) {
|
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
|
// Handle collapse toggle with cascading behavior
|
||||||
function onToggleCollapse(postId: string) {
|
function onToggleCollapse(postId: string) {
|
||||||
const newCollapsed = new Set(collapsedPosts.value)
|
const newCollapsed = new Set(collapsedPosts.value)
|
||||||
|
|
@ -369,7 +387,29 @@ function cancelDelete() {
|
||||||
|
|
||||||
<!-- Posts List - Natural flow without internal scrolling -->
|
<!-- Posts List - Natural flow without internal scrolling -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="md:space-y-4 md:py-4">
|
<!-- Today's Scheduled Events Section -->
|
||||||
|
<div v-if="todaysScheduledEvents.length > 0" class="mb-6 md:mb-8">
|
||||||
|
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3">
|
||||||
|
📅 Today's Events
|
||||||
|
</h3>
|
||||||
|
<div class="md:space-y-3">
|
||||||
|
<ScheduledEventCard
|
||||||
|
v-for="event in todaysScheduledEvents"
|
||||||
|
:key="`${event.pubkey}:${event.dTag}`"
|
||||||
|
:event="event"
|
||||||
|
:completion="getCompletion(`31922:${event.pubkey}:${event.dTag}`)"
|
||||||
|
:get-display-name="getDisplayName"
|
||||||
|
:admin-pubkeys="adminPubkeys"
|
||||||
|
@toggle-complete="onToggleComplete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posts Section -->
|
||||||
|
<div v-if="threadedPosts.length > 0" class="md:space-y-4 md:py-4">
|
||||||
|
<h3 v-if="todaysScheduledEvents.length > 0" class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3 mt-6">
|
||||||
|
💬 Posts
|
||||||
|
</h3>
|
||||||
<ThreadedPost
|
<ThreadedPost
|
||||||
v-for="post in threadedPosts"
|
v-for="post in threadedPosts"
|
||||||
:key="post.id"
|
:key="post.id"
|
||||||
|
|
|
||||||
151
src/modules/nostr-feed/components/ScheduledEventCard.vue
Normal file
151
src/modules/nostr-feed/components/ScheduledEventCard.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
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<Props>(), {
|
||||||
|
adminPubkeys: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border-b md:border md:rounded-lg bg-card p-4 md:p-6 transition-all"
|
||||||
|
:class="{ 'opacity-60': isCompleted }">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start gap-3 mb-3">
|
||||||
|
<!-- Completion Checkbox -->
|
||||||
|
<div class="flex items-center pt-1">
|
||||||
|
<Checkbox
|
||||||
|
:checked="isCompleted"
|
||||||
|
@update:checked="handleToggleComplete"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Details -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<!-- Title and badges -->
|
||||||
|
<div class="flex items-start gap-2 mb-2 flex-wrap">
|
||||||
|
<h3 class="font-semibold text-base md:text-lg flex-1"
|
||||||
|
:class="{ 'line-through': isCompleted }">
|
||||||
|
{{ event.title }}
|
||||||
|
</h3>
|
||||||
|
<Badge v-if="isAdminEvent" variant="secondary" class="shrink-0">
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date/Time -->
|
||||||
|
<div class="flex items-center gap-4 text-sm text-muted-foreground mb-2 flex-wrap">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Calendar class="h-4 w-4" />
|
||||||
|
<span>{{ formattedDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="formattedTimeRange" class="flex items-center gap-1.5">
|
||||||
|
<Clock class="h-4 w-4" />
|
||||||
|
<span>{{ formattedTimeRange }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
<div v-if="event.location" class="flex items-center gap-1.5 text-sm text-muted-foreground mb-3">
|
||||||
|
<MapPin class="h-4 w-4" />
|
||||||
|
<span>{{ event.location }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description/Content -->
|
||||||
|
<div v-if="event.description || event.content" class="text-sm mb-3">
|
||||||
|
<p class="whitespace-pre-wrap break-words">{{ event.description || event.content }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Completion info -->
|
||||||
|
<div v-if="isCompleted && completion" class="text-xs text-muted-foreground">
|
||||||
|
✓ Completed by {{ getDisplayName(completion.pubkey) }}
|
||||||
|
<span v-if="completion.notes"> - {{ completion.notes }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Author (if not admin) -->
|
||||||
|
<div v-if="!isAdminEvent" class="text-xs text-muted-foreground mt-2">
|
||||||
|
Posted by {{ getDisplayName(event.pubkey) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
143
src/modules/nostr-feed/composables/useScheduledEvents.ts
Normal file
143
src/modules/nostr-feed/composables/useScheduledEvents.ts
Normal file
|
|
@ -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<ScheduledEventService>(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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,14 @@ export const CONTENT_FILTERS: Record<string, ContentFilter> = {
|
||||||
description: 'Rideshare requests, offers, and coordination',
|
description: 'Rideshare requests, offers, and coordination',
|
||||||
tags: ['rideshare', 'carpool'], // NIP-12 tags
|
tags: ['rideshare', 'carpool'], // NIP-12 tags
|
||||||
keywords: ['rideshare', 'ride share', 'carpool', '🚗', '🚶']
|
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<string, ContentFilter[]> = {
|
||||||
// Rideshare only
|
// Rideshare only
|
||||||
rideshare: [
|
rideshare: [
|
||||||
CONTENT_FILTERS.rideshare
|
CONTENT_FILTERS.rideshare
|
||||||
|
],
|
||||||
|
|
||||||
|
// Scheduled events only
|
||||||
|
scheduledEvents: [
|
||||||
|
CONTENT_FILTERS.scheduledEvents
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useFeed } from './composables/useFeed'
|
||||||
import { FeedService } from './services/FeedService'
|
import { FeedService } from './services/FeedService'
|
||||||
import { ProfileService } from './services/ProfileService'
|
import { ProfileService } from './services/ProfileService'
|
||||||
import { ReactionService } from './services/ReactionService'
|
import { ReactionService } from './services/ReactionService'
|
||||||
|
import { ScheduledEventService } from './services/ScheduledEventService'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nostr Feed Module Plugin
|
* Nostr Feed Module Plugin
|
||||||
|
|
@ -23,10 +24,12 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
const feedService = new FeedService()
|
const feedService = new FeedService()
|
||||||
const profileService = new ProfileService()
|
const profileService = new ProfileService()
|
||||||
const reactionService = new ReactionService()
|
const reactionService = new ReactionService()
|
||||||
|
const scheduledEventService = new ScheduledEventService()
|
||||||
|
|
||||||
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
||||||
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
||||||
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
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')
|
console.log('nostr-feed module: Services registered in DI container')
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
|
|
@ -43,6 +46,10 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
reactionService.initialize({
|
reactionService.initialize({
|
||||||
waitForDependencies: true,
|
waitForDependencies: true,
|
||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
|
}),
|
||||||
|
scheduledEventService.initialize({
|
||||||
|
waitForDependencies: true,
|
||||||
|
maxRetries: 3
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
console.log('nostr-feed module: Services initialized')
|
console.log('nostr-feed module: Services initialized')
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export class FeedService extends BaseService {
|
||||||
protected relayHub: any = null
|
protected relayHub: any = null
|
||||||
protected visibilityService: any = null
|
protected visibilityService: any = null
|
||||||
protected reactionService: any = null
|
protected reactionService: any = null
|
||||||
|
protected scheduledEventService: any = null
|
||||||
|
|
||||||
// Event ID tracking for deduplication
|
// Event ID tracking for deduplication
|
||||||
private seenEventIds = new Set<string>()
|
private seenEventIds = new Set<string>()
|
||||||
|
|
@ -72,10 +73,12 @@ export class FeedService extends BaseService {
|
||||||
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||||
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
||||||
this.reactionService = injectService(SERVICE_TOKENS.REACTION_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: RelayHub injected:', !!this.relayHub)
|
||||||
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
||||||
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
||||||
|
console.log('FeedService: ScheduledEventService injected:', !!this.scheduledEventService)
|
||||||
|
|
||||||
if (!this.relayHub) {
|
if (!this.relayHub) {
|
||||||
throw new Error('RelayHub service not available')
|
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)
|
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)
|
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
|
||||||
|
|
||||||
// Subscribe to all events (posts, reactions, deletions) with deduplication
|
// Subscribe to all events (posts, reactions, deletions) with deduplication
|
||||||
|
|
@ -257,6 +266,22 @@ export class FeedService extends BaseService {
|
||||||
return
|
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)
|
// Skip if event already seen (for posts only, kind 1)
|
||||||
if (this.seenEventIds.has(event.id)) {
|
if (this.seenEventIds.has(event.id)) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
335
src/modules/nostr-feed/services/ScheduledEventService.ts
Normal file
335
src/modules/nostr-feed/services/ScheduledEventService.ts
Normal file
|
|
@ -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<string, ScheduledEvent>())
|
||||||
|
private _completions = reactive(new Map<string, EventCompletion>())
|
||||||
|
private _isLoading = ref(false)
|
||||||
|
|
||||||
|
protected async onInitialize(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, ScheduledEvent> {
|
||||||
|
return this._scheduledEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all completions
|
||||||
|
*/
|
||||||
|
get completions(): Map<string, EventCompletion> {
|
||||||
|
return this._completions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently loading
|
||||||
|
*/
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return this._isLoading.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup
|
||||||
|
*/
|
||||||
|
protected async onDestroy(): Promise<void> {
|
||||||
|
this._scheduledEvents.clear()
|
||||||
|
this._completions.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue