Files
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

5.3 KiB

name, description
name description
nostr-publishing 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.

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:

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:

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.

// 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.