bd68a32708
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.
116 lines
5.3 KiB
Markdown
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.
|