diff --git a/docs/nostr-patterns/README.md b/docs/nostr-patterns/README.md deleted file mode 100644 index b69da5a..0000000 --- a/docs/nostr-patterns/README.md +++ /dev/null @@ -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 " 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. diff --git a/docs/nostr-patterns/profiles.md b/docs/nostr-patterns/profiles.md deleted file mode 100644 index 4f5bede..0000000 --- a/docs/nostr-patterns/profiles.md +++ /dev/null @@ -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` 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. diff --git a/docs/nostr-patterns/publishing.md b/docs/nostr-patterns/publishing.md deleted file mode 100644 index 3fa9f4a..0000000 --- a/docs/nostr-patterns/publishing.md +++ /dev/null @@ -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` ~530–551) 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>` + `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. diff --git a/docs/nostr-patterns/reactions-and-deletions.md b/docs/nostr-patterns/reactions-and-deletions.md deleted file mode 100644 index 495f5b7..0000000 --- a/docs/nostr-patterns/reactions-and-deletions.md +++ /dev/null @@ -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`. 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`. diff --git a/docs/nostr-patterns/replaceable-events.md b/docs/nostr-patterns/replaceable-events.md deleted file mode 100644 index 602a623..0000000 --- a/docs/nostr-patterns/replaceable-events.md +++ /dev/null @@ -1,89 +0,0 @@ -# Replaceable events (NIP-01 §13) - -Replaceable kinds (10000–19999) are keyed by `(kind, pubkey)`; addressable -replaceable kinds (30000–39999) 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() - -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>>` + `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` - -**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` 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`, `ref>`, 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. diff --git a/docs/nostr-patterns/services-and-di.md b/docs/nostr-patterns/services-and-di.md deleted file mode 100644 index f9abc79..0000000 --- a/docs/nostr-patterns/services-and-di.md +++ /dev/null @@ -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(TOKEN)`: throws if not registered. Use in services - that **require** the dependency to function. -- `tryInjectService(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` 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>` 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. diff --git a/docs/nostr-patterns/subscriptions.md b/docs/nostr-patterns/subscriptions.md deleted file mode 100644 index 5921351..0000000 --- a/docs/nostr-patterns/subscriptions.md +++ /dev/null @@ -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` 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. diff --git a/src/modules/activities/components/RSVPButton.vue b/src/modules/activities/components/RSVPButton.vue index e003220..e1d0266 100644 --- a/src/modules/activities/components/RSVPButton.vue +++ b/src/modules/activities/components/RSVPButton.vue @@ -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 = { - 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) } @@ -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)" diff --git a/src/modules/activities/composables/useRSVP.ts b/src/modules/activities/composables/useRSVP.ts index 337a7ec..e02cae3 100644 --- a/src/modules/activities/composables/useRSVP.ts +++ b/src/modules/activities/composables/useRSVP.ts @@ -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>(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()) +// Cache: activityCoord -> count of RSVPs from all users +const rsvpCounts = 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 @@ -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 { - 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(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, } diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index d44793d..801e036 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -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() { -