Compare commits
No commits in common. "8303b0981b23469ff7088d83201bd0f79d10c4cd" and "442a755a51900ca6dd0eafb49107ef016d37caac" have entirely different histories.
8303b0981b
...
442a755a51
10 changed files with 27 additions and 682 deletions
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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<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.
|
|
||||||
|
|
@ -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`.
|
|
||||||
|
|
@ -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<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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -18,12 +18,11 @@ 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, isPending } = useRSVP()
|
const { getMyRSVP, getRSVPCount, setRSVP } = 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 },
|
||||||
|
|
@ -31,13 +30,7 @@ const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
|
||||||
{ status: 'declined', labelKey: 'activities.detail.notGoing', icon: X },
|
{ status: 'declined', labelKey: 'activities.detail.notGoing', icon: X },
|
||||||
]
|
]
|
||||||
|
|
||||||
const statusLabel: Record<RSVPStatus, string> = {
|
function handleClick(status: RSVPStatus) {
|
||||||
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: {
|
||||||
|
|
@ -47,14 +40,7 @@ async function handleClick(status: RSVPStatus) {
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const published = await setRSVP(activityKind.value, props.pubkey, props.dTag, status)
|
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>
|
||||||
|
|
||||||
|
|
@ -65,7 +51,6 @@ async 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,41 +19,12 @@ interface RSVPEntry {
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache: activityCoord -> user's own (latest) RSVP entry
|
// Cache: activityCoord -> user's RSVP status
|
||||||
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
|
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
|
||||||
// Cache: activityCoord -> (pubkey -> latest RSVP entry from that pubkey).
|
// Cache: activityCoord -> count of RSVPs from all users
|
||||||
// NIP-52 RSVPs (kind 31925) are replaceable per (kind, pubkey, d-tag), so a
|
const rsvpCounts = ref<Map<string, number>>(new Map())
|
||||||
// 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
|
||||||
|
|
@ -67,18 +38,11 @@ export function useRSVP() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RSVP count for an activity = number of pubkeys whose latest RSVP for
|
* Get RSVP count for an activity.
|
||||||
* 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}`
|
||||||
const inner = rsvpStates.value.get(coord)
|
return rsvpCounts.value.get(coord) ?? 0
|
||||||
if (!inner) return 0
|
|
||||||
let count = 0
|
|
||||||
for (const entry of inner.values()) {
|
|
||||||
if (entry.status === 'accepted') count++
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -105,20 +69,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
|
||||||
|
|
||||||
const entry: RSVPEntry = {
|
// Update count
|
||||||
status,
|
if (status === 'accepted') {
|
||||||
eventId: event.id,
|
rsvpCounts.value.set(aTag, (rsvpCounts.value.get(aTag) ?? 0) + 1)
|
||||||
createdAt: event.created_at,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-pubkey latest-wins state — drives the count.
|
// Update user's own RSVP
|
||||||
upsertRSVPState(aTag, event.pubkey, entry)
|
|
||||||
|
|
||||||
// User's own RSVP cache (used by getMyRSVP).
|
|
||||||
if (currentUser.value?.pubkey && event.pubkey === currentUser.value.pubkey) {
|
if (currentUser.value?.pubkey && event.pubkey === currentUser.value.pubkey) {
|
||||||
const existing = rsvpCache.value.get(aTag)
|
const existing = rsvpCache.value.get(aTag)
|
||||||
if (!existing || event.created_at > existing.createdAt) {
|
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.
|
* 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 null
|
if (!isAuthenticated.value || !currentUser.value?.prvkey) return
|
||||||
|
|
||||||
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
|
const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
|
||||||
|
|
||||||
// Throttle: refuse a second click while the first is still publishing.
|
// Toggle: if already this status, decline instead
|
||||||
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: createdAt,
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
content: '',
|
content: '',
|
||||||
tags: [
|
tags: [
|
||||||
['d', dTag],
|
['d', dTag],
|
||||||
|
|
@ -188,33 +130,15 @@ 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 null
|
if (!relayHub) return
|
||||||
|
|
||||||
pendingCoords.value.add(coord)
|
|
||||||
try {
|
|
||||||
const result = await relayHub.publishEvent(signedEvent)
|
const result = await relayHub.publishEvent(signedEvent)
|
||||||
if (!result || result.success <= 0) {
|
if (result.success > 0) {
|
||||||
// No relay accepted the event — leave caches untouched so the UI
|
rsvpCache.value.set(coord, {
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,7 +158,6 @@ export function useRSVP() {
|
||||||
getMyRSVP,
|
getMyRSVP,
|
||||||
getRSVPCount,
|
getRSVPCount,
|
||||||
setRSVP,
|
setRSVP,
|
||||||
isPending,
|
|
||||||
isLoaded,
|
isLoaded,
|
||||||
loadRSVPs,
|
loadRSVPs,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ 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()
|
||||||
|
|
@ -138,14 +137,9 @@ 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