Compare commits

..

No commits in common. "8303b0981b23469ff7088d83201bd0f79d10c4cd" and "442a755a51900ca6dd0eafb49107ef016d37caac" have entirely different histories.

10 changed files with 27 additions and 682 deletions

View file

@ -1,66 +0,0 @@
# Nostr patterns
Living reference for reusable Nostr patterns that show up across modules
(activities, forum, market, chat, tasks, base, nostr-feed).
**Read before writing any new Nostr code in this repo.** **Update whenever you
introduce, refine, or correct a pattern.** Each section has a "Canonical
implementation" line — that's the file the pattern was harvested from. If a
caller deviates, document why or align with the canonical version.
The single biggest reason this directory exists: Nostr's edge cases (relay
dedup, replaceable events, reactivity-of-nested-Map, EOSE timing) are subtle
enough that re-deriving them per module produces different bugs each time.
Consolidating the *resolution* prevents that.
## Index
- [**Subscriptions & lifecycle**](./subscriptions.md) — RelayHub usage, EOSE,
unsubscribe on unmount, visibility-aware reconnect.
- [**Replaceable events**](./replaceable-events.md) — NIP-01 §13 semantics,
monotonic `created_at`, per-pubkey latest-wins state, replaceable bookmark
lists.
- [**Publishing & confirmation**](./publishing.md) — `RelayHub.publishEvent`
result checks, optimistic updates, signing with `nostr-tools`, pending-coord
debounce.
- [**Reactions, deletions & dedup**](./reactions-and-deletions.md) — NIP-25
toggle, NIP-09 deletion handling, per-event-id dedup.
- [**Profiles & batch fetch**](./profiles.md) — kind-0 caching, request dedup.
- [**Services, DI & reactivity**](./services-and-di.md) — `BaseService`,
`tryInjectService`, exposing service state via computed, Vue 3 nested
ref-Map reactivity gotchas.
Patterns specific to a single NIP that doesn't repeat across modules
(currently: NIP-59 gift-wrapped market orders) are not listed here yet — only
the cross-cutting patterns. When a second consumer adopts an NIP-specific
pattern, promote it.
## Conventions
- **Canonical implementation** is a single file path with line numbers. If a
pattern lives in multiple modules, list one canonical and the rest as
"alternates" with a short note on what differs.
- **Why** sections explain the failure mode the pattern prevents — not just
what it does. If the why is obvious from the code, omit it.
- **Cross-link** by file when patterns compose (e.g. replaceable events almost
always pair with the pending-coord debounce).
- Don't duplicate code into the docs. Reference file:line and quote at most
the 5-line core. Drift between code and docs is the failure mode.
## Updating
When you implement a new pattern (or fix a subtle bug in an existing one):
1. Add or amend the relevant topic file — leave a brief "added 2026-MM-DD,
from <module>" note if it helps trace provenance.
2. If it's a brand-new topic, add a section to this README's index and create
a new topic file.
3. If the pattern obsoletes an alternate implementation listed here, either
align that implementation or note explicitly why it diverges.
## Improving
We periodically deep-dive into well-known open-source Nostr apps (Coracle,
Snort, Damus, NoStrudel, Habla, Highlighter, Flotilla, Zap.cooking) to mine
patterns we haven't reinvented yet. Tracked as a recurring issue on Forgejo
(`aiolabs/webapp`). Findings land here.

View file

@ -1,64 +0,0 @@
# Profiles & batch fetch
## One subscription, many pubkeys, request-dedup before subscribe
**Canonical:** `src/modules/base/nostr/ProfileService.ts`
`fetchProfiles(pubkeys)` + the `requestedProfiles: Set<string>` it consults
before adding to the in-flight filter.
```ts
const toFetch = pubkeys.filter(pk => !requestedProfiles.has(pk) && !profiles.has(pk))
if (toFetch.length === 0) return
toFetch.forEach(pk => requestedProfiles.add(pk))
const sub = relayHub.subscribe({
filters: [{ kinds: [0], authors: toFetch }],
onEvent: …,
onEose: () => sub.unsubscribe(),
})
```
**Why both checks:**
- `profiles.has(pk)` skips pubkeys already cached.
- `requestedProfiles.has(pk)` skips pubkeys with a fetch *in flight* — two
components rendering at the same time both call `fetchProfiles([pk])`
without coordinating, and you'd otherwise open two redundant subscriptions
for the same author.
Reset `requestedProfiles` if the subscription errors / the pubkey didn't
arrive within EOSE — otherwise a transient failure leaves the pubkey
permanently un-fetchable until a session restart.
## Kind-0 is replaceable per `(kind=0, pubkey)`, keep latest by `created_at`
The cache update path looks the same as everywhere else replaceable events
appear: compare incoming `created_at` to cached and skip if older.
**Caller pattern:** `src/modules/base/composables/useProfiles.ts` exposes a
read-only `getProfile(pubkey)` returning the cached metadata (or null while
loading). Trigger fetches in `onMounted` for any pubkey the component
needs, including in lists — the request-dedup makes it cheap.
Don't re-fetch on every render; cache invalidation here is "until the user
publishes a new kind-0", which the live subscription handles automatically.
## One-shot fetch: unsubscribe inside `onEose`
When you're filling a finite cache (profile metadata, kind-3 contact list,
kind-10000-series replaceable lists), the live tail of the subscription has
no value — you only care about the backfill. Unsubscribe in `onEose` to
free the slot on the relay and the closure on the client.
```ts
let sub: { unsubscribe: () => void }
sub = relayHub.subscribe({
filters: …,
onEvent: handleProfile,
onEose: () => sub.unsubscribe(),
})
```
Compare with RSVPs / reactions, where you DO want the live tail (other
users' reactions arriving after EOSE). Pick based on the question being
asked: "what is X right now?" → unsubscribe on EOSE; "what does X look like
over time?" → keep open.

View file

@ -1,94 +0,0 @@
# Publishing & confirmation
## Treat `result.success === 0` as failure, not success
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`if (!result || result.success <= 0) return null`.
```ts
const result = await relayHub.publishEvent(signedEvent)
if (!result || result.success <= 0) return null // no relay accepted
// only now mutate local cache
```
**Why:** `RelayHub.publishEvent` returns `{ success: N, total: M }`.
`success > 0` means at least one relay sent OK. `success === 0` means every
connected relay rejected (rate limit, signature failure, or the relay was
just down). Updating local state on a 0-success publish leaves the UI ahead
of every relay — on next refresh the user sees their action has vanished.
Some implementations (`relay-hub.ts` ~530551) throw on `success === 0`
instead. Either contract works; pick one and be consistent within a
composable. Don't write code that silently treats both as success.
## Optimistic-on-success, not optimistic-on-click
**Canonical:** `src/modules/activities/composables/useRSVP.ts` — local
cache update after the `await` resolves with `success > 0`, before the
relay echoes the event back through the subscription.
The window we're closing is *between* "publish succeeded" and "subscription
delivers the event back to us". That window can be 50ms or 5s depending on
relays. Updating the cache there gives instant UI feedback without lying:
if the publish actually fails, the cache stays untouched and the UI never
showed the false state.
Avoid pre-publish optimistic updates ("flip the button on click, roll back
on failure"). Rollback is jarring, and on slow connections users see the
button flip twice.
## Pending-coord debounce: disable the button during in-flight publish
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`pendingCoords: ref<Set<string>>` + `isPending(...)` predicate +
`try { … } finally { pendingCoords.value.delete(coord) }`.
```ts
if (pendingCoords.value.has(coord)) return null // throttle
pendingCoords.value.add(coord)
try {
await publish…
} finally {
pendingCoords.value.delete(coord)
}
```
Bind `:disabled="pending"` on the button. This pairs with the
[monotonic `created_at`](./replaceable-events.md#strictly-monotonic-created_at-per-coord) — together they make a series of rapid clicks well-defined: one in flight at a
time, each strictly newer than the last.
**Why finally:** if the publish throws, you must still release the lock or
the button is stuck disabled forever.
**Why per-coord, not global:** the user might click RSVP on activity A
while a previous publish on activity B is still flying. A global
`isPending` would block that.
## Sign with `nostr-tools.finalizeEvent`, take privkey as bytes
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`hexToUint8Array` helper + `finalizeEvent(template, signingKey)`.
`finalizeEvent` expects a `Uint8Array`, not a hex string. Several composables
duplicate the hex→bytes helper inline; centralize when you have a third
caller (the duplication is fine for two).
**Validate before signing:** check `currentUser.value?.prvkey` is set, and
guard against logging the privkey or its derived bytes anywhere
(intentionally or via `console.log(JSON.stringify(...))`). Keys never leave
the auth service except into `finalizeEvent` arguments.
If the project ever gains NIP-07/NIP-46 support, the signing call site is
where you'd branch on auth method. Until then, prvkey-in-memory is the only
path.
## Publish-only-once vs publish-to-many-relays
`RelayHub.publishEvent` fans out to all connected healthy relays. Don't
loop in user code; the hub handles the fan-out, dedup, and timeout. The
return aggregates outcomes (`{ success, total }`).
If you need to target a specific relay (rare — usually for migration or
a custom personal relay), use the relay's lower-level API directly and
skip the hub. Document why in a comment, because it breaks the visibility/
restoration story above.

View file

@ -1,67 +0,0 @@
# Reactions, deletions & dedup
## NIP-25 reactions: per-user latest, count from set size
**Canonical:** `src/modules/base/nostr/ReactionService.ts`
`handleReactionEvent` (incoming) + `recalculateEventReactions` (count derive).
The same per-pubkey latest-wins pattern as
[replaceable RSVPs](./replaceable-events.md#per-pubkey-latest-wins-state-for-derived-counts):
each `target_event_id → Map<pubkey, latestReaction>`. Counts are derived
from `latestReactionsByUser.size`, not by summing every kind-7 event seen.
**Toggle-as-delete (not toggle-as-opposite):** unliking publishes a NIP-09
deletion (kind 5) referencing the original kind-7 event, not a kind-7 with
content `'-'`. Some clients use the latter pattern; align with the
canonical implementation here so the deletion handler downstream actually
removes the reaction from the count.
**Pair with** [deletion handling](#nip-09-deletion-handler) below.
## NIP-09 deletion handler
**Canonical:** `src/modules/base/nostr/ReactionService.ts`
`handleDeletionEvent`. Validates `deletion.pubkey === eventToDelete.pubkey`
before removing.
```ts
if (deletion.pubkey !== eventToDelete.pubkey) return // not authorized
// remove from caches, recalculate counts
```
**Why the pubkey check:** without it, anyone can publish a kind-5 event
referencing anyone else's event id and trick the UI into hiding it locally.
Relays should enforce this server-side; clients must not assume they did.
Deletions are NOT replaceable — the same deletion event id may arrive
multiple times across reconnects. Track processed deletion event ids in a
bounded Set (same as
[the dedup pattern](./subscriptions.md#per-incoming-event-dedup)) so the
"recalculate counts" path runs only once per deletion.
Forum submissions / comments use the same handler shape in
`src/modules/forum/services/SubmissionService.ts`.
## Skip-by-event-id dedup before any state mutation
```ts
function handleIncomingEvent(event) {
if (seenEventIds.has(event.id)) return
seenEventIds.add(event.id)
// … process …
}
```
This is the first line of every `onEvent` callback. It's cheap (Set lookup),
covers reconnect replays, and makes downstream logic idempotent. The
[per-incoming-event dedup](./subscriptions.md#per-incoming-event-dedup)
section in subscriptions has the bounded-Set caveat.
**Don't conflate** event-id dedup (this section) with replaceable-event
dedup (`(kind, pubkey, d-tag)` key). They handle different failure modes:
- Event-id dedup: same event delivered twice via different paths → process
once.
- Replaceable dedup: different events superseding each other → keep latest.
A reaction handler does both: skip if event id seen, else upsert by
`(target_event_id, pubkey)` keeping latest by `created_at`.

View file

@ -1,89 +0,0 @@
# Replaceable events (NIP-01 §13)
Replaceable kinds (1000019999) are keyed by `(kind, pubkey)`; addressable
replaceable kinds (3000039999) by `(kind, pubkey, d-tag)`. The relay keeps
only the event with the highest `created_at` for that key. Almost everything
in this file follows from that single fact.
## Strictly-monotonic `created_at` per coord
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`lastPublishAt` map + the `Math.max(now, previous + 1)` line.
```ts
const lastPublishAt = new Map<string, number>()
const now = Math.floor(Date.now() / 1000)
const previous = lastPublishAt.get(coord) ?? 0
const createdAt = Math.max(now, previous + 1)
lastPublishAt.set(coord, signedEvent.created_at) // only after publish success
```
**Why:** NIP-01 `created_at` is integer seconds. Two button clicks in the
same wall-clock second produce the same timestamp; relays treat the second
event as a non-newer replacement and silently drop it. The publish appears
to succeed (websocket ACK), but the relay never updates the canonical
state. Bumping past `previous + 1` guarantees each click is strictly newer
than the last click on the same coord.
**Pair with** the [pending-coord debounce](./publishing.md#pending-coord-debounce-disable-the-button-during-in-flight-publish) — together they make rapid-click sequences both well-ordered and rate-limited.
## Per-pubkey latest-wins state for derived counts
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`rsvpStates: ref<Map<coord, Map<pubkey, RSVPEntry>>>` + `upsertRSVPState` +
`getRSVPCount` (count entries where status === 'accepted').
**Why a flat counter is wrong:** every replaceable event your subscription
sees may be a *replacement* of a previous one from the same pubkey. A
naive `count++` per accepted event:
- double-counts when the relay echoes the same RSVP again (reconnect, dup
delivery)
- never decrements when a pubkey flips from accepted → tentative
- never decrements when a pubkey deletes their RSVP via NIP-09
Per-pubkey latest-wins gives a correct count derived from current state.
Same shape works for: RSVPs, reaction tallies (`ReactionService`
`recalculateEventReactions` from `latestReactionsByUser`), poll responses,
any "who's currently in state X" question.
## Replaceable list, full-rewrite on toggle
**Canonical:** `src/modules/activities/composables/useBookmarks.ts`
NIP-51 kind 10003 bookmark list.
For replaceable lists (10003 bookmarks, 10000 mute list, 10006 communities,
etc.), don't try to compute a delta and merge — fetch latest with `limit: 1`,
mutate the in-memory array, then publish the *whole list* as one event.
Simpler than diff-merge, and there's nothing to diff against because the
event is a complete replacement.
Confirm `result.success > 0` before updating the local set — a failed
publish leaves the in-memory list ahead of the relay's view, which silently
diverges on next refresh.
## Vue 3 reactivity for nested `ref<Map>`
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`upsertRSVPState` (the `rsvpStates.value.set(coord, inner)` after mutating
`inner`).
```ts
function upsertRSVPState(coord, pubkey, entry) {
let inner = rsvpStates.value.get(coord) ?? new Map()
inner.set(pubkey, entry)
rsvpStates.value.set(coord, inner) // ← required to notify dependents
}
```
**Why:** Vue 3's reactive proxy on `ref<Map>` notifies on mutations of the
*outer* Map (`set`/`delete`). It does NOT proxy through to nested values.
`computed(() => outer.get(k).size)` will read once, cache, and never re-run
when the inner Map mutates. Re-`set`ting on the outer is the cheap fix.
Same caveat applies to nested `ref<Set>`, `ref<Map<_, Array>>`, etc. If you
have one level of nesting, this trick suffices; if you have two levels,
you'll need to re-set up the chain or use `reactive()` instead of `ref()`
for the whole structure.

View file

@ -1,94 +0,0 @@
# Services, DI & reactivity
## `BaseService` lifecycle: `initialize` / `onInitialize` / `onDestroy`
**Canonical:** `src/core/base/BaseService.ts` (the abstract class) +
`src/modules/base/nostr/ReactionService.ts` (a representative concrete
service).
- Constructor: do nothing async, register no subscriptions. Just stash
config.
- `onInitialize()` (override): grab dependencies via `injectService(...)`,
open relay subscriptions, set up timers.
- `onDestroy()`: unsubscribe everything, clear caches. Anything you started
in `onInitialize` you tear down here.
**Why not in the constructor:** services are constructed before all
dependencies are registered; `injectService(SOME_TOKEN)` from the
constructor will throw or return undefined non-deterministically depending
on registration order. `onInitialize` runs after the DI container is
ready.
Always call `super.initialize()` if you override `initialize()` instead of
`onInitialize()`. Most concrete services override only `onInitialize` and
leave `initialize()` alone.
## `injectService` vs `tryInjectService`
**Canonical:** `src/core/di-container.ts``SERVICE_TOKENS` registry +
both inject helpers.
- `injectService<T>(TOKEN)`: throws if not registered. Use in services
that **require** the dependency to function.
- `tryInjectService<T>(TOKEN)`: returns `undefined` if not registered. Use
in composables and components that should degrade gracefully (e.g.,
rendering an offline empty state when RelayHub isn't up yet).
The composable in `useRSVP.ts` uses `tryInjectService(RELAY_HUB)` and
returns null from `setRSVP` if the hub is missing. That's the right call
for user-facing buttons: don't crash, just no-op. A service that *needs*
the hub to do its job (`ReactionService.initialize`) uses the throwing
variant so the failure is loud at startup.
## Expose service state via getters / computed, never as mutable refs
**Canonical:** `src/modules/base/nostr/ReactionService.ts``eventReactions`
getter, `isLoading` getter; `src/modules/base/nostr/ProfileService.ts`
`profiles` getter.
```ts
class ReactionService extends BaseService {
private _state = reactive(new Map<…>()) // private, mutable
get eventReactions() { return this._state } // public, read-only by convention
// Mutation through guarded methods only
async likeEvent(...) { … }
async unlikeEvent(...) { … }
}
```
The Vue reactivity stays correct because we hand out the same reactive
object — components reading `service.eventReactions` get a reactive
reference. They're trusted not to mutate it; the type system can enforce
that with `readonly` modifiers if you want to be strict.
**Why not a public field:** anything mutating the cache from outside the
service can produce events that don't get published, or skip
`recalculateEventReactions`. Keep mutation paths inside the service so the
invariants are enforced in one place.
## Vue 3 nested `ref<Map>` reactivity gotcha
Covered fully in
[replaceable-events.md → Vue 3 reactivity for nested ref-Map](./replaceable-events.md#vue-3-reactivity-for-nested-refmap).
Cross-linked here because it bites every service that exposes a
`Map<key1, Map<key2, value>>` shape (not common, but real — RSVPs are the
main one).
The `reactive(new Map())` route avoids the gotcha because `reactive`
recursively wraps. `ref(new Map())` doesn't. Either is fine for top-level;
prefer `reactive` if you have nesting.
## Service singletons, cleanup is real
Services are registered once per app lifetime. There's no "destroy on
component unmount" — components come and go, the service persists.
This means: any subscription a service opens must be cleaned up in
`onDestroy()`, which only runs at app teardown. Don't open *per-component*
subscriptions from a service — that's the composable's job (with
`onUnmounted` cleanup). Service-level subscriptions are session-long.
If you find yourself wanting a "subscribe to X just for this view"
pattern inside a service, you've crossed the layer boundary. Move it to a
composable that consumes the service.

View file

@ -1,83 +0,0 @@
# Subscriptions & lifecycle
## Subscribe, store the unsubscribe handle, clean up on unmount
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`loadRSVPs()` (subscribe block) + the matching `onUnmounted(() => unsubscribe?.())`.
```ts
let unsubscribe: (() => void) | null = null
unsubscribe = relayHub.subscribe({
id: `rsvps-${Date.now()}`,
filters: [{ kinds: [NIP52_KINDS.RSVP], limit: 500 }],
onEvent: …,
onEose: () => { isLoaded.value = true },
})
onUnmounted(() => unsubscribe?.())
```
**Why:**
- The subscription ID must be unique per mount. Reusing a static id (e.g.
`'rsvps'`) causes RelayHub to return the *previous* subscription on rapid
re-mount, which leaks the old `onEvent` closure or skips the new one
entirely. `Date.now()` (or a uuid) suffices.
- Forgetting `onUnmounted` cleanup leaks subscriptions across route changes;
the relay keeps streaming events into a closure that updates a stale ref.
**Alternate implementation:** `src/modules/base/composables/useProfiles.ts`
uses the same shape; differs only in subscription id construction. If you
diverge, do it because the lifetime of the subscription differs (e.g.
session-long vs view-long), not by accident.
## EOSE means "backfill done", not "all events delivered"
**Canonical:** `src/modules/activities/composables/useRSVP.ts`
`onEose: () => { isLoaded.value = true }`.
`onEose` fires once, after the relay flushes everything stored that matches
the filter. Live events keep arriving on `onEvent` afterwards. Use the EOSE
flag to drop a "loading…" placeholder and let *partial* results render
during backfill — don't block the UI on EOSE.
If you want a one-shot snapshot (fetch profiles, build a one-time list), it
*is* fine to call `unsubscribe()` from inside `onEose` — see
`src/modules/base/nostr/ProfileService.ts` (the post-EOSE cleanup path).
## Visibility-aware reconnect
**Canonical:** `src/modules/base/nostr/relay-hub.ts`
`registerWithVisibilityService` + `handleResume` / `handlePause` +
`restoreSubscriptions`.
When the tab is hidden / device sleeps, the websocket drops. RelayHub closes
stale subscriptions on pause and replays their `Filter` configs against
healthy relays on resume. Consumers don't need to do anything — the
subscription you registered survives the reconnect.
**Why callers should know this:** subscriptions don't transparently
guarantee replay. If you stash event IDs in a local `Set` to dedupe and the
tab is hidden for an hour, on resume the relay may re-stream events from
before the dedupe set existed (or after the set was reset). Treat the
dedupe state as best-effort, not authoritative — pair with the
`is_duplicate_event` style check on every incoming event regardless.
## Per-incoming-event dedup
When subscribing to an actively-published kind (reactions, RSVPs, market
orders), the relay can deliver the same event ID more than once across
reconnects, multiple connected relays, or filter overlaps. The cure is a
bounded `Set<eventId>` checked on every `onEvent`.
**Canonical:** `src/modules/base/nostr/ReactionService.ts``seenEventIds`
check at the top of `handleReactionEvent`.
**Why bounded:** the set otherwise grows unboundedly across a long session.
Use an `OrderedDict`-style eviction (insert order = oldest first) and cap at
~10k entries — events older than that are not going to come back through
the same subscription's lifetime in any realistic flow.
The events extension's Python `nostr/nostr_client.py` has the same pattern
(`is_duplicate_event` over an `OrderedDict`) for the server-side
subscription. If you build a new subscription consumer, copy the cap.

View file

@ -18,12 +18,11 @@ const props = defineProps<{
const router = useRouter()
const { t } = useI18n()
const { isAuthenticated } = useAuth()
const { getMyRSVP, getRSVPCount, setRSVP, isPending } = useRSVP()
const { getMyRSVP, getRSVPCount, setRSVP } = 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 },
@ -31,13 +30,7 @@ const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
{ status: 'declined', labelKey: 'activities.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) {
function handleClick(status: RSVPStatus) {
if (!isAuthenticated.value) {
toast.info('Log in to RSVP', {
action: {
@ -47,14 +40,7 @@ async function handleClick(status: RSVPStatus) {
})
return
}
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")
}
setRSVP(activityKind.value, props.pubkey, props.dTag, status)
}
</script>
@ -65,7 +51,6 @@ async 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,41 +19,12 @@ interface RSVPEntry {
createdAt: number
}
// Cache: activityCoord -> user's own (latest) RSVP entry
// Cache: activityCoord -> user's RSVP status
const rsvpCache = ref<Map<string, RSVPEntry>>(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())
// Cache: activityCoord -> count of RSVPs from all users
const rsvpCounts = ref<Map<string, number>>(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
@ -67,18 +38,11 @@ export function useRSVP() {
}
/**
* RSVP count for an activity = number of pubkeys whose latest RSVP for
* this activity has status 'accepted'.
* Get RSVP count for an activity.
*/
function getRSVPCount(activityKind: number, pubkey: string, dTag: string): number {
const coord = `${activityKind}:${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
return rsvpCounts.value.get(coord) ?? 0
}
/**
@ -105,20 +69,20 @@ export function useRSVP() {
const status = statusTag ?? lStatus
if (!status || !['accepted', 'declined', 'tentative'].includes(status)) return
const entry: RSVPEntry = {
status,
eventId: event.id,
createdAt: event.created_at,
// Update count
if (status === 'accepted') {
rsvpCounts.value.set(aTag, (rsvpCounts.value.get(aTag) ?? 0) + 1)
}
// Per-pubkey latest-wins state — drives the count.
upsertRSVPState(aTag, event.pubkey, entry)
// User's own RSVP cache (used by getMyRSVP).
// 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, entry)
rsvpCache.value.set(aTag, {
status,
eventId: event.id,
createdAt: event.created_at,
})
}
}
},
@ -128,51 +92,29 @@ 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
): Promise<RSVPStatus | null> {
if (!isAuthenticated.value || !currentUser.value?.prvkey) return null
) {
if (!isAuthenticated.value || !currentUser.value?.prvkey) return
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
// 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.
// 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: createdAt,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['d', dTag],
@ -188,33 +130,15 @@ export function useRSVP() {
const signedEvent = finalizeEvent(template, signingKey)
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return null
if (!relayHub) return
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 = {
const result = await relayHub.publishEvent(signedEvent)
if (result.success > 0) {
rsvpCache.value.set(coord, {
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)
})
}
}
@ -234,7 +158,6 @@ export function useRSVP() {
getMyRSVP,
getRSVPCount,
setRSVP,
isPending,
isLoaded,
loadRSVPs,
}

View file

@ -14,7 +14,6 @@ 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()
@ -138,14 +137,9 @@ 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 -->