Compare commits
2 commits
442a755a51
...
8303b0981b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8303b0981b | |||
| c734f04e96 |
10 changed files with 682 additions and 27 deletions
66
docs/nostr-patterns/README.md
Normal file
66
docs/nostr-patterns/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# 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.
|
||||||
64
docs/nostr-patterns/profiles.md
Normal file
64
docs/nostr-patterns/profiles.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# 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.
|
||||||
94
docs/nostr-patterns/publishing.md
Normal file
94
docs/nostr-patterns/publishing.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# 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<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.
|
||||||
67
docs/nostr-patterns/reactions-and-deletions.md
Normal file
67
docs/nostr-patterns/reactions-and-deletions.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# 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`.
|
||||||
89
docs/nostr-patterns/replaceable-events.md
Normal file
89
docs/nostr-patterns/replaceable-events.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# 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<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.
|
||||||
94
docs/nostr-patterns/services-and-di.md
Normal file
94
docs/nostr-patterns/services-and-di.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# 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.
|
||||||
83
docs/nostr-patterns/subscriptions.md
Normal file
83
docs/nostr-patterns/subscriptions.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -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