diff --git a/docs/nostr-patterns/README.md b/docs/nostr-patterns/README.md new file mode 100644 index 0000000..b69da5a --- /dev/null +++ b/docs/nostr-patterns/README.md @@ -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 " 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 new file mode 100644 index 0000000..4f5bede --- /dev/null +++ b/docs/nostr-patterns/profiles.md @@ -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` 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 new file mode 100644 index 0000000..3fa9f4a --- /dev/null +++ b/docs/nostr-patterns/publishing.md @@ -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>` + `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 new file mode 100644 index 0000000..495f5b7 --- /dev/null +++ b/docs/nostr-patterns/reactions-and-deletions.md @@ -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`. 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 new file mode 100644 index 0000000..602a623 --- /dev/null +++ b/docs/nostr-patterns/replaceable-events.md @@ -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() + +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 new file mode 100644 index 0000000..f9abc79 --- /dev/null +++ b/docs/nostr-patterns/services-and-di.md @@ -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(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 new file mode 100644 index 0000000..5921351 --- /dev/null +++ b/docs/nostr-patterns/subscriptions.md @@ -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` 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.