webapp/docs/nostr-patterns/reactions-and-deletions.md
Padreug 8303b0981b docs(nostr): add reusable Nostr patterns reference
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>
2026-05-05 20:24:26 +02:00

2.7 KiB

Reactions, deletions & dedup

NIP-25 reactions: per-user latest, count from set size

Canonical: src/modules/base/nostr/ReactionService.tshandleReactionEvent (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.tshandleDeletionEvent. 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.