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:
parent
442a755a51
commit
c734f04e96
3 changed files with 125 additions and 27 deletions
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue