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/components/ActivityCard.vue b/src/modules/activities/components/ActivityCard.vue index 71054fb..d021b10 100644 --- a/src/modules/activities/components/ActivityCard.vue +++ b/src/modules/activities/components/ActivityCard.vue @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n' import { format } from 'date-fns' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { MapPin, Calendar, Ticket } from 'lucide-vue-next' +import { MapPin, Calendar, Ticket, User } from 'lucide-vue-next' import BookmarkButton from './BookmarkButton.vue' import { useDateLocale } from '../composables/useDateLocale' import type { Activity } from '../types/activity' @@ -87,6 +87,17 @@ const placeholderBg = computed(() => { {{ categoryLabel }} + + + + Yours + + { {{ priceDisplay }} + + + {{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} + diff --git a/src/modules/activities/components/CreateEventDialog.vue b/src/modules/activities/components/CreateEventDialog.vue index a32846a..180184b 100644 --- a/src/modules/activities/components/CreateEventDialog.vue +++ b/src/modules/activities/components/CreateEventDialog.vue @@ -67,8 +67,12 @@ const emit = defineEmits<{ }>() const isEditMode = computed(() => Boolean(props.event?.id)) -const willGoToPending = computed( - () => isEditMode.value && !props.isAdmin && !props.autoApprove +// True when the submission will land in `proposed` status: a non-admin +// owner with the extension's auto_approve toggle off. Same gate for +// create and edit; the edit path also keys the in-form warning banner +// on this so the user sees the consequence before submitting. +const willLandInPending = computed( + () => !props.isAdmin && !props.autoApprove ) const { t } = useI18n() @@ -302,8 +306,8 @@ const onSubmit = form.handleSubmit(async (formValues) => { } await props.onUpdateEvent(props.event.id, eventData) toastService.success( - willGoToPending.value - ? 'Event updated — pending re-approval' + willLandInPending.value + ? 'Updated — awaiting re-approval. Hidden from the public feed until reviewed.' : 'Event updated!' ) emit('event-updated') @@ -313,7 +317,11 @@ const onSubmit = form.handleSubmit(async (formValues) => { return } await props.onCreateEvent(eventData) - toastService.success('Event submitted!') + toastService.success( + willLandInPending.value + ? 'Submitted! Awaiting admin approval — your draft is visible on your feed with a Pending badge.' + : 'Event submitted!' + ) emit('event-created') } @@ -363,7 +371,7 @@ const handleOpenChange = (open: boolean) => {
- + Saving will resubmit for approval. The event will be removed 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 { diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index 9c509b7..1929ca2 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -156,6 +156,20 @@ function goBack() { {{ categoryLabel }} + + {{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} + + + Yours +
{{ tag }}