diff --git a/src/activities-app/App.vue b/src/activities-app/App.vue index dc3ada6..640608b 100644 --- a/src/activities-app/App.vue +++ b/src/activities-app/App.vue @@ -7,6 +7,7 @@ import AppShell from '@/components/layout/AppShell.vue' import type { BottomTab } from '@/components/layout/BottomNav.vue' import { useAuth } from '@/composables/useAuthService' import { useActivitiesStore } from '@/modules/activities/stores/activities' +import { useActivities } from '@/modules/activities/composables/useActivities' import { useApprovalState } from '@/modules/activities/composables/useApprovalState' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { TicketApiService } from '@/modules/activities/services/TicketApiService' @@ -18,6 +19,10 @@ const { t } = useI18n() const { isAuthenticated, currentUser } = useAuth() const activitiesStore = useActivitiesStore() const { isAdmin, autoApprove } = useApprovalState() +// Used to merge own LNbits drafts into the activities feed right after +// the user creates or edits an event — otherwise the new draft only +// surfaces on the next ActivitiesPage subscribe cycle. +const { loadOwnEvents } = useActivities() // Settings dropped — theme/lang/currency now live in the shared profile sheet. // Create lives in the bottom nav (auth-gated): activity creation is a deliberate @@ -96,6 +101,8 @@ function handleDialogOpenChange(open: boolean) { :on-create-event="handleCreateEvent" :on-update-event="handleUpdateEvent" @update:open="handleDialogOpenChange" + @event-created="loadOwnEvents" + @event-updated="loadOwnEvents" /> diff --git a/src/modules/activities/composables/useActivities.ts b/src/modules/activities/composables/useActivities.ts index edc4b02..78b1bb5 100644 --- a/src/modules/activities/composables/useActivities.ts +++ b/src/modules/activities/composables/useActivities.ts @@ -1,7 +1,11 @@ import { ref, computed, onUnmounted } from 'vue' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' +import { useAuth } from '@/composables/useAuthService' import type { ActivitiesNostrService } from '../services/ActivitiesNostrService' import type { CalendarEventFilters } from '../services/ActivitiesNostrService' +import type { TicketApiService } from '../services/TicketApiService' +import type { TicketedEvent } from '../types/ticket' +import { ticketedEventToActivity } from '../types/activity' import { useActivitiesStore } from '../stores/activities' import { useActivityFilters } from './useActivityFilters' @@ -12,11 +16,55 @@ import { useActivityFilters } from './useActivityFilters' export function useActivities() { const store = useActivitiesStore() const filters = useActivityFilters() + const { isAuthenticated, currentUser } = useAuth() const isSubscribed = ref(false) const subscriptionError = ref(null) let unsubscribe: (() => void) | null = null + /** + * Merge the caller's own LNbits events (any status) into the feed. + * + * The `/activities` feed is Nostr-driven, so an event that hasn't + * been published yet — typically because it's still `proposed` under + * auto_approve=off — would silently vanish from the creator's view + * until an admin approves it. Pull own events from the events + * extension and upsert them as Activities so users see their own + * drafts with a Pending-review badge. + * + * Once an event is approved and the Nostr relay delivers the kind + * 31922/31923 event, the relay-sourced Activity has a newer + * createdAt and wins on upsert (it lacks `lnbitsStatus`, so the + * badge disappears). + */ + /** + * Stamp `isMine` on a Nostr-sourced activity when the organizer + * pubkey matches the logged-in user's Nostr key. LNbits drafts come + * pre-tagged via the adapter. + */ + function tagOwnership(activity: { organizer: { pubkey: string }; isMine?: boolean }) { + const myPubkey = currentUser.value?.pubkey + if (myPubkey && activity.organizer.pubkey === myPubkey) { + activity.isMine = true + } + } + + async function loadOwnEvents() { + if (!isAuthenticated.value) return + const invoiceKey = currentUser.value?.wallets?.[0]?.inkey + if (!invoiceKey) return + const ticketApi = tryInjectService(SERVICE_TOKENS.TICKET_API) + if (!ticketApi) return + try { + const mine = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[] + for (const ev of mine) { + store.upsertActivity(ticketedEventToActivity(ev)) + } + } catch (err) { + console.warn('[useActivities] loadOwnEvents failed:', err) + } + } + // Filtered and sorted activities (from all activities, filters handle time range) const filteredActivities = computed(() => { const all = store.activities.sort( @@ -43,6 +91,7 @@ export function useActivities() { unsubscribe = nostrService.subscribeToCalendarEvents( (activity) => { + tagOwnership(activity) store.upsertActivity(activity) store.isLoading = false }, @@ -51,6 +100,10 @@ export function useActivities() { isSubscribed.value = true + // Best-effort merge of own LNbits events (any status) so the + // creator sees their own pending drafts on the feed too. + loadOwnEvents() + // Set loading to false after a timeout (in case no events arrive) setTimeout(() => { store.isLoading = false @@ -75,6 +128,7 @@ export function useActivities() { store.isLoading = true subscriptionError.value = null const activities = await nostrService.queryCalendarEvents(eventFilters) + for (const a of activities) tagOwnership(a) store.upsertActivities(activities) } catch (err) { subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities' @@ -125,5 +179,6 @@ export function useActivities() { query, stop, refresh, + loadOwnEvents, } } diff --git a/src/modules/activities/types/activity.ts b/src/modules/activities/types/activity.ts index 24543a0..a377e8b 100644 --- a/src/modules/activities/types/activity.ts +++ b/src/modules/activities/types/activity.ts @@ -1,6 +1,7 @@ import ngeohash from 'ngeohash' import type { ActivityCategory } from './category' import type { CalendarTimeEvent, CalendarDateEvent } from './nip52' +import type { TicketedEvent } from './ticket' /** * Unified view model for displaying activities in the UI. @@ -45,6 +46,22 @@ export interface Activity { isPrivate: boolean /** Nostr event created_at timestamp */ createdAt: Date + /** + * LNbits approval status, when the activity came from the events + * extension rather than a Nostr relay. Undefined for activities + * sourced from Nostr (approved by definition — only published + * events make it onto relays). Used to render a "Pending review" + * badge for the creator's own non-approved drafts. + */ + lnbitsStatus?: 'approved' | 'proposed' | 'rejected' + /** + * Belongs to the current user. Set by the adapter for own LNbits + * drafts and by the activities-subscribe callback when the Nostr + * organizer pubkey matches the logged-in user. Used to render a + * "Yours" badge on the feed so the creator can spot their events + * at a glance. + */ + isMine?: boolean } export interface OrganizerInfo { @@ -128,6 +145,74 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer? } } +/** + * Convert an LNbits TicketedEvent to an Activity view model. + * + * Used to surface the caller's own pending events on the activities + * feed alongside Nostr-published activities. Once an event is approved + * and published, the Nostr-derived Activity (newer createdAt) wins on + * upsert in the activities store and this draft version is replaced. + * + * The wire format for dates mirrors how nostr_publisher emits NIP-52: + * - "YYYY-MM-DD" → date-based (kind 31922 on publish) + * - "YYYY-MM-DDTHH:MM..." → time-based (kind 31923 on publish) + */ +export function ticketedEventToActivity( + event: TicketedEvent, + organizer?: Partial, +): Activity { + const hasTime = event.event_start_date.includes('T') + const startDate = hasTime + ? new Date(event.event_start_date) + : parseDateOnly(event.event_start_date) + const endRaw = event.event_end_date + const endDate = endRaw + ? endRaw.includes('T') + ? new Date(endRaw) + : parseDateOnly(endRaw) + : undefined + + const category = event.categories?.[0] as ActivityCategory | undefined + + return { + id: event.id, + // No published Nostr event yet for pending drafts; reuse the LNbits + // id as a placeholder. Approved + published versions will overwrite + // this with the real Nostr event id. + nostrEventId: event.id, + type: hasTime ? 'time' : 'date', + organizer: { + // Pending events have no Nostr pubkey yet. Empty string is fine + // — the card layer falls back gracefully and the OrganizerCard + // is only shown for approved (Nostr-sourced) activities anyway. + pubkey: '', + ...organizer, + }, + title: event.name, + description: event.info ?? '', + image: event.banner ?? undefined, + startDate, + endDate, + location: event.location ?? undefined, + category, + tags: event.categories ?? [], + isPrivate: false, + // event.time is the LNbits creation timestamp (ISO string after + // FastAPI serialization). new Date() handles both ISO strings and + // numeric epoch — same shape used in useEvents sorting. + createdAt: new Date(event.time) || new Date(), + lnbitsStatus: event.status as Activity['lnbitsStatus'], + // fetchMyEvents only returns the caller's own events, so anything + // reaching this adapter is by definition mine. + isMine: true, + } +} + +function parseDateOnly(dateStr: string): Date { + const [year, month, day] = dateStr.split('-').map(Number) + return new Date(Date.UTC(year, month - 1, day)) +} + function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined { if (!geohash) return undefined try {