From c734f04e96a7aa3d06666f66f2775b83c4626c5a Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 5 May 2026 20:24:05 +0200 Subject: [PATCH] fix(activities): correct RSVP count, throttle clicks, fix kind reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to NIP-52 RSVP handling that were producing wrong counts and unpublished clicks: 1. Count was a flat running tally that incremented on every "accepted" event seen and never decremented. Replaced with per-pubkey latest-wins state derived from a Map> — count is now the number of pubkeys whose latest RSVP has status "accepted", which handles both flips (going → maybe → going leaves count unchanged) and relay re-deliveries. 2. Fast clicks could land in the same wall-clock second; both events got the same created_at and most relays silently dropped the second one as a non-newer replacement. Added a per-coord pendingCoords Set that disables the buttons during in-flight publish, plus a lastPublishAt map that bumps created_at to max(now, previous + 1) so each click is strictly newer than the last. 3. RSVPButton defaulted activityKind to CALENDAR_TIME_EVENT (31923), but the events extension publishes calendar events as kind 31922 (date- based). RSVPs were therefore building `a` tags pointing at a non- existent (kind, pubkey, d-tag) coord. ActivityDetailPage.vue now passes `:kind` derived from `activity.type` so date-based and time- based activities each get the correct kind reference. setRSVP now returns the published status (or null on throttle / failure) so the button can toast feedback per click. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../activities/components/RSVPButton.vue | 21 ++- src/modules/activities/composables/useRSVP.ts | 123 ++++++++++++++---- .../activities/views/ActivityDetailPage.vue | 6 + 3 files changed, 124 insertions(+), 26 deletions(-) diff --git a/src/modules/activities/components/RSVPButton.vue b/src/modules/activities/components/RSVPButton.vue index e1d0266..e003220 100644 --- a/src/modules/activities/components/RSVPButton.vue +++ b/src/modules/activities/components/RSVPButton.vue @@ -18,11 +18,12 @@ const props = defineProps<{ const router = useRouter() const { t } = useI18n() const { isAuthenticated } = useAuth() -const { getMyRSVP, getRSVPCount, setRSVP } = useRSVP() +const { getMyRSVP, getRSVPCount, setRSVP, isPending } = useRSVP() const activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT) const myStatus = computed(() => getMyRSVP(activityKind.value, props.pubkey, props.dTag)) const goingCount = computed(() => getRSVPCount(activityKind.value, props.pubkey, props.dTag)) +const pending = computed(() => isPending(activityKind.value, props.pubkey, props.dTag)) const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [ { status: 'accepted', labelKey: 'activities.detail.going', icon: Check }, @@ -30,7 +31,13 @@ const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [ { status: 'declined', labelKey: 'activities.detail.notGoing', icon: X }, ] -function handleClick(status: RSVPStatus) { +const statusLabel: Record = { + 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: { @@ -40,7 +47,14 @@ function handleClick(status: RSVPStatus) { }) return } - setRSVP(activityKind.value, props.pubkey, props.dTag, status) + const published = await setRSVP(activityKind.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") + } } @@ -51,6 +65,7 @@ function handleClick(status: RSVPStatus) { 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)" diff --git a/src/modules/activities/composables/useRSVP.ts b/src/modules/activities/composables/useRSVP.ts index e02cae3..337a7ec 100644 --- a/src/modules/activities/composables/useRSVP.ts +++ b/src/modules/activities/composables/useRSVP.ts @@ -19,12 +19,41 @@ interface RSVPEntry { createdAt: number } -// Cache: activityCoord -> user's RSVP status +// Cache: activityCoord -> user's own (latest) RSVP entry const rsvpCache = ref>(new Map()) -// Cache: activityCoord -> count of RSVPs from all users -const rsvpCounts = ref>(new Map()) +// Cache: activityCoord -> (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 activity 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>>(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>(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() + +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 reactivity doesn't reach into nested Map values). + rsvpStates.value.set(coord, inner) +} + export function useRSVP() { const { isAuthenticated, currentUser } = useAuth() let unsubscribe: (() => void) | null = null @@ -38,11 +67,18 @@ export function useRSVP() { } /** - * Get RSVP count for an activity. + * RSVP count for an activity = number of pubkeys whose latest RSVP for + * this activity has status 'accepted'. */ function getRSVPCount(activityKind: number, pubkey: string, dTag: string): number { const coord = `${activityKind}:${pubkey}:${dTag}` - return rsvpCounts.value.get(coord) ?? 0 + 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 } /** @@ -69,20 +105,20 @@ export function useRSVP() { const status = statusTag ?? lStatus if (!status || !['accepted', 'declined', 'tentative'].includes(status)) return - // Update count - if (status === 'accepted') { - rsvpCounts.value.set(aTag, (rsvpCounts.value.get(aTag) ?? 0) + 1) + const entry: RSVPEntry = { + status, + eventId: event.id, + createdAt: event.created_at, } - // Update user's own RSVP + // 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, { - status, - eventId: event.id, - createdAt: event.created_at, - }) + rsvpCache.value.set(aTag, entry) } } }, @@ -92,29 +128,51 @@ export function useRSVP() { }) } + /** + * Whether a publish is currently in flight for the given activity. Bind + * to the RSVP buttons' `:disabled` so users can't queue racing clicks. + */ + function isPending(activityKind: number, pubkey: string, dTag: string): boolean { + const coord = `${activityKind}:${pubkey}:${dTag}` + return pendingCoords.value.has(coord) + } + /** * Publish an RSVP for an activity. * 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( activityKind: number, activityPubkey: string, activityDTag: string, status: RSVPStatus - ) { - if (!isAuthenticated.value || !currentUser.value?.prvkey) return + ): Promise { + if (!isAuthenticated.value || !currentUser.value?.prvkey) return null const coord = `${activityKind}:${activityPubkey}:${activityDTag}` - // Toggle: if already this status, decline instead + // 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(activityKind, activityPubkey, activityDTag) const newStatus = currentStatus === status ? 'declined' : status const dTag = `rsvp-${activityDTag}` + // 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: Math.floor(Date.now() / 1000), + created_at: createdAt, content: '', tags: [ ['d', dTag], @@ -130,15 +188,33 @@ export function useRSVP() { const signedEvent = finalizeEvent(template, signingKey) const relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB) - if (!relayHub) return + if (!relayHub) return null - const result = await relayHub.publishEvent(signedEvent) - if (result.success > 0) { - rsvpCache.value.set(coord, { + 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) } } @@ -158,6 +234,7 @@ export function useRSVP() { getMyRSVP, getRSVPCount, setRSVP, + isPending, isLoaded, loadRSVPs, } diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index 801e036..d44793d 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -14,6 +14,7 @@ import { useActivityDetail } from '../composables/useActivityDetail' import BookmarkButton from '../components/BookmarkButton.vue' import RSVPButton from '../components/RSVPButton.vue' import OrganizerCard from '../components/OrganizerCard.vue' +import { NIP52_KINDS } from '../types/nip52' const route = useRoute() const router = useRouter() @@ -137,9 +138,14 @@ function goBack() { +