Compare commits

..

No commits in common. "63fc7b3ab8656aaab05889589b44e584815f134f" and "9b5f1273b3423b7336a98bb05b7134aea2267c96" have entirely different histories.

6 changed files with 7 additions and 198 deletions

View file

@ -7,7 +7,6 @@ import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue' import type { BottomTab } from '@/components/layout/BottomNav.vue'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '@/modules/activities/stores/activities' import { useActivitiesStore } from '@/modules/activities/stores/activities'
import { useActivities } from '@/modules/activities/composables/useActivities'
import { useApprovalState } from '@/modules/activities/composables/useApprovalState' import { useApprovalState } from '@/modules/activities/composables/useApprovalState'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '@/modules/activities/services/TicketApiService' import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
@ -19,10 +18,6 @@ const { t } = useI18n()
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const activitiesStore = useActivitiesStore() const activitiesStore = useActivitiesStore()
const { isAdmin, autoApprove } = useApprovalState() 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. // 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 // Create lives in the bottom nav (auth-gated): activity creation is a deliberate
@ -101,8 +96,6 @@ function handleDialogOpenChange(open: boolean) {
:on-create-event="handleCreateEvent" :on-create-event="handleCreateEvent"
:on-update-event="handleUpdateEvent" :on-update-event="handleUpdateEvent"
@update:open="handleDialogOpenChange" @update:open="handleDialogOpenChange"
@event-created="loadOwnEvents"
@event-updated="loadOwnEvents"
/> />
</AppShell> </AppShell>
</template> </template>

View file

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket, User } from 'lucide-vue-next' import { MapPin, Calendar, Ticket } from 'lucide-vue-next'
import BookmarkButton from './BookmarkButton.vue' import BookmarkButton from './BookmarkButton.vue'
import { useDateLocale } from '../composables/useDateLocale' import { useDateLocale } from '../composables/useDateLocale'
import type { Activity } from '../types/activity' import type { Activity } from '../types/activity'
@ -87,17 +87,6 @@ const placeholderBg = computed(() => {
{{ categoryLabel }} {{ categoryLabel }}
</Badge> </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 --> <!-- Price badge -->
<Badge <Badge
v-if="priceDisplay" v-if="priceDisplay"
@ -106,17 +95,6 @@ const placeholderBg = computed(() => {
{{ priceDisplay }} {{ priceDisplay }}
</Badge> </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> </div>
<CardContent class="p-4 flex-1 flex flex-col gap-2"> <CardContent class="p-4 flex-1 flex flex-col gap-2">

View file

@ -67,12 +67,8 @@ const emit = defineEmits<{
}>() }>()
const isEditMode = computed(() => Boolean(props.event?.id)) const isEditMode = computed(() => Boolean(props.event?.id))
// True when the submission will land in `proposed` status: a non-admin const willGoToPending = computed(
// owner with the extension's auto_approve toggle off. Same gate for () => isEditMode.value && !props.isAdmin && !props.autoApprove
// 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() const { t } = useI18n()
@ -306,8 +302,8 @@ const onSubmit = form.handleSubmit(async (formValues) => {
} }
await props.onUpdateEvent(props.event.id, eventData) await props.onUpdateEvent(props.event.id, eventData)
toastService.success( toastService.success(
willLandInPending.value willGoToPending.value
? 'Updated — awaiting re-approval. Hidden from the public feed until reviewed.' ? 'Event updated — pending re-approval'
: 'Event updated!' : 'Event updated!'
) )
emit('event-updated') emit('event-updated')
@ -317,11 +313,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
return return
} }
await props.onCreateEvent(eventData) await props.onCreateEvent(eventData)
toastService.success( toastService.success('Event submitted!')
willLandInPending.value
? 'Submitted! Awaiting admin approval — your draft is visible on your feed with a Pending badge.'
: 'Event submitted!'
)
emit('event-created') emit('event-created')
} }
@ -371,7 +363,7 @@ const handleOpenChange = (open: boolean) => {
<ScrollArea class="max-h-[70vh] px-6 pb-6"> <ScrollArea class="max-h-[70vh] px-6 pb-6">
<form @submit="onSubmit" class="space-y-4"> <form @submit="onSubmit" class="space-y-4">
<Alert v-if="isEditMode && willLandInPending" variant="default" class="border-orange-500/40 bg-orange-500/5"> <Alert v-if="willGoToPending" variant="default" class="border-orange-500/40 bg-orange-500/5">
<AlertCircle class="h-4 w-4 text-orange-500" /> <AlertCircle class="h-4 w-4 text-orange-500" />
<AlertDescription> <AlertDescription>
Saving will resubmit for approval. The event will be removed Saving will resubmit for approval. The event will be removed

View file

@ -1,11 +1,7 @@
import { ref, computed, onUnmounted } from 'vue' import { ref, computed, onUnmounted } from 'vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService' import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
import type { CalendarEventFilters } 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 { useActivitiesStore } from '../stores/activities'
import { useActivityFilters } from './useActivityFilters' import { useActivityFilters } from './useActivityFilters'
@ -16,55 +12,11 @@ import { useActivityFilters } from './useActivityFilters'
export function useActivities() { export function useActivities() {
const store = useActivitiesStore() const store = useActivitiesStore()
const filters = useActivityFilters() const filters = useActivityFilters()
const { isAuthenticated, currentUser } = useAuth()
const isSubscribed = ref(false) const isSubscribed = ref(false)
const subscriptionError = ref<string | null>(null) const subscriptionError = ref<string | null>(null)
let unsubscribe: (() => void) | 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) // Filtered and sorted activities (from all activities, filters handle time range)
const filteredActivities = computed(() => { const filteredActivities = computed(() => {
const all = store.activities.sort( const all = store.activities.sort(
@ -91,7 +43,6 @@ export function useActivities() {
unsubscribe = nostrService.subscribeToCalendarEvents( unsubscribe = nostrService.subscribeToCalendarEvents(
(activity) => { (activity) => {
tagOwnership(activity)
store.upsertActivity(activity) store.upsertActivity(activity)
store.isLoading = false store.isLoading = false
}, },
@ -100,10 +51,6 @@ export function useActivities() {
isSubscribed.value = true 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) // Set loading to false after a timeout (in case no events arrive)
setTimeout(() => { setTimeout(() => {
store.isLoading = false store.isLoading = false
@ -128,7 +75,6 @@ export function useActivities() {
store.isLoading = true store.isLoading = true
subscriptionError.value = null subscriptionError.value = null
const activities = await nostrService.queryCalendarEvents(eventFilters) const activities = await nostrService.queryCalendarEvents(eventFilters)
for (const a of activities) tagOwnership(a)
store.upsertActivities(activities) store.upsertActivities(activities)
} catch (err) { } catch (err) {
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities' subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
@ -179,6 +125,5 @@ export function useActivities() {
query, query,
stop, stop,
refresh, refresh,
loadOwnEvents,
} }
} }

View file

@ -1,7 +1,6 @@
import ngeohash from 'ngeohash' import ngeohash from 'ngeohash'
import type { ActivityCategory } from './category' import type { ActivityCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52' import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
import type { TicketedEvent } from './ticket'
/** /**
* Unified view model for displaying activities in the UI. * Unified view model for displaying activities in the UI.
@ -46,22 +45,6 @@ export interface Activity {
isPrivate: boolean isPrivate: boolean
/** Nostr event created_at timestamp */ /** Nostr event created_at timestamp */
createdAt: Date 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 { export interface OrganizerInfo {
@ -145,74 +128,6 @@ 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 { function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined {
if (!geohash) return undefined if (!geohash) return undefined
try { try {

View file

@ -156,20 +156,6 @@ function goBack() {
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1"> <Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
{{ categoryLabel }} {{ categoryLabel }}
</Badge> </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"> <div v-for="tag in activity.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge> <Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div> </div>