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

67 lines
2.7 KiB
Markdown

# 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`.