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 router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { isAuthenticated } = useAuth()
|
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 activityKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||||
const myStatus = computed(() => getMyRSVP(activityKind.value, props.pubkey, props.dTag))
|
const myStatus = computed(() => getMyRSVP(activityKind.value, props.pubkey, props.dTag))
|
||||||
const goingCount = computed(() => getRSVPCount(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 }[] = [
|
const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
|
||||||
{ status: 'accepted', labelKey: 'activities.detail.going', icon: Check },
|
{ 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 },
|
{ 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) {
|
if (!isAuthenticated.value) {
|
||||||
toast.info('Log in to RSVP', {
|
toast.info('Log in to RSVP', {
|
||||||
action: {
|
action: {
|
||||||
|
|
@ -40,7 +47,14 @@ function handleClick(status: RSVPStatus) {
|
||||||
})
|
})
|
||||||
return
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -51,6 +65,7 @@ function handleClick(status: RSVPStatus) {
|
||||||
v-for="btn in buttons"
|
v-for="btn in buttons"
|
||||||
:key="btn.status"
|
:key="btn.status"
|
||||||
:variant="myStatus === btn.status ? 'default' : 'outline'"
|
:variant="myStatus === btn.status ? 'default' : 'outline'"
|
||||||
|
:disabled="pending"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 gap-1.5"
|
class="flex-1 gap-1.5"
|
||||||
@click="handleClick(btn.status)"
|
@click="handleClick(btn.status)"
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,41 @@ interface RSVPEntry {
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache: activityCoord -> user's RSVP status
|
// Cache: activityCoord -> user's own (latest) RSVP entry
|
||||||
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
|
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
|
||||||
// Cache: activityCoord -> count of RSVPs from all users
|
// Cache: activityCoord -> (pubkey -> latest RSVP entry from that pubkey).
|
||||||
const rsvpCounts = ref<Map<string, number>>(new Map())
|
// 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)
|
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() {
|
export function useRSVP() {
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
let unsubscribe: (() => void) | null = null
|
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 {
|
function getRSVPCount(activityKind: number, pubkey: string, dTag: string): number {
|
||||||
const coord = `${activityKind}:${pubkey}:${dTag}`
|
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
|
const status = statusTag ?? lStatus
|
||||||
if (!status || !['accepted', 'declined', 'tentative'].includes(status)) return
|
if (!status || !['accepted', 'declined', 'tentative'].includes(status)) return
|
||||||
|
|
||||||
// Update count
|
const entry: RSVPEntry = {
|
||||||
if (status === 'accepted') {
|
|
||||||
rsvpCounts.value.set(aTag, (rsvpCounts.value.get(aTag) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user's own RSVP
|
|
||||||
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,
|
status,
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
createdAt: event.created_at,
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -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.
|
* Publish an RSVP for an activity.
|
||||||
* Clicking the same status again removes the RSVP (publishes 'declined').
|
* 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(
|
async function setRSVP(
|
||||||
activityKind: number,
|
activityKind: number,
|
||||||
activityPubkey: string,
|
activityPubkey: string,
|
||||||
activityDTag: string,
|
activityDTag: string,
|
||||||
status: RSVPStatus
|
status: RSVPStatus
|
||||||
) {
|
): Promise<RSVPStatus | null> {
|
||||||
if (!isAuthenticated.value || !currentUser.value?.prvkey) return
|
if (!isAuthenticated.value || !currentUser.value?.prvkey) return null
|
||||||
|
|
||||||
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
|
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 currentStatus = getMyRSVP(activityKind, activityPubkey, activityDTag)
|
||||||
const newStatus = currentStatus === status ? 'declined' : status
|
const newStatus = currentStatus === status ? 'declined' : status
|
||||||
|
|
||||||
const dTag = `rsvp-${activityDTag}`
|
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 = {
|
const template: EventTemplate = {
|
||||||
kind: NIP52_KINDS.RSVP,
|
kind: NIP52_KINDS.RSVP,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: createdAt,
|
||||||
content: '',
|
content: '',
|
||||||
tags: [
|
tags: [
|
||||||
['d', dTag],
|
['d', dTag],
|
||||||
|
|
@ -130,15 +188,33 @@ export function useRSVP() {
|
||||||
const signedEvent = finalizeEvent(template, signingKey)
|
const signedEvent = finalizeEvent(template, signingKey)
|
||||||
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
if (!relayHub) return
|
if (!relayHub) return null
|
||||||
|
|
||||||
|
pendingCoords.value.add(coord)
|
||||||
|
try {
|
||||||
const result = await relayHub.publishEvent(signedEvent)
|
const result = await relayHub.publishEvent(signedEvent)
|
||||||
if (result.success > 0) {
|
if (!result || result.success <= 0) {
|
||||||
rsvpCache.value.set(coord, {
|
// 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,
|
status: newStatus,
|
||||||
eventId: signedEvent.id,
|
eventId: signedEvent.id,
|
||||||
createdAt: signedEvent.created_at,
|
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,
|
getMyRSVP,
|
||||||
getRSVPCount,
|
getRSVPCount,
|
||||||
setRSVP,
|
setRSVP,
|
||||||
|
isPending,
|
||||||
isLoaded,
|
isLoaded,
|
||||||
loadRSVPs,
|
loadRSVPs,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { useActivityDetail } from '../composables/useActivityDetail'
|
||||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||||
import RSVPButton from '../components/RSVPButton.vue'
|
import RSVPButton from '../components/RSVPButton.vue'
|
||||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||||
|
import { NIP52_KINDS } from '../types/nip52'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -137,9 +138,14 @@ function goBack() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RSVP -->
|
<!-- 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
|
<RSVPButton
|
||||||
:pubkey="activity.organizer.pubkey"
|
:pubkey="activity.organizer.pubkey"
|
||||||
:d-tag="activity.id"
|
:d-tag="activity.id"
|
||||||
|
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Organizer -->
|
<!-- Organizer -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue