feat(activities): drop RSVP buttons from EventDetailPage
The Going/Maybe/Not going row was redundant: the bookmark heart count already signals casual interest and ticket sales answer "who's actually going". Cuts an affordance whose value-add was unclear and whose visual weight competed with the buy-ticket CTA right below it. Removes the RSVPButton component, the useRSVP composable, and the i18n strings that only fed those buttons. Keeps NIP52_KINDS.RSVP and the CalendarRSVP type as protocol documentation in case we revisit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6d4a9f8c22
commit
60369ce1b1
7 changed files with 0 additions and 356 deletions
|
|
@ -101,9 +101,6 @@ const messages: LocaleMessages = {
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
getTicket: 'Get Ticket',
|
getTicket: 'Get Ticket',
|
||||||
going: 'Going',
|
|
||||||
maybe: 'Maybe',
|
|
||||||
notGoing: 'Not Going',
|
|
||||||
contactOrganizer: 'Contact Organizer',
|
contactOrganizer: 'Contact Organizer',
|
||||||
organizer: 'Organizer',
|
organizer: 'Organizer',
|
||||||
location: 'Location',
|
location: 'Location',
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,6 @@ const messages: LocaleMessages = {
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
getTicket: 'Obtener boleto',
|
getTicket: 'Obtener boleto',
|
||||||
going: 'Voy',
|
|
||||||
maybe: 'Tal vez',
|
|
||||||
notGoing: 'No voy',
|
|
||||||
contactOrganizer: 'Contactar organizador',
|
contactOrganizer: 'Contactar organizador',
|
||||||
organizer: 'Organizador',
|
organizer: 'Organizador',
|
||||||
location: 'Ubicación',
|
location: 'Ubicación',
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,6 @@ const messages: LocaleMessages = {
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
getTicket: 'Obtenir un billet',
|
getTicket: 'Obtenir un billet',
|
||||||
going: 'Présent',
|
|
||||||
maybe: 'Peut-être',
|
|
||||||
notGoing: 'Absent',
|
|
||||||
contactOrganizer: "Contacter l'organisateur",
|
contactOrganizer: "Contacter l'organisateur",
|
||||||
organizer: 'Organisateur',
|
organizer: 'Organisateur',
|
||||||
location: 'Lieu',
|
location: 'Lieu',
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,6 @@ export interface LocaleMessages {
|
||||||
categories: Record<string, string>
|
categories: Record<string, string>
|
||||||
detail: {
|
detail: {
|
||||||
getTicket: string
|
getTicket: string
|
||||||
going: string
|
|
||||||
maybe: string
|
|
||||||
notGoing: string
|
|
||||||
contactOrganizer: string
|
contactOrganizer: string
|
||||||
organizer: string
|
organizer: string
|
||||||
location: string
|
location: string
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Check, HelpCircle, X } from 'lucide-vue-next'
|
|
||||||
import { toast } from 'vue-sonner'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { useRSVP } from '../composables/useRSVP'
|
|
||||||
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
pubkey: string
|
|
||||||
dTag: string
|
|
||||||
kind?: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const { t } = useI18n()
|
|
||||||
const { isAuthenticated } = useAuth()
|
|
||||||
const { getMyRSVP, getRSVPCount, setRSVP, isPending } = useRSVP()
|
|
||||||
|
|
||||||
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
|
||||||
const myStatus = computed(() => getMyRSVP(eventKind.value, props.pubkey, props.dTag))
|
|
||||||
const goingCount = computed(() => getRSVPCount(eventKind.value, props.pubkey, props.dTag))
|
|
||||||
const pending = computed(() => isPending(eventKind.value, props.pubkey, props.dTag))
|
|
||||||
|
|
||||||
const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
|
|
||||||
{ status: 'accepted', labelKey: 'events.detail.going', icon: Check },
|
|
||||||
{ status: 'tentative', labelKey: 'events.detail.maybe', icon: HelpCircle },
|
|
||||||
{ status: 'declined', labelKey: 'events.detail.notGoing', icon: X },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusLabel: Record<RSVPStatus, string> = {
|
|
||||||
accepted: "You're going",
|
|
||||||
tentative: 'Marked as maybe',
|
|
||||||
declined: "You're not going",
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleClick(status: RSVPStatus) {
|
|
||||||
if (!isAuthenticated.value) {
|
|
||||||
toast.info('Log in to RSVP', {
|
|
||||||
action: {
|
|
||||||
label: 'Log in',
|
|
||||||
onClick: () => router.push('/login'),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const published = await setRSVP(eventKind.value, props.pubkey, props.dTag, status)
|
|
||||||
if (published) {
|
|
||||||
toast.success(statusLabel[published])
|
|
||||||
} else if (!pending.value) {
|
|
||||||
// setRSVP returned null AND we're no longer pending → publish failed
|
|
||||||
// (vs. throttled, where pending was true at the time of the click).
|
|
||||||
toast.error("Couldn't save RSVP — try again")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
v-for="btn in buttons"
|
|
||||||
:key="btn.status"
|
|
||||||
:variant="myStatus === btn.status ? 'default' : 'outline'"
|
|
||||||
:disabled="pending"
|
|
||||||
size="sm"
|
|
||||||
class="flex-1 gap-1.5"
|
|
||||||
@click="handleClick(btn.status)"
|
|
||||||
>
|
|
||||||
<component :is="btn.icon" class="w-3.5 h-3.5" />
|
|
||||||
{{ t(btn.labelKey) }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p v-if="goingCount > 0" class="text-xs text-muted-foreground">
|
|
||||||
{{ goingCount }} {{ goingCount === 1 ? 'person' : 'people' }} going
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
|
||||||
import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
|
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
|
||||||
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NIP-52 RSVP (kind 31925) for responding to calendar events.
|
|
||||||
*
|
|
||||||
* Each RSVP is an addressable event with:
|
|
||||||
* d-tag: unique identifier for this RSVP
|
|
||||||
* a-tag: reference to the calendar event (kind:pubkey:d-tag)
|
|
||||||
* status tag: 'accepted' | 'declined' | 'tentative'
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface RSVPEntry {
|
|
||||||
status: RSVPStatus
|
|
||||||
eventId: string
|
|
||||||
createdAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache: eventCoord -> user's own (latest) RSVP entry
|
|
||||||
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
|
|
||||||
// Cache: eventCoord -> (pubkey -> latest RSVP entry from that pubkey).
|
|
||||||
// NIP-52 RSVPs (kind 31925) are replaceable per (kind, pubkey, d-tag), so a
|
|
||||||
// user's earlier RSVP for an event is superseded by their later one. The
|
|
||||||
// "going" count is derived from this map (count of pubkeys whose *latest*
|
|
||||||
// RSVP has status === 'accepted'), not by summing every accepted event seen
|
|
||||||
// — that would double-count replacements and never decrement on flip.
|
|
||||||
const rsvpStates = ref<Map<string, Map<string, RSVPEntry>>>(new Map())
|
|
||||||
const isLoaded = ref(false)
|
|
||||||
|
|
||||||
// Coords with an in-flight publish — used to disable RSVP buttons so fast
|
|
||||||
// clicks don't race each other.
|
|
||||||
const pendingCoords = ref<Set<string>>(new Set())
|
|
||||||
|
|
||||||
// Last successfully-published `created_at` per coord. NIP-01 created_at is
|
|
||||||
// integer seconds, so two clicks in the same wall-clock second produce the
|
|
||||||
// same timestamp and most relays treat the second one as a duplicate /
|
|
||||||
// older replacement and silently drop it. We bump past the previous
|
|
||||||
// timestamp so each click is strictly newer.
|
|
||||||
const lastPublishAt = new Map<string, number>()
|
|
||||||
|
|
||||||
function upsertRSVPState(coord: string, pubkey: string, entry: RSVPEntry) {
|
|
||||||
let inner = rsvpStates.value.get(coord)
|
|
||||||
if (!inner) {
|
|
||||||
inner = new Map()
|
|
||||||
}
|
|
||||||
const existing = inner.get(pubkey)
|
|
||||||
if (existing && existing.createdAt >= entry.createdAt) return
|
|
||||||
inner.set(pubkey, entry)
|
|
||||||
// Re-set on the outer map so the ref's reactive proxy notifies dependents
|
|
||||||
// (Vue 3's deep reevent doesn't reach into nested Map values).
|
|
||||||
rsvpStates.value.set(coord, inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRSVP() {
|
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
|
||||||
let unsubscribe: (() => void) | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the user's RSVP status for an event.
|
|
||||||
*/
|
|
||||||
function getMyRSVP(eventKind: number, pubkey: string, dTag: string): RSVPStatus | null {
|
|
||||||
const coord = `${eventKind}:${pubkey}:${dTag}`
|
|
||||||
return rsvpCache.value.get(coord)?.status ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RSVP count for an event = number of pubkeys whose latest RSVP for
|
|
||||||
* this event has status 'accepted'.
|
|
||||||
*/
|
|
||||||
function getRSVPCount(eventKind: number, pubkey: string, dTag: string): number {
|
|
||||||
const coord = `${eventKind}:${pubkey}:${dTag}`
|
|
||||||
const inner = rsvpStates.value.get(coord)
|
|
||||||
if (!inner) return 0
|
|
||||||
let count = 0
|
|
||||||
for (const entry of inner.values()) {
|
|
||||||
if (entry.status === 'accepted') count++
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the user's RSVPs and counts for visible events from relays.
|
|
||||||
*/
|
|
||||||
function loadRSVPs() {
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
|
||||||
if (!relayHub) return
|
|
||||||
|
|
||||||
// Subscribe to all RSVPs (for counts) and filter user's own
|
|
||||||
unsubscribe = relayHub.subscribe({
|
|
||||||
id: `rsvps-${Date.now()}`,
|
|
||||||
filters: [{
|
|
||||||
kinds: [NIP52_KINDS.RSVP],
|
|
||||||
limit: 500,
|
|
||||||
}],
|
|
||||||
onEvent: (event: NostrEvent) => {
|
|
||||||
const aTag = event.tags.find(t => t[0] === 'a')?.[1]
|
|
||||||
if (!aTag) return
|
|
||||||
|
|
||||||
const statusTag = event.tags.find(t => t[0] === 'status')?.[1] as RSVPStatus | undefined
|
|
||||||
// Also check 'l' tag pattern used by some clients
|
|
||||||
const lStatus = event.tags.find(t => t[0] === 'l' && t[2] === 'status')?.[1] as RSVPStatus | undefined
|
|
||||||
const status = statusTag ?? lStatus
|
|
||||||
if (!status || !['accepted', 'declined', 'tentative'].includes(status)) return
|
|
||||||
|
|
||||||
const entry: RSVPEntry = {
|
|
||||||
status,
|
|
||||||
eventId: event.id,
|
|
||||||
createdAt: event.created_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-pubkey latest-wins state — drives the count.
|
|
||||||
upsertRSVPState(aTag, event.pubkey, entry)
|
|
||||||
|
|
||||||
// User's own RSVP cache (used by getMyRSVP).
|
|
||||||
if (currentUser.value?.pubkey && event.pubkey === currentUser.value.pubkey) {
|
|
||||||
const existing = rsvpCache.value.get(aTag)
|
|
||||||
if (!existing || event.created_at > existing.createdAt) {
|
|
||||||
rsvpCache.value.set(aTag, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onEose: () => {
|
|
||||||
isLoaded.value = true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a publish is currently in flight for the given event. Bind
|
|
||||||
* to the RSVP buttons' `:disabled` so users can't queue racing clicks.
|
|
||||||
*/
|
|
||||||
function isPending(eventKind: number, pubkey: string, dTag: string): boolean {
|
|
||||||
const coord = `${eventKind}:${pubkey}:${dTag}`
|
|
||||||
return pendingCoords.value.has(coord)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish an RSVP for an event.
|
|
||||||
* Clicking the same status again removes the RSVP (publishes 'declined').
|
|
||||||
*
|
|
||||||
* Returns the status that was published on success, or null if the publish
|
|
||||||
* was rejected, blocked, or threw — caller should toast accordingly.
|
|
||||||
*/
|
|
||||||
async function setRSVP(
|
|
||||||
eventKind: number,
|
|
||||||
eventPubkey: string,
|
|
||||||
eventDTag: string,
|
|
||||||
status: RSVPStatus
|
|
||||||
): Promise<RSVPStatus | null> {
|
|
||||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
|
|
||||||
|
|
||||||
const coord = `${eventKind}:${eventPubkey}:${eventDTag}`
|
|
||||||
|
|
||||||
// Throttle: refuse a second click while the first is still publishing.
|
|
||||||
if (pendingCoords.value.has(coord)) return null
|
|
||||||
|
|
||||||
// Toggle: if already this status, decline instead.
|
|
||||||
const currentStatus = getMyRSVP(eventKind, eventPubkey, eventDTag)
|
|
||||||
const newStatus = currentStatus === status ? 'declined' : status
|
|
||||||
|
|
||||||
const dTag = `rsvp-${eventDTag}`
|
|
||||||
|
|
||||||
// Strictly-monotonic created_at per coord so two clicks in the same
|
|
||||||
// wall-clock second don't both stamp the same timestamp (relays would
|
|
||||||
// dedupe the second one as a non-newer replacement).
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
|
||||||
const previous = lastPublishAt.get(coord) ?? 0
|
|
||||||
const createdAt = Math.max(now, previous + 1)
|
|
||||||
|
|
||||||
const template: EventTemplate = {
|
|
||||||
kind: NIP52_KINDS.RSVP,
|
|
||||||
created_at: createdAt,
|
|
||||||
content: '',
|
|
||||||
tags: [
|
|
||||||
['d', dTag],
|
|
||||||
['a', coord],
|
|
||||||
['status', newStatus],
|
|
||||||
['L', 'status'],
|
|
||||||
['l', newStatus, 'status'],
|
|
||||||
['p', eventPubkey],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
let signedEvent: NostrEvent
|
|
||||||
try {
|
|
||||||
signedEvent = await signEventViaLnbits(template)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[useRSVP] signEventViaLnbits failed:', err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
|
||||||
if (!relayHub) return null
|
|
||||||
|
|
||||||
pendingCoords.value.add(coord)
|
|
||||||
try {
|
|
||||||
const result = await relayHub.publishEvent(signedEvent)
|
|
||||||
if (!result || result.success <= 0) {
|
|
||||||
// No relay accepted the event — leave caches untouched so the UI
|
|
||||||
// continues to reflect the last known-good state.
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry: RSVPEntry = {
|
|
||||||
status: newStatus,
|
|
||||||
eventId: signedEvent.id,
|
|
||||||
createdAt: signedEvent.created_at,
|
|
||||||
}
|
|
||||||
// Update both the user-scoped cache and the all-users state so the
|
|
||||||
// count flips immediately rather than waiting for the relay to echo
|
|
||||||
// our own event back through the subscription.
|
|
||||||
rsvpCache.value.set(coord, entry)
|
|
||||||
if (currentUser.value.pubkey) {
|
|
||||||
upsertRSVPState(coord, currentUser.value.pubkey, entry)
|
|
||||||
}
|
|
||||||
lastPublishAt.set(coord, signedEvent.created_at)
|
|
||||||
return newStatus
|
|
||||||
} finally {
|
|
||||||
pendingCoords.value.delete(coord)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!isLoaded.value) {
|
|
||||||
loadRSVPs()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (unsubscribe) {
|
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
getMyRSVP,
|
|
||||||
getRSVPCount,
|
|
||||||
setRSVP,
|
|
||||||
isPending,
|
|
||||||
isLoaded,
|
|
||||||
loadRSVPs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -12,10 +12,8 @@ import {
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useEventDetail } from '../composables/useEventDetail'
|
import { useEventDetail } from '../composables/useEventDetail'
|
||||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||||
import RSVPButton from '../components/RSVPButton.vue'
|
|
||||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||||
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
||||||
import { NIP52_KINDS } from '../types/nip52'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useEventsStore } from '../stores/events'
|
import { useEventsStore } from '../stores/events'
|
||||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
|
|
@ -285,19 +283,6 @@ function goToMyTickets() {
|
||||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RSVP — hidden for the host since RSVPing to your own event
|
|
||||||
is a noise affordance. -->
|
|
||||||
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
|
|
||||||
(31922 for date-based, 31923 for time-based). Without this prop the
|
|
||||||
button would default to time-based for every event, leaving RSVPs
|
|
||||||
on date-based events pointing at a non-existent event coord. -->
|
|
||||||
<RSVPButton
|
|
||||||
v-if="!ownedLnbitsEvent"
|
|
||||||
:pubkey="event.organizer.pubkey"
|
|
||||||
:d-tag="event.id"
|
|
||||||
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Host's primary CTA is to scan tickets at the door. Lives
|
<!-- Host's primary CTA is to scan tickets at the door. Lives
|
||||||
OUTSIDE the ticketInfo gate so it appears even when the
|
OUTSIDE the ticketInfo gate so it appears even when the
|
||||||
event was published without AIO ticket tags — a host always
|
event was published without AIO ticket tags — a host always
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue