Compare commits

...

3 commits

Author SHA1 Message Date
63fc7b3ab8 feat(activities): pending-aware toast + unified pending gate
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) <noreply@anthropic.com>
2026-05-21 16:53:55 +02:00
556b9e5cfe feat(activities): ownership + status badges on cards & detail
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) <noreply@anthropic.com>
2026-05-21 16:53:42 +02:00
dbc8b7abf4 feat(activities): merge own LNbits drafts into the feed
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) <noreply@anthropic.com>
2026-05-21 16:53:24 +02:00
6 changed files with 198 additions and 7 deletions

View file

@ -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"
/>
</AppShell>
</template>

View file

@ -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 }}
</Badge>
<!-- Ownership badge the creator can spot their own events at a
glance on the feed. -->
<Badge
v-if="activity.isMine"
variant="outline"
class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
<User class="w-3 h-3" />
Yours
</Badge>
<!-- Price badge -->
<Badge
v-if="priceDisplay"
@ -95,6 +106,17 @@ const placeholderBg = computed(() => {
{{ priceDisplay }}
</Badge>
<!-- Pending/rejected overlay for the creator's own non-approved
drafts. Only present when the activity originated from a
local LNbits event (Nostr-sourced activities have no
lnbitsStatus). -->
<Badge
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="absolute bottom-2 left-2 text-xs capitalize"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
</div>
<CardContent class="p-4 flex-1 flex flex-col gap-2">

View file

@ -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) => {
<ScrollArea class="max-h-[70vh] px-6 pb-6">
<form @submit="onSubmit" class="space-y-4">
<Alert v-if="willGoToPending" variant="default" class="border-orange-500/40 bg-orange-500/5">
<Alert v-if="isEditMode && willLandInPending" variant="default" class="border-orange-500/40 bg-orange-500/5">
<AlertCircle class="h-4 w-4 text-orange-500" />
<AlertDescription>
Saving will resubmit for approval. The event will be removed

View file

@ -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<string | null>(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<TicketApiService>(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,
}
}

View file

@ -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<OrganizerInfo>,
): 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 {

View file

@ -156,6 +156,20 @@ function goBack() {
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
{{ categoryLabel }}
</Badge>
<Badge
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="shrink-0 mt-1 capitalize"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<Badge
v-if="activity.isMine"
variant="outline"
class="shrink-0 mt-1"
>
Yours
</Badge>
<div v-for="tag in activity.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div>