From dbc8b7abf4ca8063cd86fe0f1c68f9273266f572 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 21 May 2026 16:53:24 +0200 Subject: [PATCH 1/3] feat(activities): merge own LNbits drafts into the feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /activities feed is Nostr-driven, so an event that hasn't been published yet (typically `proposed` under auto_approve=off) silently vanishes from the creator's view. Add ticketedEventToActivity (the LNbits → NIP-52-shape adapter) and call it from useActivities so own drafts surface alongside Nostr-published activities. Once approved and published, the relay-sourced Activity has a newer createdAt and wins on upsert (and lacks lnbitsStatus, so any badge disappears). Also fire loadOwnEvents from the shell after event-created / event-updated so a fresh draft shows up immediately, not on the next subscribe cycle. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/activities-app/App.vue | 7 ++ .../activities/composables/useActivities.ts | 55 ++++++++++++ src/modules/activities/types/activity.ts | 85 +++++++++++++++++++ 3 files changed, 147 insertions(+) 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 { From 556b9e5cfee320bae388e00c01d650035cd3065d Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 21 May 2026 16:53:42 +0200 Subject: [PATCH 2/3] feat(activities): ownership + status badges on cards & detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActivityCard now renders a "Yours" badge (top-right corner of the image) when activity.isMine, and a "Pending review" / "Rejected" badge (bottom-left) when activity.lnbitsStatus is non-approved. The creator can spot their own posts at a glance on the main feed — particularly important for pending drafts that don't exist on Nostr yet. ActivityDetailPage echoes both badges next to the category row so users landing on the detail link of their own draft have the same signal. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../activities/components/ActivityCard.vue | 24 ++++++++++++++++++- .../activities/views/ActivityDetailPage.vue | 14 +++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) 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/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 }}
From 63fc7b3ab8656aaab05889589b44e584815f134f Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 21 May 2026 16:53:55 +0200 Subject: [PATCH 3/3] feat(activities): pending-aware toast + unified pending gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename willGoToPending → willLandInPending, drop the (now-redundant) isEditMode predicate from the gate so create can reuse it. Toast copy now confirms the destination explicitly: create + pending : "Submitted! Awaiting admin approval — your draft is visible on your feed with a Pending badge." edit + pending : "Updated — awaiting re-approval. Hidden from the public feed until reviewed." Closes the surprise where a non-admin user under auto_approve=off got a generic "Event submitted!" and then couldn't tell whether their post had been accepted or was just waiting. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/CreateEventDialog.vue | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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