Files
eranos/.agents/skills/nostr-publishing/SKILL.md
T
Alex Gleason bd68a32708 Split AGENTS.md into skills; compress to 358 lines
Extract eleven topic areas into loadable skills so AGENTS.md can serve
as a scannable overview instead of a specification dump. The file
shrinks from 1480 to 358 lines (~76%) while keeping every concrete
rule, critical code pattern, and pointer that an agent needs on first
read.

New Ditto-specific skills:
- nostr-kinds: NIP-vs-custom-kind decision framework, kind ranges,
  tag design, content-vs-tags, NIP.md update rule, and Ditto's
  seven-location UI registration checklist for new kinds (NoteCard,
  PostDetailPage, extraKinds.ts, KIND_LABELS/KIND_ICONS in
  CommentContext, WELL_KNOWN_KIND_LABELS in ExternalContentHeader,
  EmbeddedNote/EmbeddedNaddr, ReplyComposeModal).
- nostr-publishing: useNostrPublish, the read-modify-write pattern
  via fetchFreshEvent + prev for replaceable/addressable events,
  published_at contract, and d-tag collision prevention.
- nostr-queries: the standard useNostr + useQuery pattern,
  combining kinds into one filter to avoid rate limits, and the
  NIP-52 validator walkthrough.
- theming: @fontsource install flow, the Ditto runtime font-loader
  path (sanitizeUrl + sanitizeCssString), color scheme variables,
  useTheme toggle, and the isolate + negative-z-index gotcha.
- ci-cd-publishing: Zapstore NIP-46 bunker auth (zsp +
  nip46-auth.mjs), nsite deploys (nsyte nbunksec + configured
  relays/servers), and Google Play AAB uploads via fastlane supply
  (service-account JSON base64 encoding and rotation).
- capacitor-compat: WKWebView/WebView limitations, the
  downloadTextFile / openUrl helpers in src/lib/downloadFile.ts,
  platform detection, and the full plugin list.
- git-workflow: pre-commit validation order and the Regression-of:
  trailer convention used by the release skill's changelog
  generator.

Ported from mkstack, lightly adapted where needed:
- nip19-routing: root-level /:nip19 routing and filter construction
  patterns (adapted to reference Ditto's existing NIP19Page).
- nostr-relay-pools: nostr.relay() and nostr.group() for targeted
  queries.
- nostr-encryption: NIP-44 / NIP-04 via the user's signer.
- file-uploads: useUploadFile + Blossom + NIP-94 imeta tag
  construction.

AGENTS.md itself now follows mkstack's density — concrete rules inline,
one code example per section, pointer to the matching skill for details.
The enumerations that previously bloated it (every shadcn primitive,
every hook, every Capacitor plugin, the full NostrMetadata type dump,
the NIP-19 prefix reference table, etc.) are either removed in favor
of "ls the directory" or moved into their skill.
2026-04-26 23:13:30 -05:00

116 lines
5.3 KiB
Markdown

---
name: nostr-publishing
description: Publish Nostr events with useNostrPublish. Covers the basic publishing pattern, safely mutating replaceable and addressable events (read-modify-write via fetchFreshEvent + prev), published_at preservation, and d-tag collision prevention for new addressable content.
---
# Publishing Nostr Events
Use this skill when a feature needs to publish events — notes, reactions, list updates, profile edits, addressable content, etc. Covers the `useNostrPublish` hook, the correct read-modify-write pattern for replaceable/addressable lists, and d-tag collision prevention.
## The `useNostrPublish` Hook
`useNostrPublish` publishes an event through the app's connection pool and auto-adds a `client` tag. Always guard calls with `useCurrentUser` — publishing requires a signer.
```tsx
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
export function PostForm() {
const { user } = useCurrentUser();
const { mutate: createEvent } = useNostrPublish();
if (!user) return <span>You must be logged in to post.</span>;
return (
<button onClick={() => createEvent({ kind: 1, content: 'hello' })}>
Post
</button>
);
}
```
Prefer `mutateAsync` over `mutate` when the caller needs to `await` the published event (e.g. to navigate to the new event's page, or to chain another publish).
## Mutating Replaceable and Addressable Events (CRITICAL)
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a **read-modify-write** cycle: fetch the current event, modify its tags, publish a new version. **Never read from the TanStack Query cache before mutating** — the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const prev = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = prev?.tags ?? [];
// ...modify tags...
await publishEvent({
kind: 10003,
content: prev?.content ?? '',
tags: newTags,
prev: prev ?? undefined,
});
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### The `prev` Property on Event Templates
`useNostrPublish` accepts an optional `prev` property on the event template — the **previous version** of the event being replaced. The hook uses it to manage the `published_at` tag (NIP-24) automatically:
- **First publish (no `prev`)** — `published_at` is set equal to `created_at`.
- **Update (`prev` provided)** — `published_at` is preserved from the old event.
- **Old event lacks `published_at`** — nothing is fabricated.
- **Caller already set `published_at` in tags** — left alone.
**Convention**: name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
```typescript
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ...
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
```
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
## D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
### When to check for collisions
- **Must check** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.).
- **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with an embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
### Implementation pattern
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
```typescript
// Before publishing a new addressable event:
const slug = slugify(title, { lower: true, strict: true });
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'Change the slug or edit the existing item.',
variant: 'destructive',
});
return;
}
// Safe to publish
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
```
**Skip the check in edit mode** — when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.