Living reference at docs/nostr-patterns/ that future Claude Code sessions
(per memory directive) and human contributors must consult before writing
Nostr code, and update when implementing or refining patterns.
Six topic files covering 18 patterns harvested from existing modules
(activities, base, forum, market, chat, tasks, nostr-feed):
- subscriptions.md — RelayHub lifecycle, EOSE, visibility-aware
reconnect, per-event-id dedup
- replaceable-events.md — monotonic created_at, per-pubkey latest-wins,
replaceable-list rewrite, Vue 3 nested ref<Map>
reactivity gotcha
- publishing.md — result.success > 0 checks, optimistic-on-success,
pending-coord debounce, finalizeEvent with bytes
- reactions-and-deletions.md — NIP-25 toggle-as-delete, NIP-09 pubkey
check, dedup-before-mutate
- profiles.md — kind-0 batch fetch with request dedup,
unsubscribe-on-EOSE for snapshot fetches
- services-and-di.md — BaseService lifecycle, injectService vs
tryInjectService, expose state via getters
Each pattern points at a canonical implementation (file:line) and notes
the *why* behind each pattern so a new caller doesn't trip on the same
edge case the canonical implementation already learned about.
Recurring deep-dive issue (#42) tracks mining patterns from Coracle,
Snort, NoStrudel, Damus, Habla, Highlighter, Flotilla, Zap.cooking, NDK
that we haven't reinvented yet — findings land in this directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2.7 KiB
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:
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 below.
NIP-09 deletion handler
Canonical: src/modules/base/nostr/ReactionService.ts —
handleDeletionEvent. Validates deletion.pubkey === eventToDelete.pubkey
before removing.
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) 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
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
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.