diff --git a/src/modules/activities/components/CreateActivityDialog.vue b/src/modules/activities/components/CreateActivityDialog.vue index 2c5654c..0c6935a 100644 --- a/src/modules/activities/components/CreateActivityDialog.vue +++ b/src/modules/activities/components/CreateActivityDialog.vue @@ -16,8 +16,8 @@ import { Button } from '@/components/ui/button' import { CalendarPlus } from 'lucide-vue-next' import { useAuth } from '@/composables/useAuthService' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' -import type { TicketApiService } from '../services/TicketApiService' -import type { CreateEventRequest } from '../types/ticket' +import type { ActivitiesNostrService } from '../services/ActivitiesNostrService' +import type { CalendarTimeEvent } from '../types/nip52' import type { ActivityCategory } from '../types/category' import CategorySelector from './CategorySelector.vue' import LocationPicker from './LocationPicker.vue' @@ -67,64 +67,56 @@ const form = useForm({ const isFormValid = computed(() => form.meta.value.valid) const onSubmit = form.handleSubmit(async (values) => { - const ticketApi = tryInjectService(SERVICE_TOKENS.TICKET_API) - if (!ticketApi) { + const nostrService = tryInjectService(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) + if (!nostrService) { toast.error('Activities service not available') return } - const invoiceKey = currentUser.value?.wallets?.[0]?.inkey - if (!invoiceKey) { - toast.error('No wallet available. Please log in first.') + const signingKey = currentUser.value?.prvkey + if (!signingKey) { + toast.error('Signing key not available. Please log in again.') return } isPublishing.value = true try { - // Compose ISO 8601 datetime strings the events extension parses. - const startIso = `${values.startDate}T${values.startTime}` - const endIso = - values.endDate && values.endTime - ? `${values.endDate}T${values.endTime}` - : undefined - - // Fold summary + description into `info` since the events extension - // CreateEventRequest has no separate summary field. - const info = - values.summary && values.description - ? `${values.summary}\n\n${values.description}` - : values.description || values.summary || '' - - // Ticket-less activity — amount_tickets and price_per_ticket both - // pinned at 0 (events extension treats 0 as "unlimited / not - // ticketed" per models.py:45-46). Server-side `signer.sign_event` - // produces the kind-31922 calendar event and publishes via the - // operator's configured relays — no webapp signing path needed. - const eventData: CreateEventRequest = { - name: values.title, - info, - event_start_date: startIso, - event_end_date: endIso, - location: location.value || null, - banner: values.image || null, - categories: selectedCategories.value, - amount_tickets: 0, - price_per_ticket: 0, + // Build unix timestamps + const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000) + let endTimestamp: number | undefined + if (values.endDate && values.endTime) { + endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000) } - await ticketApi.createEvent(eventData, invoiceKey) + // Generate a unique d-tag + const dTag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - // Approval workflow caveat: non-admin users on instances with - // `auto_approve=false` (the default) land in the proposal queue; - // their event isn't published to relays until an admin approves. - // Admins-and-auto-approve-on instances publish immediately. - toast.success('Activity created!') - emit('created') - handleClose() + const eventData: Partial = { + dTag, + title: values.title, + summary: values.summary || undefined, + content: values.description, + image: values.image || undefined, + start: startTimestamp, + end: endTimestamp, + startTzid: Intl.DateTimeFormat().resolvedOptions().timeZone, + location: location.value || undefined, + hashtags: selectedCategories.value, + } + + const result = await nostrService.publishCalendarEvent(eventData, signingKey) + + if (result.success > 0) { + toast.success(`Activity published to ${result.success} relay${result.success > 1 ? 's' : ''}`) + emit('created') + handleClose() + } else { + toast.error('Failed to publish to any relay') + } } catch (err) { - console.error('Failed to create activity:', err) - toast.error(err instanceof Error ? err.message : 'Failed to create activity') + console.error('Failed to publish activity:', err) + toast.error(err instanceof Error ? err.message : 'Failed to publish activity') } finally { isPublishing.value = false } diff --git a/src/modules/activities/services/ActivitiesNostrService.ts b/src/modules/activities/services/ActivitiesNostrService.ts index 4883910..da5ed05 100644 --- a/src/modules/activities/services/ActivitiesNostrService.ts +++ b/src/modules/activities/services/ActivitiesNostrService.ts @@ -1,10 +1,12 @@ import { BaseService } from '@/core/base/BaseService' -import type { Event as NostrEvent } from 'nostr-tools' +import { finalizeEvent, type Event as NostrEvent, type EventTemplate } from 'nostr-tools' import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub' import { NIP52_KINDS, parseCalendarTimeEvent, parseCalendarDateEvent, + buildCalendarTimeEventTags, + type CalendarTimeEvent, } from '../types/nip52' import { calendarTimeEventToActivity, @@ -28,15 +30,7 @@ export interface CalendarEventFilters { } /** - * Service for subscribing to NIP-52 Calendar Events via RelayHub. - * - * Publishing kind-31922 calendar events lives server-side in the - * `aiolabs/events` LNbits extension (signer-abstraction branch, commit - * 66076d6) — `POST /events/api/v1/events` constructs and signs the - * event via NostrSigner and broadcasts it to the operator's configured - * relays. The webapp constructs only the request payload; see - * CreateActivityDialog for the flow. - * + * Service for subscribing to and publishing NIP-52 Calendar Events via RelayHub. * Extends BaseService for standardized dependency injection and lifecycle. */ export class ActivitiesNostrService extends BaseService { @@ -113,6 +107,32 @@ export class ActivitiesNostrService extends BaseService { return activities } + /** + * Publish a NIP-52 time-based calendar event. + * Requires an authenticated user with a signing key. + */ + async publishCalendarEvent( + eventData: Partial, + signingKeyHex: string + ): Promise<{ success: number; total: number }> { + if (!this.relayHub) { + throw new Error('RelayHub not available') + } + + const tags = buildCalendarTimeEventTags(eventData) + const template: EventTemplate = { + kind: NIP52_KINDS.CALENDAR_TIME_EVENT, + created_at: Math.floor(Date.now() / 1000), + content: eventData.content ?? '', + tags, + } + + const privkeyBytes = hexToUint8Array(signingKeyHex) + const signedEvent = finalizeEvent(template, privkeyBytes) + + return await this.relayHub.publishEvent(signedEvent) + } + /** * Parse a raw Nostr event into an Activity view model. */ @@ -162,3 +182,11 @@ export class ActivitiesNostrService extends BaseService { this.activeUnsubscribes = [] } } + +function 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 +}