fix(activities): correct RSVP count, throttle clicks, fix kind reference

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<aTag, Map<pubkey, RSVPEntry>> — 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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-05 20:24:05 +02:00
commit c734f04e96
3 changed files with 125 additions and 27 deletions

View file

@ -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<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: {
@ -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")
}
}
</script>
@ -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)"

View file

@ -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<Map<string, RSVPEntry>>(new Map())
// Cache: activityCoord -> count of RSVPs from all users
const rsvpCounts = ref<Map<string, number>>(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<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 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<RSVPStatus | null> {
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<any>(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,
}

View file

@ -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() {
</div>
<!-- RSVP -->
<!-- The NIP-52 RSVP `a` tag must reference the activity's actual kind
(31922 for date-based, 31923 for time-based). Without this prop the
button would default to time-based for every activity, leaving RSVPs
on date-based activities pointing at a non-existent event coord. -->
<RSVPButton
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/>
<!-- Organizer -->