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.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: capacitor-compat
|
||||
description: Browser-API gotchas inside Capacitor's WKWebView (iOS) and Android WebView — which common web APIs silently fail, the downloadTextFile/openUrl helpers that bridge web and native, platform detection, and the installed Capacitor plugins. Load when writing code that interacts with file downloads, external URLs, or platform-specific behavior.
|
||||
---
|
||||
|
||||
# Capacitor Compatibility
|
||||
|
||||
Ditto runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
|
||||
|
||||
## What Doesn't Work in WKWebView (iOS)
|
||||
|
||||
- **`<a download>` file downloads** — programmatically creating an anchor with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
|
||||
- **`<a target="_blank">` new tabs** — programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
|
||||
- **`window.open()`** — may be blocked or behave unexpectedly without user-gesture context.
|
||||
|
||||
For a deeper list of Apple Lockdown Mode restrictions that also affect WKWebView, load the **`lockdown-mode`** skill.
|
||||
|
||||
## File Downloads and URL Opening
|
||||
|
||||
`src/lib/downloadFile.ts` provides two utilities that handle the web/native split automatically. **Always use these** instead of manually constructing anchors.
|
||||
|
||||
### `downloadTextFile(filename, content)`
|
||||
|
||||
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
|
||||
|
||||
```typescript
|
||||
import { downloadTextFile } from '@/lib/downloadFile';
|
||||
|
||||
await downloadTextFile('backup.txt', fileContents);
|
||||
```
|
||||
|
||||
### `openUrl(url)`
|
||||
|
||||
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
|
||||
|
||||
```typescript
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
await openUrl('https://example.com/image.jpg');
|
||||
```
|
||||
|
||||
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. The utilities above work correctly on all platforms; manual anchors silently fail on iOS.
|
||||
|
||||
## Detecting Native Platforms
|
||||
|
||||
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
|
||||
|
||||
```typescript
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// iOS or Android
|
||||
} else {
|
||||
// Web browser
|
||||
}
|
||||
```
|
||||
|
||||
Reserve platform forks for cases where behavior genuinely differs (share sheets, secure storage, haptics). Most UI code should stay platform-agnostic.
|
||||
|
||||
## Installed Capacitor Plugins
|
||||
|
||||
- `@capacitor/app` — app lifecycle events (deep links, back button)
|
||||
- `@capacitor/core` — core runtime and platform detection
|
||||
- `@capacitor/filesystem` — read/write files on the native filesystem
|
||||
- `@capacitor/haptics` — native haptics
|
||||
- `@capacitor/keyboard` — keyboard control (hide accessory bar, etc.)
|
||||
- `@capacitor/local-notifications` — schedule local push notifications
|
||||
- `@capacitor/share` — native share sheet
|
||||
- `@capacitor/status-bar` — control the native status-bar style
|
||||
- `@capgo/capacitor-autofill-save-password` — iOS keychain autofill for nsec
|
||||
- `capacitor-secure-storage-plugin` — OS-level secure storage (iOS Keychain / Android KeyStore)
|
||||
|
||||
After adding or removing plugins, run `npm run cap:sync` to update the native projects.
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: ci-cd-publishing
|
||||
description: Ditto's release and publishing pipeline — cutting a version tag, Zapstore APK publishing with NIP-46 bunker auth, nsite web deploys via nsyte, and Google Play AAB uploads via fastlane supply. Includes GitLab CI variable setup and credential rotation.
|
||||
---
|
||||
|
||||
# CI/CD Pipeline and Publishing
|
||||
|
||||
Ditto uses GitLab CI (`.gitlab-ci.yml`) to run tests on every commit, deploy the web app to nsite on every default-branch push, and build + publish Android binaries to Zapstore and Google Play on every tag. Load this skill when setting up CI credentials, rotating a signing key, diagnosing a failed publish, or adding a new publishing target.
|
||||
|
||||
## Pipeline Overview
|
||||
|
||||
| Stage | Runs on | Job |
|
||||
|-----------|---------------------------|-----------------------------------------|
|
||||
| `test` | every commit (not tags) | `npm run test` |
|
||||
| `deploy` | default branch only | `deploy-nsite` (Vite build → nsyte) |
|
||||
| `build` | tags only | `build-apk` (signed release APK + AAB) |
|
||||
| `release` | tags only | GitLab Release with APK artifact |
|
||||
| `publish` | tags only | `publish-zapstore` + `publish-google-play` |
|
||||
|
||||
## Creating a Release
|
||||
|
||||
Releases are triggered by pushing a version tag:
|
||||
|
||||
```bash
|
||||
npm run release
|
||||
```
|
||||
|
||||
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` jobs.
|
||||
|
||||
For the full versioning / changelog / native-build workflow, load the **`release`** skill.
|
||||
|
||||
## Zapstore Publishing
|
||||
|
||||
The `publish-zapstore` CI job uploads signed APKs to [Zapstore](https://zapstore.dev/) using the [`zsp`](https://github.com/zapstore/zsp) CLI and NIP-46 bunker signing via Amber.
|
||||
|
||||
**Configuration files:**
|
||||
|
||||
- `zapstore.yaml` — app metadata for Zapstore (name, tags, icon, supported NIPs)
|
||||
- `.gitlab-ci.yml` — the `publish-zapstore` job definition
|
||||
|
||||
**GitLab CI/CD variables** (Settings → CI/CD → Variables):
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `ZAPSTORE_BUNKER_URL` | NIP-46 bunker URL (`bunker://<pubkey>?relay=...`). No `secret` param needed after initial auth. | Yes | No | Yes |
|
||||
| `ZAPSTORE_CLIENT_KEY` | Hex private key used as the NIP-46 client identity for bunker communication | Yes | Yes | Yes |
|
||||
| `ANDROID_KEYSTORE_BASE64` | Base64-encoded Android signing keystore | Yes | Yes | Yes |
|
||||
| `KEYSTORE_PASSWORD` | Android keystore password | Yes | Yes | Yes |
|
||||
| `KEY_PASSWORD` | Android key password | Yes | Yes | Yes |
|
||||
|
||||
### How NIP-46 bunker auth works in CI
|
||||
|
||||
NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and a **client key** (the CI runner's identity). The bunker authorizes specific client pubkeys — once authorized, the client can request signatures without re-approval.
|
||||
|
||||
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
Run the NIP-46 client-initiated auth script:
|
||||
|
||||
```bash
|
||||
node scripts/nip46-auth.mjs
|
||||
```
|
||||
|
||||
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script outputs the `bunker://` URI and client key hex, and writes the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
|
||||
|
||||
Options:
|
||||
- `--relay <url>` — relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
|
||||
- `--name <name>` — app name shown to the signer (default: `Ditto`)
|
||||
- `--timeout <sec>` — how long to wait for approval (default: 300)
|
||||
|
||||
After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs. If the client key is rotated, run the script again and update the GitLab variables.
|
||||
|
||||
## nsite Publishing
|
||||
|
||||
The `deploy-nsite` CI job deploys the Vite build to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The job uploads `dist/` to Blossom servers and publishes site manifest events to Nostr relays.
|
||||
|
||||
nsyte uses a NIP-46 bunker credential called **nbunksec** — a bech32-encoded string bundling the bunker pubkey, client secret key, and relay info into a single self-contained token. It's passed to nsyte via `--sec`.
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. Install nsyte locally:
|
||||
```bash
|
||||
curl -fsSL https://nsyte.run/get/install.sh | bash
|
||||
```
|
||||
2. Generate the CI credential:
|
||||
```bash
|
||||
nsyte ci
|
||||
```
|
||||
This guides you through connecting a NIP-46 bunker (e.g. Amber) and outputs an `nbunksec1...` string. The credential is shown only once.
|
||||
3. Add the `nbunksec1...` value as `NSITE_NBUNKSEC` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**.
|
||||
|
||||
### Configured relays and servers
|
||||
|
||||
Relays the deploy job publishes to:
|
||||
|
||||
- `wss://relay.ditto.pub`
|
||||
- `wss://relay.nsite.lol`
|
||||
- `wss://relay.dreamith.to`
|
||||
- `wss://relay.primal.net`
|
||||
|
||||
Blossom servers:
|
||||
|
||||
- `https://blossom.primal.net`
|
||||
- `https://blossom.ditto.pub`
|
||||
- `https://blossom.dreamith.to`
|
||||
|
||||
The `--use-fallback-relays` and `--use-fallback-servers` flags include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
|
||||
|
||||
### Credential rotation
|
||||
|
||||
To rotate the nsite credential:
|
||||
|
||||
1. Revoke the old bunker connection in your signer app.
|
||||
2. Run `nsyte ci` again to generate a new `nbunksec1...` string.
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings.
|
||||
|
||||
## Google Play Publishing
|
||||
|
||||
The `publish-google-play` CI job uploads Android AABs to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). It runs after a successful AAB build and uploads directly to the production track.
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON. The CI job decodes with `base64 -d` before passing to `fastlane supply`. | Yes | Yes | No |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. Create or reuse a project in [Google Cloud Console](https://console.cloud.google.com/projectcreate).
|
||||
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project.
|
||||
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it.
|
||||
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`.
|
||||
5. **Base64-encode** the key file:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
base64 -w0 service-account.json
|
||||
|
||||
# macOS
|
||||
base64 -i service-account.json | tr -d '\n'
|
||||
```
|
||||
|
||||
6. Add the base64-encoded value as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**. **Do not paste the raw JSON** — the CI script expects base64 and will fail to decode a raw value.
|
||||
|
||||
### Key points
|
||||
|
||||
- The job uploads the signed **AAB** (not APK) — Google Play requires App Bundles.
|
||||
- Uploads go directly to the **production** track. Google's review process still applies before the update reaches users.
|
||||
- Metadata, screenshots, and changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.).
|
||||
- The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`).
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: file-uploads
|
||||
description: Upload files (images, media, attachments) from the browser to a Blossom server via the useUploadFile hook, and attach them to Nostr events with NIP-94 imeta tags.
|
||||
---
|
||||
|
||||
# File Uploads on Nostr
|
||||
|
||||
This project includes a `useUploadFile` hook that uploads files to Blossom servers and returns NIP-94-compatible tags. Use it whenever a feature needs to accept a user-provided file (avatars, banners, post attachments, etc.).
|
||||
|
||||
## The `useUploadFile` Hook
|
||||
|
||||
```tsx
|
||||
import { useUploadFile } from "@/hooks/useUploadFile";
|
||||
|
||||
function MyComponent() {
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
// Returns an array of NIP-94-compatible tags.
|
||||
// The first tag is the `url` tag; its second element is the file URL.
|
||||
const tags = await uploadFile(file);
|
||||
const url = tags[0][1];
|
||||
// ...use the url
|
||||
} catch (error) {
|
||||
// ...handle errors (show a toast, etc.)
|
||||
}
|
||||
};
|
||||
|
||||
// ...rest of component
|
||||
}
|
||||
```
|
||||
|
||||
The hook is a TanStack Query mutation, so `isPending` can drive loading UI and `mutateAsync` integrates cleanly with `async`/`await` flows.
|
||||
|
||||
## Attaching Files to Events
|
||||
|
||||
### Kind 0 (profile metadata)
|
||||
|
||||
Use the plain URL in the relevant JSON field:
|
||||
|
||||
```ts
|
||||
const tags = await uploadFile(file);
|
||||
const url = tags[0][1];
|
||||
|
||||
createEvent({
|
||||
kind: 0,
|
||||
content: JSON.stringify({ ...existingMetadata, picture: url }),
|
||||
});
|
||||
```
|
||||
|
||||
### Kind 1 (text notes) and other content events
|
||||
|
||||
Append the URL to `content`, and add one `imeta` tag per file. `imeta` carries the NIP-94 metadata (mime type, dimensions, blurhash, etc.) that the uploader returned:
|
||||
|
||||
```ts
|
||||
const tags = await uploadFile(file); // e.g. [["url", "https://..."], ["m", "image/png"], ["dim", "1024x768"], ...]
|
||||
const url = tags[0][1];
|
||||
|
||||
// Flatten the NIP-94 tags into a single imeta tag value.
|
||||
const imeta = tags.map(([name, value]) => `${name} ${value}`);
|
||||
|
||||
createEvent({
|
||||
kind: 1,
|
||||
content: `Check this out ${url}`,
|
||||
tags: [["imeta", ...imeta]],
|
||||
});
|
||||
```
|
||||
|
||||
Repeat the pattern (one `imeta` tag per file) for multiple attachments.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- **Avatar / banner pickers:** wrap an `<input type="file" accept="image/*">` and call `uploadFile` on change; on success, update the relevant profile field and publish a kind 0 event.
|
||||
- **Post composers:** call `uploadFile` for each selected file before publishing the note, then build `imeta` tags alongside `content`.
|
||||
- **Progress UI:** use `isPending` from the mutation to disable the submit button and show a spinner or skeleton.
|
||||
- **Error handling:** wrap `uploadFile` in `try/catch` and surface failures via `useToast` — network and Blossom-server errors are common and should never break the UI.
|
||||
|
||||
## Constraints
|
||||
|
||||
- The hook requires a logged-in user (Blossom auth is signed by the user's signer). Guard uploads behind `useCurrentUser`.
|
||||
- Don't store or display raw `File` objects after upload — always use the returned URL.
|
||||
- Large files may take time; prefer `mutateAsync` over `mutate` so the caller can `await` completion before publishing an event that references the URL.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: git-workflow
|
||||
description: Ditto's git conventions — validating changes before committing, writing commit messages that match project style, and attributing regressions with a Regression-of trailer so the release changelog skill can filter them from the "Fixed" section.
|
||||
---
|
||||
|
||||
# Git Workflow
|
||||
|
||||
Ditto expects every completed task to end with a git commit. This skill covers the pre-commit validation loop, commit-message conventions, and the `Regression-of:` trailer used by the release skill to filter intra-release regressions from the changelog.
|
||||
|
||||
## Pre-commit Validation
|
||||
|
||||
**Your task is not finished until the code type-checks and builds without errors.** In priority order:
|
||||
|
||||
1. **Type Checking** (required) — `tsc --noEmit`
|
||||
2. **Building/Compilation** (required) — `vite build`
|
||||
3. **Linting** (recommended; fix anything critical) — `eslint`
|
||||
4. **Tests** (if available) — `vitest run`
|
||||
5. **Git commit** (required)
|
||||
|
||||
The full `npm run test` script runs all of these in sequence; running it is equivalent to steps 1–4.
|
||||
|
||||
## Using Git
|
||||
|
||||
Use `git status` and `git diff` to review changes, and `git log` to learn the project's commit-message conventions before writing a new one. If you make a mistake, `git checkout` restores files.
|
||||
|
||||
When your changes are complete and validated, create a commit with a message that focuses on **why** the change was made (not just **what**). Summaries should fit on one line; a body is warranted for non-trivial changes.
|
||||
|
||||
**Always commit when you are finished making changes. Non-negotiable — every completed task ends with a commit. Don't leave uncommitted changes.**
|
||||
|
||||
## Contributing Guide
|
||||
|
||||
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
|
||||
|
||||
## Attributing Regressions
|
||||
|
||||
When a commit fixes a bug that was introduced by an identifiable prior commit, add a `Regression-of:` trailer at the bottom of the commit message body referencing the offending commit's short SHA:
|
||||
|
||||
```
|
||||
Fix missing background on expanded emoji picker in feeds
|
||||
|
||||
The compose box overhaul accidentally dropped the bg-background class
|
||||
when refactoring the picker out of QuickReactMenu.
|
||||
|
||||
Regression-of: 3aa08ba9
|
||||
```
|
||||
|
||||
This is a standard Git trailer (compatible with `git interpret-trailers`) that records the cause-and-effect link directly in history. It is consumed by the `release` skill to detect intra-release regressions and exclude them from the changelog's "Fixed" section, and it makes future debugging and post-mortems substantially faster.
|
||||
|
||||
### When to add it
|
||||
|
||||
- The commit fixes a bug (not a new feature, refactor, or doc change).
|
||||
- The introducing commit is identifiable with reasonable effort.
|
||||
|
||||
### When to skip it
|
||||
|
||||
- The bug is pre-existing with no clear single origin.
|
||||
- The behavior was always wrong (no regression).
|
||||
- The introducing commit cannot be determined after a brief search.
|
||||
|
||||
### Finding the introducing commit
|
||||
|
||||
- `git log -S '<removed-or-changed-string>'` — find commits that touched a specific string.
|
||||
- `git log --oneline -- path/to/file` — list all commits touching a file.
|
||||
- `git blame -L <start>,<end> -- path/to/file` — find who last changed specific lines.
|
||||
|
||||
This convention is **strongly recommended but not required.** When the origin is non-obvious, prioritize shipping the fix over hunting indefinitely.
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: nip19-routing
|
||||
description: Implement or populate the root-level NIP-19 router (/:nip19) that handles npub, nprofile, note, nevent, and naddr identifiers. Covers decoding, secure filter construction, and type-specific rendering for profiles, notes, events, and addressable events.
|
||||
---
|
||||
|
||||
# NIP-19 Identifier Routing
|
||||
|
||||
NIP-19 defines the bech32-encoded identifiers used throughout Nostr (`npub1...`, `note1...`, `naddr1...`, etc.). This project routes all of them through a single root-level page at `/:nip19`, implemented by `src/pages/NIP19Page.tsx`.
|
||||
|
||||
Use this skill when the user wants to populate the `NIP19Page` sections with real views, add a new identifier type, or build links that point into the Nostr routing system.
|
||||
|
||||
## Identifier Reference
|
||||
|
||||
| Prefix | Payload | Use when… |
|
||||
|--------------|------------------------------------------------------------------|--------------------------------------------------------------|
|
||||
| `npub1` | 32-byte public key | Simple user reference |
|
||||
| `nprofile1` | Public key + optional relay hints + petname | User reference with relay context |
|
||||
| `note1` | 32-byte event ID (kind:1 text notes only, per NIP-10) | Referencing a short text note/thread |
|
||||
| `nevent1` | Event ID + optional relay hints + author pubkey + kind | Any event kind, or notes where you need relay/author context |
|
||||
| `naddr1` | `kind` + `pubkey` + `identifier` (`d` tag) + optional relay hints | Addressable events (kind 30000-39999): articles, products |
|
||||
| `nsec1` | Private key | **Never display or route** — treat as a 404 |
|
||||
| `nrelay1` | Relay URL | Deprecated |
|
||||
|
||||
### `note1` vs `nevent1`
|
||||
|
||||
- `note1` carries only an event ID, and is canonically tied to kind:1 text notes.
|
||||
- `nevent1` can reference **any** kind and can carry relay hints + author pubkey. Prefer `nevent1` for non-kind-1 events or when you want to ship relay hints with a link.
|
||||
|
||||
### `npub1` vs `nprofile1`
|
||||
|
||||
- `npub1` is just a pubkey.
|
||||
- `nprofile1` adds relay hints and a petname. Prefer it for shareable profile links where discoverability matters.
|
||||
|
||||
## Routing Rules
|
||||
|
||||
1. **All NIP-19 identifiers are handled at the URL root**: `/:nip19` in `AppRouter.tsx`. Never nest them under paths like `/note/:id` or `/profile/:npub`.
|
||||
2. **Invalid, vacant, or unsupported identifiers** (including `nsec1` and `nrelay1`) render the 404 page. The `NIP19Page` boilerplate already handles this.
|
||||
3. **Addressable event URLs must include the author**. `naddr1` already encodes `pubkey` + `kind` + `identifier`, which is exactly what a secure query filter needs. If you ever design an alternative URL, use the shape `/:npub/:dtag`, never `/:dtag` alone — otherwise anyone can publish a conflicting event with the same `d` tag.
|
||||
|
||||
## Decoding and Filtering
|
||||
|
||||
Nostr relay filters only accept hex strings. Always decode the NIP-19 identifier before building a filter.
|
||||
|
||||
```ts
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
const decoded = nip19.decode(value); // throws on invalid input
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub': {
|
||||
const pubkey = decoded.data; // hex string
|
||||
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'nprofile': {
|
||||
const { pubkey /*, relays */ } = decoded.data;
|
||||
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'note': {
|
||||
const id = decoded.data;
|
||||
return nostr.query([{ ids: [id], kinds: [1], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'nevent': {
|
||||
const { id /*, relays, author, kind */ } = decoded.data;
|
||||
return nostr.query([{ ids: [id], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'naddr': {
|
||||
const { kind, pubkey, identifier } = decoded.data;
|
||||
return nostr.query([{
|
||||
kinds: [kind],
|
||||
authors: [pubkey], // critical: prevents d-tag spoofing
|
||||
'#d': [identifier],
|
||||
limit: 1,
|
||||
}]);
|
||||
}
|
||||
|
||||
default:
|
||||
// nsec, nrelay, unknown → 404
|
||||
throw new Error('Unsupported Nostr identifier');
|
||||
}
|
||||
```
|
||||
|
||||
### Common mistakes
|
||||
|
||||
```ts
|
||||
// ❌ Passing bech32 into a filter
|
||||
nostr.query([{ ids: [naddr] }]);
|
||||
|
||||
// ❌ Addressable lookup without the author — anyone can spoof the d-tag
|
||||
nostr.query([{ kinds: [30023], '#d': [slug] }]);
|
||||
|
||||
// ✅ Decode first, then include author
|
||||
const { kind, pubkey, identifier } = nip19.decode(naddr).data;
|
||||
nostr.query([{ kinds: [kind], authors: [pubkey], '#d': [identifier] }]);
|
||||
```
|
||||
|
||||
## Populating `NIP19Page`
|
||||
|
||||
`src/pages/NIP19Page.tsx` already:
|
||||
|
||||
- Decodes `params.nip19` with `nip19.decode`.
|
||||
- Branches on `decoded.type` with a section for each supported identifier.
|
||||
- Redirects invalid / unsupported identifiers to the 404 page.
|
||||
- Provides a responsive container wrapper.
|
||||
|
||||
To turn it into a real router, replace each placeholder section with a concrete component:
|
||||
|
||||
| `decoded.type` | Typical view |
|
||||
|-----------------------|---------------------------------------------------------------|
|
||||
| `npub` / `nprofile` | Profile page: header from kind 0, feed of the user's events |
|
||||
| `note` | Single kind:1 text note with thread + replies |
|
||||
| `nevent` | Generic event renderer; branch on `kind` for specialized UIs |
|
||||
| `naddr` | Addressable-event view (article, product, community, etc.) |
|
||||
|
||||
Inside each branch, pass the decoded payload (not the raw bech32 string) to a child component. That keeps filter construction colocated with the fetching hook and removes any chance of a re-decode mismatch.
|
||||
|
||||
## Linking to NIP-19 Routes
|
||||
|
||||
When building links elsewhere in the app:
|
||||
|
||||
```tsx
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// To a profile
|
||||
<Link to={`/${nip19.npubEncode(pubkey)}`}>Profile</Link>
|
||||
|
||||
// To an addressable event (article, product, …)
|
||||
<Link to={`/${nip19.naddrEncode({ kind, pubkey, identifier, relays })}`}>
|
||||
Open
|
||||
</Link>
|
||||
|
||||
// To a specific event of any kind, with relay hints
|
||||
<Link to={`/${nip19.neventEncode({ id, relays, author, kind })}`}>Open</Link>
|
||||
```
|
||||
|
||||
Always encode with the **most specific** identifier you have context for (`nprofile` > `npub`, `nevent` > `note`, `naddr` for addressable). The extra metadata makes links more robust across relays.
|
||||
|
||||
## Security Recap
|
||||
|
||||
- Decode **before** querying.
|
||||
- For addressable events, always include `authors: [pubkey]` in the filter — the `d` tag alone is not a trust boundary.
|
||||
- Treat `nsec1` and any unknown/invalid identifier as 404. Never render, log, or echo a decoded `nsec`.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: nostr-encryption
|
||||
description: Encrypt and decrypt content for Nostr direct messages, gift wraps, or any feature that needs NIP-44 (or legacy NIP-04) ciphertext, using the logged-in user's signer.
|
||||
---
|
||||
|
||||
# Nostr Encryption and Decryption
|
||||
|
||||
The logged-in user exposes a `signer` object that matches the NIP-07 signer interface. The signer handles all cryptographic operations internally — including ECDH, conversation-key derivation, and AEAD — so your code never touches a private key.
|
||||
|
||||
**Always use the signer interface for encryption. Never ask the user for their private key, and never derive a shared secret yourself.**
|
||||
|
||||
## NIP-44 (preferred)
|
||||
|
||||
NIP-44 is the modern, authenticated encryption scheme used for DMs (NIP-17), gift wraps (NIP-59), and most new encrypted payloads.
|
||||
|
||||
```ts
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
|
||||
function useEncryptedNote() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
if (!user) throw new Error("Must be logged in");
|
||||
|
||||
// Guard: older signers may not support NIP-44 yet.
|
||||
if (!user.signer.nip44) {
|
||||
throw new Error(
|
||||
"Please upgrade your signer extension to a version that supports NIP-44 encryption",
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypt a message to a recipient (use your own pubkey to encrypt to self).
|
||||
const ciphertext = await user.signer.nip44.encrypt(
|
||||
recipientPubkey,
|
||||
"hello world",
|
||||
);
|
||||
|
||||
// Decrypt a message from a sender (use the *other party's* pubkey).
|
||||
const plaintext = await user.signer.nip44.decrypt(senderPubkey, ciphertext);
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
```
|
||||
|
||||
### Key points
|
||||
|
||||
- `encrypt(peerPubkey, plaintext)` — `peerPubkey` is the **other party's** hex public key. For self-encryption (notes, backups), pass `user.pubkey`.
|
||||
- `decrypt(peerPubkey, ciphertext)` — `peerPubkey` is the author of the ciphertext you're decrypting (for an incoming DM, this is the sender's pubkey).
|
||||
- Both methods are async and may throw if the signer rejects the request or the ciphertext is malformed. Wrap calls in `try/catch`.
|
||||
- The signer handles conversation-key caching; repeated calls for the same peer are cheap.
|
||||
|
||||
## NIP-04 (legacy)
|
||||
|
||||
NIP-04 is only needed when interacting with older clients that haven't adopted NIP-44. The API mirrors NIP-44:
|
||||
|
||||
```ts
|
||||
if (!user.signer.nip04) {
|
||||
throw new Error("Signer does not support NIP-04");
|
||||
}
|
||||
|
||||
const ciphertext = await user.signer.nip04.encrypt(peerPubkey, plaintext);
|
||||
const plaintext = await user.signer.nip04.decrypt(peerPubkey, ciphertext);
|
||||
```
|
||||
|
||||
Prefer NIP-44 for anything new. Only fall back to NIP-04 when a spec or peer explicitly requires it.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Encrypt-to-self (drafts, private notes)
|
||||
|
||||
```ts
|
||||
const ciphertext = await user.signer.nip44.encrypt(user.pubkey, draft);
|
||||
createEvent({ kind: 30078, content: ciphertext, tags: [["d", "my-draft"]] });
|
||||
```
|
||||
|
||||
### Decrypt an incoming DM (NIP-17 / NIP-59)
|
||||
|
||||
For gift-wrapped DMs, you'll typically decrypt the outer wrap, then the inner seal, then read the rumor's content. Each decryption uses the *sender* of that specific layer as the peer pubkey.
|
||||
|
||||
### Guarding the UI
|
||||
|
||||
Always check `user.signer.nip44` (or `nip04`) before calling encryption methods. Remote signers and older browser extensions may not implement every interface, and catching the missing-capability case lets you show a useful message ("Please upgrade your signer") instead of an unhandled promise rejection.
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: nostr-kinds
|
||||
description: Decide whether to reuse an existing NIP or mint a new kind, design tag structures that relays can index, choose what goes in content vs. tags, and register a new kind in Ditto's many UI touchpoints (feed cards, detail pages, embedded previews, kind-label maps).
|
||||
---
|
||||
|
||||
# Nostr Kinds — Design and Registration
|
||||
|
||||
Use this skill when introducing a new kind to Ditto, extending an existing NIP with new tags, or deciding whether an existing NIP covers a feature. It covers the decision framework, schema rules, and — critically — the full list of places a new kind must be registered in Ditto's UI.
|
||||
|
||||
## Choosing Between Existing NIPs and Custom Kinds
|
||||
|
||||
1. **Thorough NIP review first.** Browse the NIP index, then read candidate NIPs in detail. The goal is to find the closest existing solution.
|
||||
2. **Prefer extending existing NIPs** over creating custom kinds, even at the cost of minor schema compromises. Custom kinds fragment the ecosystem.
|
||||
3. **When an existing NIP is close but not perfect**, use its kind as the base and add domain-specific tags. Document the extension in `NIP.md`.
|
||||
4. **Only mint a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case requires different storage characteristics (regular vs. replaceable vs. addressable).
|
||||
5. **If a tool to generate a new kind number is available, you MUST call it.** Never pick an arbitrary number.
|
||||
6. **Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description of the event's purpose.
|
||||
|
||||
**Example decision:**
|
||||
|
||||
```
|
||||
Need: Equipment marketplace for farmers
|
||||
Options:
|
||||
1. NIP-15 (Marketplace) — too structured for peer-to-peer sales
|
||||
2. NIP-99 (Classifieds) — good fit, extensible with farming tags
|
||||
3. Custom kind — perfect fit, no interoperability
|
||||
|
||||
Decision: NIP-99 + farming-specific tags.
|
||||
```
|
||||
|
||||
## Kind Ranges
|
||||
|
||||
An event's kind number determines storage semantics:
|
||||
|
||||
- **Regular** (1000 ≤ kind < 10000) — stored permanently by relays. Notes, articles, etc.
|
||||
- **Replaceable** (10000 ≤ kind < 20000) — only the latest event per `pubkey+kind` is kept. Profile metadata, contact lists, mute lists.
|
||||
- **Addressable** (30000 ≤ kind < 40000) — identified by `pubkey+kind+d-tag`; only the latest per combo is kept. Long-form content, products, definitions.
|
||||
|
||||
Kinds below 1000 are "legacy"; storage is per-kind (e.g. kind 1 is regular, kind 3 is replaceable).
|
||||
|
||||
## Tag Design Principles
|
||||
|
||||
- **Kind = schema, tags = semantics.** Don't mint a new kind just to represent a different category of the same data.
|
||||
- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering.
|
||||
- **Filter at the relay**, not in JavaScript:
|
||||
|
||||
```ts
|
||||
// ❌ Fetch everything, filter locally
|
||||
const events = await nostr.query([{ kinds: [30402] }]);
|
||||
const filtered = events.filter((e) => hasTag(e, 'product_type', 'electronics'));
|
||||
|
||||
// ✅ Filter at the relay
|
||||
const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]);
|
||||
```
|
||||
|
||||
- **For Ditto-specific niches** (community apps, regional variants), tag events with a `t` value and query on it. Don't do this for generic platforms — it would silo content.
|
||||
|
||||
## Content vs. Tags
|
||||
|
||||
- **`content`** — large freeform text or existing industry-standard JSON (GeoJSON, FHIR, Tiled maps). Kind 0 is the one exception where structured JSON goes in content.
|
||||
- **Tags** — queryable metadata, structured data, anything you might filter on.
|
||||
- **Empty content is fine.** `content: ""` is idiomatic for tag-only events.
|
||||
- **If you need to filter by a field, it must be a tag** — relays don't index content.
|
||||
|
||||
```json
|
||||
// ✅ Queryable
|
||||
{ "kind": 30402, "content": "",
|
||||
"tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]] }
|
||||
|
||||
// ❌ Structured data buried in content
|
||||
{ "kind": 30402, "content": "{\"title\":\"Camera\",\"price\":250}", "tags": [["d", "product-123"]] }
|
||||
```
|
||||
|
||||
## `NIP.md`
|
||||
|
||||
`NIP.md` documents Ditto's custom kinds and any extensions to existing NIPs. Whenever you mint a new kind or change a custom schema, **create or update `NIP.md`** with the tag list, content format, and intended usage. If a kind you add is effectively the same shape as an existing NIP, note the NIP reference rather than duplicating the spec.
|
||||
|
||||
## Registering a New Kind in the Ditto UI
|
||||
|
||||
When adding support for a new kind, the kind must be registered in **multiple locations** or it will render incorrectly in certain views (blank content in quote posts, "Kind 12345" as a label, missing action headers, etc.).
|
||||
|
||||
### Checklist
|
||||
|
||||
1. **Content card component** (`src/components/`) — create `<MyKindCard>` that renders the event's tags/content appropriately.
|
||||
|
||||
2. **Feed rendering** (`src/components/NoteCard.tsx`):
|
||||
- Add `const isMyKind = event.kind === XXXX;`.
|
||||
- Include it in the appropriate group flag (e.g. `isDevKind`) or the `isTextNote` exclusion list.
|
||||
- Add the content dispatch: `isMyKind ? <MyKindCard event={event} /> : …`.
|
||||
- Add an entry to `KIND_HEADER_MAP` for the action header (e.g. "deployed an nsite").
|
||||
- Import the new component and any new icons (e.g. `Globe` from `lucide-react`).
|
||||
|
||||
3. **Detail page** (`src/pages/PostDetailPage.tsx`):
|
||||
- Mirror the `isMyKind` detection and group/exclusion flags from `NoteCard`.
|
||||
- Add the content dispatch for the detail view.
|
||||
- Add an entry in `shellTitleForKind()` for the loading-state title.
|
||||
- Import the new component.
|
||||
|
||||
4. **Feed registration** (`src/lib/extraKinds.ts`):
|
||||
- Add the kind number to an existing feed definition's `extraFeedKinds` array, or create a new `ExtraKindDef` entry.
|
||||
|
||||
5. **Kind-label registries** — independent maps that resolve kind → human-readable string/icon. All must be updated:
|
||||
- `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` — used for "Commenting on an nsite" text and inline icons.
|
||||
- `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` — used in addressable event preview headers.
|
||||
- The icon fallback in `AddressableEventPreview` in the same file.
|
||||
|
||||
6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) — small preview cards shown inside quote posts, reply-context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal preview (author + title/content + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags (e.g. kind 20 photos via `imeta` tags) may need attachment-indicator logic added to `EmbeddedNoteCard`.
|
||||
|
||||
> Do not confuse these with the `compact` prop on `NoteCard` — that just hides action buttons on the full `NoteCard`. `EmbeddedNote`/`EmbeddedNaddr` are entirely different components.
|
||||
|
||||
7. **Reply composer** (`src/components/ReplyComposeModal.tsx`) — `EmbeddedPost` delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components, so no per-kind registration is needed here.
|
||||
|
||||
### Why so many places?
|
||||
|
||||
These are genuinely different UI contexts (feed cards, detail pages, embedded previews, reply previews, comment-context labels) with different rendering requirements. Several of them maintain independent kind-to-label maps that could theoretically be unified. **When in doubt, grep the codebase for an existing kind number like `30617`** — you'll find every registration point you need to mirror.
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: nostr-queries
|
||||
description: Query Nostr events efficiently with useNostr + TanStack Query. Covers the standard useQuery pattern, combining related kinds into a single request to avoid rate limiting, and validating events with required tags or strict schemas.
|
||||
---
|
||||
|
||||
# Querying Nostr Events
|
||||
|
||||
Use this skill when building a hook that fetches Nostr events. Covers the standard `useNostr` + `useQuery` pattern, efficient query design (combining kinds to avoid relay round-trips), and event validation for kinds with required tags.
|
||||
|
||||
## The Standard Pattern
|
||||
|
||||
Combine `useNostr` with TanStack Query in a custom hook. Pass the abort signal from `c.signal` into `nostr.query` so cancelled queries free relay resources:
|
||||
|
||||
```typescript
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function usePosts() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: async (c) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [1], limit: 20 }],
|
||||
{ signal: c.signal },
|
||||
);
|
||||
return events;
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Transform events into a domain model inside the `queryFn` if needed — callers should rarely see raw `NostrEvent`s. Multiple calls to `nostr.query()` inside one `queryFn` are fine for compound queries that can't be expressed as a single filter.
|
||||
|
||||
## Efficient Query Design
|
||||
|
||||
**Always minimize the number of separate round-trips** to relays. Each query consumes relay capacity and may count against rate limits.
|
||||
|
||||
**✅ Efficient — single query with multiple kinds:**
|
||||
|
||||
```typescript
|
||||
// Query repost variants in one request
|
||||
const events = await nostr.query([{
|
||||
kinds: [1, 6, 16],
|
||||
'#e': [eventId],
|
||||
limit: 150,
|
||||
}]);
|
||||
|
||||
// Separate by kind in JavaScript
|
||||
const notes = events.filter((e) => e.kind === 1);
|
||||
const reposts = events.filter((e) => e.kind === 6);
|
||||
const genericReposts = events.filter((e) => e.kind === 16);
|
||||
```
|
||||
|
||||
**❌ Inefficient — three separate round-trips:**
|
||||
|
||||
```typescript
|
||||
const [notes, reposts, genericReposts] = await Promise.all([
|
||||
nostr.query([{ kinds: [1], '#e': [eventId] }]),
|
||||
nostr.query([{ kinds: [6], '#e': [eventId] }]),
|
||||
nostr.query([{ kinds: [16], '#e': [eventId] }]),
|
||||
]);
|
||||
```
|
||||
|
||||
### Optimization rules
|
||||
|
||||
1. **Combine kinds** into one filter: `kinds: [1, 6, 16]`.
|
||||
2. **Use multiple filter objects** in a single `nostr.query()` call when different tag filters are needed simultaneously.
|
||||
3. **Raise the `limit`** when combining kinds so you still receive enough of each type.
|
||||
4. **Split by kind in JavaScript**, not by making separate requests.
|
||||
5. **Respect relay capacity** — heavy parallel queries can trigger rate limits even when each individually would be fine.
|
||||
|
||||
## Event Validation
|
||||
|
||||
For kinds with required tags or strict schemas (most custom kinds, anything beyond kind 1), filter query results through a validator before returning them. Loose kinds (kind 1 text notes) rarely need validation — all tags are optional and `content` is freeform.
|
||||
|
||||
```typescript
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
// Example validator for NIP-52 calendar events
|
||||
function validateCalendarEvent(event: NostrEvent): boolean {
|
||||
if (![31922, 31923].includes(event.kind)) return false;
|
||||
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
const title = event.tags.find(([n]) => n === 'title')?.[1];
|
||||
const start = event.tags.find(([n]) => n === 'start')?.[1];
|
||||
if (!d || !title || !start) return false;
|
||||
|
||||
// Date-based events require YYYY-MM-DD
|
||||
if (event.kind === 31922 && !/^\d{4}-\d{2}-\d{2}$/.test(start)) return false;
|
||||
|
||||
// Time-based events require a unix timestamp
|
||||
if (event.kind === 31923) {
|
||||
const ts = parseInt(start);
|
||||
if (isNaN(ts) || ts <= 0) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function useCalendarEvents() {
|
||||
const { nostr } = useNostr();
|
||||
return useQuery({
|
||||
queryKey: ['calendar-events'],
|
||||
queryFn: async (c) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [31922, 31923], limit: 20 }],
|
||||
{ signal: c.signal },
|
||||
);
|
||||
return events.filter(validateCalendarEvent);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Validation is a correctness layer, not a security layer. For trust-sensitive queries (admin actions, addressable events, moderator approvals), also constrain `authors` — see the `nostr-security` skill.
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: nostr-relay-pools
|
||||
description: Query or publish to specific Nostr relays or curated relay groups using nostr.relay() and nostr.group(), instead of the default connection pool. Useful for debugging, testing, specialized relays, or geographically-targeted publishing.
|
||||
---
|
||||
|
||||
# Targeted Nostr Relay Connections
|
||||
|
||||
By default, the `nostr` object returned from `useNostr` uses the app's connection pool: it reads from one of the configured relays and publishes to all of them. For most features this is exactly what you want.
|
||||
|
||||
Use this skill when you need **more granular control** — talking to a single relay, a curated group of relays, or debugging a specific relay's behavior.
|
||||
|
||||
## Single Relay: `nostr.relay(url)`
|
||||
|
||||
```ts
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
function useSpecificRelay() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Connect to a specific relay
|
||||
const relay = nostr.relay('wss://relay.damus.io');
|
||||
|
||||
// Query from this relay only
|
||||
const events = await relay.query([{ kinds: [1], limit: 15 }]);
|
||||
|
||||
// Publish to this relay only
|
||||
await relay.event({ kind: 1, content: 'Hello from a specific relay!' });
|
||||
}
|
||||
```
|
||||
|
||||
**Good fits:**
|
||||
|
||||
- Testing a relay's behavior in isolation
|
||||
- Debugging connectivity or rate-limiting issues
|
||||
- Querying content that only lives on a specialized relay (paid relays, private relays, niche communities)
|
||||
- Health checks / admin tooling
|
||||
|
||||
## Relay Group: `nostr.group(urls)`
|
||||
|
||||
```ts
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
function useRelayGroup() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Create a group of specific relays
|
||||
const relayGroup = nostr.group([
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
'wss://nos.lol',
|
||||
]);
|
||||
|
||||
// Query from all relays in the group (deduplicated)
|
||||
const events = await relayGroup.query([{ kinds: [1], limit: 15 }]);
|
||||
|
||||
// Publish to all relays in the group
|
||||
await relayGroup.event({ kind: 1, content: 'Hello from a relay group!' });
|
||||
}
|
||||
```
|
||||
|
||||
**Good fits:**
|
||||
|
||||
- Publishing to a curated set of trusted relays for a specific feature
|
||||
- Community-scoped queries (e.g. a set of relays known to host a particular topic)
|
||||
- Geographic/region-targeted delivery
|
||||
- Load-balancing reads across a known-good subset
|
||||
|
||||
## API Consistency
|
||||
|
||||
Both the `relay` object and the `group` object expose the **same interface** as the top-level `nostr` object:
|
||||
|
||||
- `.query(filters, opts?)` — request events matching filters
|
||||
- `.req(filters, opts?)` — open a streaming subscription
|
||||
- `.event(event)` — publish a signed event
|
||||
- All other Nostrify methods
|
||||
|
||||
This means you can drop them into any existing hook or helper that expects a `nostr`-shaped object.
|
||||
|
||||
## Choosing Between Pool, Group, and Single Relay
|
||||
|
||||
| Scenario | Use |
|
||||
|----------------------------------------------------|---------------------|
|
||||
| Default app queries, best reach for publishing | `nostr` (pool) |
|
||||
| Trusted subset, community-specific publishing | `nostr.group([…])` |
|
||||
| Single-relay debugging or specialized relay access | `nostr.relay(url)` |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Don't hard-code user-facing relay lists.** If a feature should publish to "the user's write relays", read from `AppContext.config.relayMetadata` (NIP-65) instead of hard-coding URLs.
|
||||
- **Compose with TanStack Query.** Wrap `relay.query(...)` / `group.query(...)` inside a `useQuery` hook exactly as you would with the default `nostr` object; the caching layer is identical.
|
||||
- **Handle unreachable relays.** Specific relays can be offline, rate-limited, or slow. Always wrap calls in `try/catch` and respect the abort signal from the query function (`c.signal`).
|
||||
- **Avoid leaking subscriptions.** When using `.req(...)` for streaming, always close the subscription on unmount (`controller.abort()` or the returned disposer).
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: theming
|
||||
description: Customize Ditto's visual design — install Google Fonts via @fontsource, change the color scheme, configure light/dark themes, and apply consistent component styling patterns with Tailwind and CSS variables.
|
||||
---
|
||||
|
||||
# Theming, Fonts, and Color Schemes
|
||||
|
||||
Use this skill when the user wants to change fonts, colors, light/dark appearance, or general visual styling. Ditto ships with a light/dark theme system built on CSS custom properties and Tailwind v3, plus a `useTheme` hook for runtime switching.
|
||||
|
||||
## Adding Fonts
|
||||
|
||||
Any Google Font can be installed via the `@fontsource` / `@fontsource-variable` packages.
|
||||
|
||||
1. **Install the font package.** Prefer the variable version when available.
|
||||
```bash
|
||||
npm install @fontsource-variable/inter
|
||||
```
|
||||
Package naming:
|
||||
- `@fontsource-variable/<font-name>` — variable fonts (preferred; one file, all weights)
|
||||
- `@fontsource/<font-name>` — static fonts
|
||||
|
||||
2. **Import the font once** in `src/main.tsx`:
|
||||
```ts
|
||||
import '@fontsource-variable/inter';
|
||||
```
|
||||
|
||||
3. **Register the family** in `tailwind.config.ts`:
|
||||
```ts
|
||||
export default {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Suggested families by use case
|
||||
|
||||
- **Modern / Clean:** Inter Variable, Outfit Variable, Manrope
|
||||
- **Professional / Corporate:** Roboto, Open Sans, Source Sans Pro
|
||||
- **Creative / Artistic:** Poppins, Nunito, Comfortaa
|
||||
- **Monospace / Code:** JetBrains Mono, Fira Code, Source Code Pro
|
||||
|
||||
For expressive hierarchies, pair a sans body font with a display/serif heading font (e.g. Inter + Playfair Display) and expose the second family as `fontFamily.serif` or `fontFamily.display` in Tailwind.
|
||||
|
||||
### Runtime font loading from Nostr events
|
||||
|
||||
Ditto also supports loading fonts referenced from Nostr events (theme events, letter stationery, etc.) through `src/lib/fontLoader.ts`. That path is separate from the build-time `@fontsource` approach — it constructs `@font-face` rules at runtime from sanitized URLs. Never feed event data through the `@fontsource` path; always go through `fontLoader` so the URL and family name are passed through `sanitizeUrl()` and `sanitizeCssString()` (see the `nostr-security` skill).
|
||||
|
||||
## Color Schemes
|
||||
|
||||
Colors are defined as CSS custom properties in `src/index.css` under two selectors:
|
||||
|
||||
- `:root` — light-mode values
|
||||
- `.dark` — dark-mode overrides
|
||||
|
||||
When the user requests a new color scheme:
|
||||
|
||||
1. **Update both `:root` and `.dark`** in `src/index.css`. Each variable is an HSL triplet (no `hsl()` wrapper), e.g. `--primary: 222 47% 11%;`.
|
||||
2. **Keep contrast ratios ≥ 4.5:1** for body text and interactive elements. Test both modes.
|
||||
3. **Prefer extending Tailwind's palette** (`tailwind.config.ts`) over hard-coding hex values in components — this keeps the theme consistent and dark-mode-friendly.
|
||||
4. **Apply colors through semantic tokens** (`bg-primary`, `text-muted-foreground`, `border-input`) rather than raw palette names when possible, so future theme changes propagate.
|
||||
|
||||
The shadcn/ui components consume these semantic tokens, so changing the variables automatically restyles the entire component library.
|
||||
|
||||
## Light/Dark Theme Switching
|
||||
|
||||
Ditto includes:
|
||||
|
||||
- **`useTheme` hook** (`src/hooks/useTheme.ts`) — read and set the current theme programmatically.
|
||||
- **CSS custom properties** in `src/index.css` — one set in `:root`, dark overrides in `.dark`.
|
||||
- **Automatic persistence** via the `AppContext` config (`config.theme`), saved to local storage.
|
||||
|
||||
To add a theme toggle:
|
||||
|
||||
```tsx
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Component Styling Patterns
|
||||
|
||||
- **Class merging:** use the `cn()` utility (`@/lib/utils`) to combine conditional classes and override defaults without class-order bugs.
|
||||
- **Variants:** follow shadcn/ui's `class-variance-authority` pattern for component variants (`variant`, `size`). Copy an existing `ui/` component as a template.
|
||||
- **Responsive design:** lean on Tailwind breakpoints (`sm:`, `md:`, `lg:`) rather than JS media queries. Use `useIsMobile` only when layout must change based on JS-measured viewport.
|
||||
- **Interactive states:** always define `hover:`, `focus-visible:`, and `disabled:` states for clickable elements. Focus rings should use `ring-ring` / `ring-offset-background` so they pick up theme colors.
|
||||
- **Spacing:** an 8px grid (Tailwind's default 4-based scale) keeps visual rhythm consistent. Common paddings: `p-4`, `p-6`; gaps: `gap-2`, `gap-4`.
|
||||
- **Depth:** soft shadows (`shadow-sm`, `shadow-md`), subtle gradients, and `rounded-lg` / `rounded-xl` corners match Ditto's aesthetic. Avoid heavy drop shadows.
|
||||
|
||||
### Negative z-index gotcha
|
||||
|
||||
When placing decorative elements behind content with `-z-10` (e.g. blurred background gradients), **add `isolate` to the parent container**. Without `isolate`, the negative z-index escapes the local stacking context and the element disappears behind the page's background color.
|
||||
|
||||
```tsx
|
||||
<section className="relative isolate">
|
||||
<div className="absolute inset-0 -z-10 bg-gradient-to-br from-primary/20 to-transparent" />
|
||||
{/* content */}
|
||||
</section>
|
||||
```
|
||||
|
||||
## Design Quality Checklist
|
||||
|
||||
Before finishing a visual change, verify:
|
||||
|
||||
- [ ] Both light and dark modes look correct — no hard-coded colors, all text readable.
|
||||
- [ ] Contrast ratios meet WCAG AA (≥ 4.5:1 for body, ≥ 3:1 for large text).
|
||||
- [ ] Interactive elements have visible `hover`, `focus-visible`, and `disabled` states.
|
||||
- [ ] Layout is responsive down to ~360px width without horizontal scroll.
|
||||
- [ ] Animations respect `prefers-reduced-motion` (Tailwind: `motion-safe:` / `motion-reduce:`).
|
||||
- [ ] Spacing is consistent — no one-off `p-[13px]` style values.
|
||||
Reference in New Issue
Block a user