From bd68a327080d6654e43800cdc5caa75415a224a3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 26 Apr 2026 23:13:30 -0500 Subject: [PATCH] Split AGENTS.md into skills; compress to 358 lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .agents/skills/capacitor-compat/SKILL.md | 73 + .agents/skills/ci-cd-publishing/SKILL.md | 157 +++ .agents/skills/file-uploads/SKILL.md | 83 ++ .agents/skills/git-workflow/SKILL.md | 66 + .agents/skills/nip19-routing/SKILL.md | 146 ++ .agents/skills/nostr-encryption/SKILL.md | 81 ++ .agents/skills/nostr-kinds/SKILL.md | 115 ++ .agents/skills/nostr-publishing/SKILL.md | 115 ++ .agents/skills/nostr-queries/SKILL.md | 117 ++ .agents/skills/nostr-relay-pools/SKILL.md | 92 ++ .agents/skills/theming/SKILL.md | 127 ++ AGENTS.md | 1469 +++------------------ 12 files changed, 1345 insertions(+), 1296 deletions(-) create mode 100644 .agents/skills/capacitor-compat/SKILL.md create mode 100644 .agents/skills/ci-cd-publishing/SKILL.md create mode 100644 .agents/skills/file-uploads/SKILL.md create mode 100644 .agents/skills/git-workflow/SKILL.md create mode 100644 .agents/skills/nip19-routing/SKILL.md create mode 100644 .agents/skills/nostr-encryption/SKILL.md create mode 100644 .agents/skills/nostr-kinds/SKILL.md create mode 100644 .agents/skills/nostr-publishing/SKILL.md create mode 100644 .agents/skills/nostr-queries/SKILL.md create mode 100644 .agents/skills/nostr-relay-pools/SKILL.md create mode 100644 .agents/skills/theming/SKILL.md diff --git a/.agents/skills/capacitor-compat/SKILL.md b/.agents/skills/capacitor-compat/SKILL.md new file mode 100644 index 00000000..9f1b6829 --- /dev/null +++ b/.agents/skills/capacitor-compat/SKILL.md @@ -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) + +- **`` file downloads** — programmatically creating an anchor with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely. +- **`` 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 `` 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. diff --git a/.agents/skills/ci-cd-publishing/SKILL.md b/.agents/skills/ci-cd-publishing/SKILL.md new file mode 100644 index 00000000..d0ddec56 --- /dev/null +++ b/.agents/skills/ci-cd-publishing/SKILL.md @@ -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://?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/.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 ` — relay for NIP-46 communication (default: `wss://relay.ditto.pub`) +- `--name ` — app name shown to the signer (default: `Ditto`) +- `--timeout ` — 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`). diff --git a/.agents/skills/file-uploads/SKILL.md b/.agents/skills/file-uploads/SKILL.md new file mode 100644 index 00000000..cb891662 --- /dev/null +++ b/.agents/skills/file-uploads/SKILL.md @@ -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 `` 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. diff --git a/.agents/skills/git-workflow/SKILL.md b/.agents/skills/git-workflow/SKILL.md new file mode 100644 index 00000000..1d56a2b5 --- /dev/null +++ b/.agents/skills/git-workflow/SKILL.md @@ -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 ''` — find commits that touched a specific string. +- `git log --oneline -- path/to/file` — list all commits touching a file. +- `git blame -L , -- 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. diff --git a/.agents/skills/nip19-routing/SKILL.md b/.agents/skills/nip19-routing/SKILL.md new file mode 100644 index 00000000..8b056ba3 --- /dev/null +++ b/.agents/skills/nip19-routing/SKILL.md @@ -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 +Profile + +// To an addressable event (article, product, …) + + Open + + +// To a specific event of any kind, with relay hints +Open +``` + +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`. diff --git a/.agents/skills/nostr-encryption/SKILL.md b/.agents/skills/nostr-encryption/SKILL.md new file mode 100644 index 00000000..11d0d601 --- /dev/null +++ b/.agents/skills/nostr-encryption/SKILL.md @@ -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. diff --git a/.agents/skills/nostr-kinds/SKILL.md b/.agents/skills/nostr-kinds/SKILL.md new file mode 100644 index 00000000..ca9cb180 --- /dev/null +++ b/.agents/skills/nostr-kinds/SKILL.md @@ -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 `` 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 ? : …`. + - 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. diff --git a/.agents/skills/nostr-publishing/SKILL.md b/.agents/skills/nostr-publishing/SKILL.md new file mode 100644 index 00000000..cccb99e3 --- /dev/null +++ b/.agents/skills/nostr-publishing/SKILL.md @@ -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 You must be logged in to post.; + + return ( + + ); +} +``` + +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. diff --git a/.agents/skills/nostr-queries/SKILL.md b/.agents/skills/nostr-queries/SKILL.md new file mode 100644 index 00000000..0ac7bf16 --- /dev/null +++ b/.agents/skills/nostr-queries/SKILL.md @@ -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. diff --git a/.agents/skills/nostr-relay-pools/SKILL.md b/.agents/skills/nostr-relay-pools/SKILL.md new file mode 100644 index 00000000..960902f8 --- /dev/null +++ b/.agents/skills/nostr-relay-pools/SKILL.md @@ -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). diff --git a/.agents/skills/theming/SKILL.md b/.agents/skills/theming/SKILL.md new file mode 100644 index 00000000..cce5d6d3 --- /dev/null +++ b/.agents/skills/theming/SKILL.md @@ -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/` — variable fonts (preferred; one file, all weights) + - `@fontsource/` — 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 ( + + ); +} +``` + +## 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 +
+
+ {/* content */} +
+``` + +## 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. diff --git a/AGENTS.md b/AGENTS.md index 0f92028e..af1858cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,323 +1,76 @@ # Project Overview -This project is a Nostr client application built with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify. +Ditto is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor. ## Technology Stack -- **React 18.x**: Stable version of React with hooks, concurrent rendering, and improved performance -- **TailwindCSS 3.x**: Utility-first CSS framework for styling -- **Vite**: Fast build tool and development server -- **shadcn/ui**: Unstyled, accessible UI components built with Radix UI and Tailwind -- **Nostrify**: Nostr protocol framework for Deno and web -- **React Router**: For client-side routing with BrowserRouter and ScrollToTop functionality -- **TanStack Query**: For data fetching, caching, and state management -- **TypeScript**: For type-safe JavaScript development -- **Capacitor**: Native iOS and Android shell wrapping the web app +- **React 19.x** — hooks, concurrent rendering, ref-as-prop +- **TailwindCSS 3.x** — utility-first styling +- **Vite** — dev server and production bundler +- **shadcn/ui** — unstyled accessible components on Radix UI + Tailwind (48+ primitives in `@/components/ui`) +- **Nostrify** (`@nostrify/react`) — Nostr protocol framework +- **React Router** — client-side routing with `BrowserRouter` and automatic scroll-to-top +- **TanStack Query** — data fetching, caching, state +- **TypeScript** — type-safe JS. **Never use the `any` type.** +- **Capacitor** — native iOS/Android wrapper around the web app ## Project Structure -- `/src/components/`: UI components including NostrProvider for Nostr integration - - `/src/components/ui/`: shadcn/ui components (48+ components available) - - `/src/components/auth/`: Authentication-related components (LoginArea, LoginDialog, etc.) - - `/src/components/dm/`: Direct messaging UI components (DMMessagingInterface, DMConversationList, DMChatArea) - - Zap components: `ZapButton`, `ZapDialog`, `WalletModal` for Lightning payments -- `/src/hooks/`: Custom hooks including: - - `useNostr`: Core Nostr protocol integration - - `useAuthor`: Fetch user profile data by pubkey - - `useCurrentUser`: Get currently logged-in user - - `useNostrPublish`: Publish events to Nostr - - `useUploadFile`: Upload files via Blossom servers - - `useAppContext`: Access global app configuration - - `useTheme`: Theme management - - `useToast`: Toast notifications - - `useLocalStorage`: Persistent local storage - - `useLoggedInAccounts`: Manage multiple accounts - - `useLoginActions`: Authentication actions - - `useIsMobile`: Responsive design helper - - `useZaps`: Lightning zap functionality with payment processing - - `useWallet`: Unified wallet detection (WebLN + NWC) - - `useNWC`: Nostr Wallet Connect connection management - - `useNWCContext`: Access NWC context provider - - `useShakespeare`: AI chat completions with Shakespeare AI API -- `/src/pages/`: Page components used by React Router (Index, NotFound) -- `/src/lib/`: Utility functions and shared logic -- `/src/contexts/`: React context providers (AppContext, NWCContext, DMContext) - - `useDMContext`: Hook exported from DMContext for direct messaging (NIP-04 & NIP-17) - - `useConversationMessages`: Hook exported from DMContext for paginated messages -- `/src/test/`: Testing utilities including TestApp component -- `/public/`: Static assets -- `App.tsx`: Main app component with provider setup (**CRITICAL**: this file is **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider` and other important providers - **read this file before making changes**. Changes are usually not necessary unless adding new providers. Changing this file may break the application) -- `AppRouter.tsx`: React Router configuration +- `/src/components/` — UI components. `ui/` holds shadcn primitives; `auth/` holds login components; `dm/` holds direct-messaging UI (built on `DMContext`). +- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Key ones: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`, `useZaps`, `useWallet`, `useNWC`, `useShakespeare`. +- `/src/pages/` — page components wired into `AppRouter.tsx`. The catch-all `/:nip19` route is handled by `NIP19Page.tsx` (see the `nip19-routing` skill). +- `/src/lib/` — utility functions and shared logic. +- `/src/contexts/` — React context providers (`AppContext`, `NWCContext`, `DMContext`). +- `/src/test/` — testing utilities including the `TestApp` wrapper. +- `/public/` — static assets. +- `App.tsx` — **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider`, `AppProvider`, `NostrLoginProvider`, `NWCContext`, `DMContext`. Read before editing; changes are rarely needed. +- `AppRouter.tsx` — React Router configuration. +- `NIP.md` — custom kinds documented by this project (see the `nostr-kinds` skill). -**CRITICAL**: Always read the files mentioned above before making changes, as they contain important setup and configuration for the application. Never directly write to these files without first reading their contents. +**Always read an existing file before modifying it.** Never overwrite `App.tsx`, `AppRouter.tsx`, or `NostrProvider` without first reading their contents. ## UI Components -The project uses shadcn/ui components located in `@/components/ui`. These are unstyled, accessible components built with Radix UI and styled with Tailwind CSS. Available components include: - -- **Accordion**: Vertically collapsing content panels -- **Alert**: Displays important messages to users -- **AlertDialog**: Modal dialog for critical actions requiring confirmation -- **AspectRatio**: Maintains consistent width-to-height ratio -- **Avatar**: User profile pictures with fallback support -- **Badge**: Small status descriptors for UI elements -- **Breadcrumb**: Navigation aid showing current location in hierarchy -- **Button**: Customizable button with multiple variants and sizes -- **Calendar**: Date picker component -- **Card**: Container with header, content, and footer sections -- **Carousel**: Slideshow for cycling through elements -- **Chart**: Data visualization component -- **Checkbox**: Selectable input element -- **Collapsible**: Toggle for showing/hiding content -- **Command**: Command palette for keyboard-first interfaces -- **ContextMenu**: Right-click menu component -- **Dialog**: Modal window overlay -- **Drawer**: Side-sliding panel (using vaul) -- **DropdownMenu**: Menu that appears from a trigger element -- **Form**: Form validation and submission handling -- **HoverCard**: Card that appears when hovering over an element -- **InputOTP**: One-time password input field -- **Input**: Text input field -- **Label**: Accessible form labels -- **Menubar**: Horizontal menu with dropdowns -- **NavigationMenu**: Accessible navigation component -- **Pagination**: Controls for navigating between pages -- **Popover**: Floating content triggered by a button -- **Progress**: Progress indicator -- **RadioGroup**: Group of radio inputs -- **Resizable**: Resizable panels and interfaces -- **ScrollArea**: Scrollable container with custom scrollbars -- **Select**: Dropdown selection component -- **Separator**: Visual divider between content -- **Sheet**: Side-anchored dialog component -- **Sidebar**: Navigation sidebar component -- **Skeleton**: Loading placeholder -- **Slider**: Input for selecting a value from a range -- **Switch**: Toggle switch control -- **Table**: Data table with headers and rows -- **Tabs**: Tabbed interface component -- **Textarea**: Multi-line text input -- **Toast**: Toast notification component -- **ToggleGroup**: Group of toggle buttons -- **Toggle**: Two-state button -- **Tooltip**: Informational text that appears on hover - -These components follow a consistent pattern using React's `forwardRef` and use the `cn()` utility for class name merging. Many are built on Radix UI primitives for accessibility and customized with Tailwind CSS. +Components in `@/components/ui` are unstyled, accessible primitives styled with Tailwind. They follow a consistent pattern using `React.forwardRef` and the `cn()` class-merge utility, and many are built on Radix UI primitives. When you need a specific primitive, list the directory (`ls src/components/ui/`) or import from `@/components/ui/` — all common primitives are present (buttons, inputs, dialogs, dropdowns, forms, tables, carousels, sidebars, etc.). ## System Prompt Management -The AI assistant's behavior and knowledge is defined by the AGENTS.md file, which serves as the system prompt. To modify the assistant's instructions or add new project-specific guidelines: - -1. Edit AGENTS.md directly -2. The changes take effect in the next session +The assistant's behavior is defined by this file (`AGENTS.md`). Edit it directly to change guidelines — updates take effect the next session. Specialized workflows live in `/.agents/skills/` as loadable skills, discoverable through the `skill` tool. ## Nostr Protocol Integration -This project comes with custom hooks for querying and publishing events on the Nostr network. +### The `useNostr` Hook -### Nostr Implementation Guidelines +```ts +import { useNostr } from '@nostrify/react'; -- Always check the full list of existing NIPs before implementing any Nostr features to see what kinds are currently in use across all NIPs. -- If any existing kind or NIP might offer the required functionality, read the relevant NIPs to investigate thoroughly. Several NIPs may need to be read before making a decision. -- Only generate new kind numbers if no existing suitable kinds are found after comprehensive research. - -Knowing when to create a new kind versus reusing an existing kind requires careful judgement. Introducing new kinds means the project won't be interoperable with existing clients. But deviating too far from the schema of a particular kind can cause different interoperability issues. - -#### Choosing Between Existing NIPs and Custom Kinds - -When implementing features that could use existing NIPs, follow this decision framework: - -1. **Thorough NIP Review**: Before considering a new kind, always perform a comprehensive review of existing NIPs and their associated kinds. Get an overview of all NIPs, and then read specific NIPs and kind documentation to investigate any potentially relevant NIPs or kinds in detail. The goal is to find the closest existing solution. - -2. **Prioritize Existing NIPs**: Always prefer extending or using existing NIPs over creating custom kinds, even if they require minor compromises in functionality. - -3. **Interoperability vs. Perfect Fit**: Consider the trade-off between: - - **Interoperability**: Using existing kinds means compatibility with other Nostr clients - - **Perfect Schema**: Custom kinds allow perfect data modeling but create ecosystem fragmentation - -4. **Extension Strategy**: When existing NIPs are close but not perfect: - - Use the existing kind as the base - - Add domain-specific tags for additional metadata - - Document the extensions in `NIP.md` - -5. **When to Generate Custom Kinds**: - - No existing NIP covers the core functionality - - The data structure is fundamentally different from existing patterns - - The use case requires different storage characteristics (regular vs replaceable vs addressable) - - If you have a tool available to generate a kind, you **MUST** call the tool to generate a new kind rather than picking an arbitrary number - -6. **Custom Kind Publishing**: When publishing events with custom generated kinds, always include a NIP-31 "alt" tag with a human-readable description of the event's purpose. - -**Example Decision Process**: -``` -Need: Equipment marketplace for farmers -Options: -1. NIP-15 (Marketplace) - Too structured for peer-to-peer sales -2. NIP-99 (Classified Listings) - Good fit, can extend with farming tags -3. Custom kind - Perfect fit but no interoperability - -Decision: Use NIP-99 + farming-specific tags for best balance -``` - -#### Tag Design Principles - -When designing tags for Nostr events, follow these principles: - -1. **Kind vs Tags Separation**: - - **Kind** = Schema/structure (how the data is organized) - - **Tags** = Semantics/categories (what the data represents) - - Don't create different kinds for the same data structure - -2. **Use Single-Letter Tags for Categories**: - - **Relays only index single-letter tags** for efficient querying - - Use `t` tags for categorization, not custom multi-letter tags - - Multiple `t` tags allow items to belong to multiple categories - -3. **Relay-Level Filtering**: - - Design tags to enable efficient relay-level filtering with `#t: ["category"]` - - Avoid client-side filtering when relay-level filtering is possible - - Consider query patterns when designing tag structure - -4. **Tag Examples**: - ```json - // ❌ Wrong: Multi-letter tag, not queryable at relay level - ["product_type", "electronics"] - - // ✅ Correct: Single-letter tag, relay-indexed and queryable - ["t", "electronics"] - ["t", "smartphone"] - ["t", "android"] - ``` - -5. **Querying Best Practices**: - ```typescript - // ❌ Inefficient: Get all events, filter in JavaScript - const events = await nostr.query([{ kinds: [30402] }]); - const filtered = events.filter(e => hasTag(e, 'product_type', 'electronics')); - - // ✅ Efficient: Filter at relay level - const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]); - ``` - -#### `t` Tag Filtering for Community-Specific Content - -For applications focused on a specific community or niche, you can use `t` tags to filter events for the target audience. - -**When to Use:** -- ✅ Community apps: "farmers" → `t: "farming"`, "Poland" → `t: "poland"` -- ❌ Generic platforms: Twitter clones, general Nostr clients - -**Implementation:** -```typescript -// Publishing with community tag -createEvent({ - kind: 1, - content: data.content, - tags: [['t', 'farming']] -}); - -// Querying community content -const events = await nostr.query([{ - kinds: [1], - '#t': ['farming'], - limit: 20 -}]); -``` - -### Kind Ranges - -An event's kind number determines the event's behavior and storage characteristics: - -- **Regular Events** (1000 ≤ kind < 10000): Expected to be stored by relays permanently. Used for persistent content like notes, articles, etc. -- **Replaceable Events** (10000 ≤ kind < 20000): Only the latest event per pubkey+kind combination is stored. Used for profile metadata, contact lists, etc. -- **Addressable Events** (30000 ≤ kind < 40000): Identified by pubkey+kind+d-tag combination, only latest per combination is stored. Used for articles, long-form content, etc. - -Kinds below 1000 are considered "legacy" kinds, and may have different storage characteristics based on their kind definition. For example, kind 1 is regular, while kind 3 is replaceable. - -### Content Field Design Principles - -When designing new event kinds, the `content` field should be used for semantically important data that doesn't need to be queried by relays. **Structured JSON data generally shouldn't go in the content field** (kind 0 being an early exception). - -#### Guidelines - -- **Use content for**: Large text, freeform human-readable content, or existing industry-standard JSON formats (Tiled maps, FHIR, GeoJSON) -- **Use tags for**: Queryable metadata, structured data, anything that needs relay-level filtering -- **Empty content is valid**: Many events need only tags with `content: ""` -- **Relays only index tags**: If you need to filter by a field, it must be a tag - -#### Example - -**✅ Good - queryable data in tags:** -```json -{ - "kind": 30402, - "content": "", - "tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]] +function useCustomHook() { + const { nostr } = useNostr(); + // nostr.query(filters) / nostr.event(event) / nostr.req(filters) } ``` -**❌ Bad - structured data in content:** -```json -{ - "kind": 30402, - "content": "{\"title\":\"Camera\",\"price\":250,\"category\":\"photo\"}", - "tags": [["d", "product-123"]] -} -``` +By default `nostr` uses the app's connection pool (reads from one relay, publishes to all configured). For targeted single-relay or relay-group calls, load the **`nostr-relay-pools`** skill. -### Implementing New Event Kinds in the UI +### Kinds, Tags, and NIP.md -When adding support for a new Nostr event kind to the application, the kind must be registered in **multiple locations** across the codebase. Missing any of these will cause the event to render incorrectly in certain views (e.g. showing blank content in quote posts, or "Kind 12345" as a label). +When introducing a new kind, extending an existing NIP with new tags, or registering a kind in the UI (feed cards, detail pages, embedded previews, kind-label maps), load the **`nostr-kinds`** skill. It covers the NIP-vs-custom-kind decision framework, kind ranges, tag design (single-letter indexed tags, content vs. tags), the `NIP.md` documentation requirement, and Ditto's multi-location UI registration checklist. -#### Checklist for adding a new event kind +Summary rules: -1. **Content card component** (`src/components/`): Create a dedicated `` component that renders the event's tags/content appropriately. - -2. **Feed rendering** (`src/components/NoteCard.tsx`): - - Add a `const isMyKind = event.kind === XXXX;` detection flag - - Include it in the appropriate group flag (e.g. `isDevKind`) or add it to the `isTextNote` exclusion list - - Add the content dispatch: `isMyKind ? : ...` - - 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`): - - Add the same `isMyKind` detection flag and include it in the group/exclusion flags (mirrors 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** -- these are separate maps that resolve kind numbers to human-readable strings. 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`) -- these are the small preview cards shown inside quote posts, reply context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal card (author + title/content preview + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags rather than in the `content` field (e.g. kind 20 photos via `imeta` tags) may need attachment indicator logic added to `EmbeddedNoteCard`. - - > **Note**: Do not confuse these with the `compact` prop on `NoteCard`. The `compact` prop simply hides action buttons on a full `NoteCard`; `EmbeddedNote`/`EmbeddedNaddr` are entirely different components with their own rendering logic. - -7. **Reply composer** (`src/components/ReplyComposeModal.tsx`): - - The `EmbeddedPost` component delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components — no per-kind registration needed - -#### Why so many places? - -These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points. - -### NIP.md - -The file `NIP.md` is used by this project to define a custom Nostr protocol document. If the file doesn't exist, it means this project doesn't have any custom kinds associated with it. - -Whenever new kinds are generated, the `NIP.md` file in the project must be created or updated to document the custom event schema. Whenever the schema of one of these custom events changes, `NIP.md` must also be updated accordingly. +- **Kind ranges:** Regular (1000-9999), Replaceable (10000-19999), Addressable (30000-39999). Kinds below 1000 are legacy with per-kind storage semantics. +- **Prefer existing NIPs** over custom kinds. If you must mint a new kind, use an available kind-generation tool (never pick a number arbitrarily) and include a NIP-31 `alt` tag. +- **Relays only index single-letter tags.** Use `t` tags for categories. +- **Use `content` for** freeform text or industry-standard JSON only. Structured queryable data belongs in tags. +- **Update `NIP.md`** whenever you mint or modify a custom kind. ### Nostr Security Model Nostr is permissionless — **anyone can publish any event**, and `nsec` keys sit in plaintext `localStorage`, so an XSS is an instant key-theft. Core rules: - **Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. If HTML must come from event data, run it through DOMPurify at the parse layer. -- **Sanitize every event-sourced URL** with `sanitizeUrl()` from `@/lib/sanitizeUrl` before it lands in `href`, `src`, `srcSet`, `poster`, iframe `src`, or CSS `url()`. It returns `undefined` for anything that isn't a well-formed `https:` URL. Prefer sanitizing at the parse layer, not the render site. -- **Sanitize event-sourced strings interpolated into CSS** with `sanitizeCssString()` from `@/lib/fontLoader` (allowlists Unicode letters/numbers, spaces, hyphens, underscores, apostrophes, periods). URLs in CSS `url()` still go through `sanitizeUrl()`. +- **Sanitize every event-sourced URL** with `sanitizeUrl()` from `@/lib/sanitizeUrl` before it lands in `href`, `src`, `srcSet`, `poster`, iframe `src`, or CSS `url()`. It returns `undefined` for anything that isn't a well-formed `https:` URL. Prefer sanitizing at the parse layer. +- **Sanitize event-sourced strings interpolated into CSS** with `sanitizeCssString()` from `@/lib/fontLoader`. URLs in CSS `url()` still go through `sanitizeUrl()`. - **Filter trust-sensitive queries by `authors`**. Without it, any event matching your kind/d-tag comes back — an attacker publishes a fake admin action and your UI trusts it. - **Routes for addressable/replaceable events must carry the author in the path** (e.g. `/article/:npub/:slug`), so the route handler can include `authors` in its filter. - **Don't filter by `authors` for public UGC** (kind 1 notes, reactions, zaps, discovery feeds) — anyone can post there by design. @@ -332,215 +85,29 @@ nostr.query([{ kinds: [30078], '#d': ['pathos-organizers'], limit: 1 }]); nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['pathos-organizers'], limit: 1 }]); ``` -Load the **`nostr-security` skill** for the full threat model, NIP-72 moderation walkthrough, sanitization helper examples, and the pre-merge checklist. +Load the **`nostr-security`** skill for the full threat model, NIP-72 moderation walkthrough, sanitization helper examples, and the pre-merge checklist. -### The `useNostr` Hook +### Querying Nostr Data -The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively. - -```typescript -import { useNostr } from '@nostrify/react'; - -function useCustomHook() { - const { nostr } = useNostr(); - - // ... -} -``` - -### Connecting to Multiple Nostr Relays - -By default, the `nostr` object from `useNostr` uses a pool configuration that reads data from 1 relay and publishes to all configured relays. However, you can connect to specific relays or groups of relays for more granular control: - -#### Single Relay Connection - -To read and publish from one specific relay, use `nostr.relay()` with a WebSocket URL: - -```typescript -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 specific relay only - const events = await relay.query([{ kinds: [1], limit: 20 }]); - - // Publish to this specific relay only - await relay.event({ kind: 1, content: 'Hello from specific relay!' }); -} -``` - -#### Multiple Relay Group - -To read and publish from a specific set of relays, use `nostr.group()` with an array of relay URLs: - -```typescript -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 - const events = await relayGroup.query([{ kinds: [1], limit: 20 }]); - - // Publish to all relays in the group - await relayGroup.event({ kind: 1, content: 'Hello from relay group!' }); -} -``` - -#### API Consistency - -Both `relay` and `group` objects have the same API as the main `nostr` object, including: - -- `.query()` - Query events with filters -- `.req()` - Create subscriptions -- `.event()` - Publish events -- All other Nostr protocol methods - -#### Use Cases - -**Single Relay (`nostr.relay()`):** -- Testing specific relay behavior -- Querying relay-specific content -- Debugging connectivity issues -- Working with specialized relays - -**Relay Group (`nostr.group()`):** -- Querying from trusted relay sets -- Publishing to specific communities -- Load balancing across relay subsets -- Geographic relay optimization - -**Default Pool (`nostr`):** -- General application queries -- Maximum reach for publishing -- Default user experience -- Simplified relay management - -### Query Nostr Data with `useNostr` and Tanstack Query - -When querying Nostr, the best practice is to create custom hooks that combine `useNostr` and `useQuery` to get the required data. - -```typescript -import { useNostr } from '@nostrify/react'; -import { useQuery } from '@tanstack/query'; +The standard pattern is a custom hook combining `useNostr` and `useQuery`: +```ts function usePosts() { const { nostr } = useNostr(); - return useQuery({ queryKey: ['posts'], - queryFn: async (c) => { - const events = await nostr.query([{ kinds: [1], limit: 20 }]); - return events; // these events could be transformed into another format - }, + queryFn: async (c) => nostr.query([{ kinds: [1], limit: 20 }], { signal: c.signal }), }); } ``` -### Efficient Query Design +**Efficient query design matters** — each query costs relay capacity and may count against rate limits. Combine related kinds into a single filter (`kinds: [1, 6, 16]`) and split by type in JavaScript; don't fan out into parallel round-trips. -**Critical**: Always minimize the number of separate queries to avoid rate limiting and improve performance. Combine related queries whenever possible. - -**✅ Efficient - Single query with multiple kinds:** -```typescript -// Query multiple event types in one request -const events = await nostr.query([ - { - kinds: [1, 6, 16], // All repost kinds in one query - '#e': [eventId], - limit: 150, - } -]); - -// Separate by type 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 - Multiple separate queries:** -```typescript -// This creates unnecessary load and can trigger rate limiting -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] }]), -]); -``` - -**Query Optimization Guidelines:** -1. **Combine kinds**: Use `kinds: [1, 6, 16]` instead of separate queries -2. **Use multiple filters**: When you need different tag filters, use multiple filter objects in a single query -3. **Adjust limits**: When combining queries, increase the limit appropriately -4. **Filter in JavaScript**: Separate event types after receiving results rather than making multiple requests -5. **Consider relay capacity**: Each query consumes relay resources and may count against rate limits - -The data may be transformed into a more appropriate format if needed, and multiple calls to `nostr.query()` may be made in a single queryFn. - -### Event Validation - -When querying events, if the event kind being returned has required tags or required JSON fields in the content, the events should be filtered through a validator function. This is not generally needed for kinds such as 1, where all tags are optional and the content is freeform text, but is especially useful for custom kinds as well as kinds with strict requirements. - -```typescript -// Example validator function for NIP-52 calendar events -function validateCalendarEvent(event: NostrEvent): boolean { - // Check if it's a calendar event kind - if (![31922, 31923].includes(event.kind)) return false; - - // Check for required tags according to NIP-52 - const d = event.tags.find(([name]) => name === 'd')?.[1]; - const title = event.tags.find(([name]) => name === 'title')?.[1]; - const start = event.tags.find(([name]) => name === 'start')?.[1]; - - // All calendar events require 'd', 'title', and 'start' tags - if (!d || !title || !start) return false; - - // Additional validation for date-based events (kind 31922) - if (event.kind === 31922) { - // start tag should be in YYYY-MM-DD format for date-based events - const dateRegex = /^\d{4}-\d{2}-\d{2}$/; - if (!dateRegex.test(start)) return false; - } - - // Additional validation for time-based events (kind 31923) - if (event.kind === 31923) { - // start tag should be a unix timestamp for time-based events - const timestamp = parseInt(start); - if (isNaN(timestamp) || timestamp <= 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 }]); - - // Filter events through validator to ensure they meet NIP-52 requirements - return events.filter(validateCalendarEvent); - }, - }); -} -``` +For kinds with required tags or strict schemas, filter results through a validator before returning. Load the **`nostr-queries`** skill for patterns, examples, and a NIP-52 validator walkthrough. ### The `useAuthor` Hook -To display profile data for a user by their Nostr pubkey (such as an event author), use the `useAuthor` hook. +Fetch kind 0 profile metadata for a pubkey: ```tsx import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; @@ -553,488 +120,172 @@ function Post({ event }: { event: NostrEvent }) { const displayName = metadata?.name ?? genUserName(event.pubkey); const profileImage = metadata?.picture; - - // ...render elements with this data } ``` -### `NostrMetadata` type +`NostrMetadata` (from `@nostrify/nostrify`) covers the standard kind-0 fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`, `nip05`, `lud06`, `lud16`, `bot`. Read the type definition from the package for the exact field list. -```ts -/** Kind 0 metadata. */ -interface NostrMetadata { - /** A short description of the user. */ - about?: string; - /** A URL to a wide (~1024x768) picture to be optionally displayed in the background of a profile screen. */ - banner?: string; - /** A boolean to clarify that the content is entirely or partially the result of automation, such as with chatbots or newsfeeds. */ - bot?: boolean; - /** An alternative, bigger name with richer characters than `name`. `name` should always be set regardless of the presence of `display_name` in the metadata. */ - display_name?: string; - /** A bech32 lightning address according to NIP-57 and LNURL specifications. */ - lud06?: string; - /** An email-like lightning address according to NIP-57 and LNURL specifications. */ - lud16?: string; - /** A short name to be displayed for the user. */ - name?: string; - /** An email-like Nostr address according to NIP-05. */ - nip05?: string; - /** A URL to the user's avatar. */ - picture?: string; - /** A web URL related in any way to the event author. */ - website?: string; -} -``` +### Publishing Events -### The `useNostrPublish` Hook - -To publish events, use the `useNostrPublish` hook in this project. This hook automatically adds a "client" tag to published events. +Publishes go through `useNostrPublish`, which auto-adds a `client` tag. Always guard with `useCurrentUser`: ```tsx -import { useState } from 'react'; - -import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; -export function MyComponent() { - const [ data, setData] = useState>({}); - +export function PostForm() { const { user } = useCurrentUser(); const { mutate: createEvent } = useNostrPublish(); - const handleSubmit = () => { - createEvent({ kind: 1, content: data.content }); - }; + if (!user) return You must be logged in.; - if (!user) { - return You must be logged in to use this form.; - } - - return ( -
- {/* ...some input fields */} -
- ); + return ; } ``` -The `useCurrentUser` hook should be used to ensure that the user is logged in before they are able to publish Nostr events. +**Mutating replaceable or addressable events requires a read-modify-write cycle.** Never read from the TanStack Query cache before mutating — use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` and pass the fetched event as `prev` so `useNostrPublish` can preserve `published_at`: -### Mutating Replaceable 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, then publish a new version. **Never read from 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, -}); +```ts +const prev = await fetchFreshEvent(nostr, { kinds: [10003], authors: [user.pubkey] }); +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. +**Publishing new addressable events with user-derived d-tags (slugs, etc.) requires a collision check** — otherwise you silently overwrite an existing event with the same `(kind, pubkey, d)` triple. -#### The `prev` Property on Event Templates - -`useNostrPublish` accepts an optional `prev` property on the event template. This is the **previous version** of the event being replaced. The hook uses it to automatically manage the `published_at` tag (NIP-24) for replaceable and addressable events: - -- **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 before publishing** 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 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. +Load the **`nostr-publishing`** skill for the full pattern: the `prev` property contract, bookmark/follow/mute examples, and d-tag collision prevention. ### Nostr Login -To enable login with Nostr, simply use the `LoginArea` component already included in this project. +Use the `LoginArea` component (already wired into the project). It renders "Log in" / "Sign Up" buttons when logged out and an account switcher when logged in. **Don't wrap it in conditional logic.** ```tsx -import { LoginArea } from "@/components/auth/LoginArea"; +import { LoginArea } from '@/components/auth/LoginArea'; -function MyComponent() { - return ( -
- {/* other components ... */} - - -
- ); -} + ``` -The `LoginArea` component handles all the login-related UI and interactions, including displaying login dialogs, sign up functionality, and switching between accounts. It should not be wrapped in any conditional logic. +`LoginArea` is inline-flex by default. Pass `flex` or `w-full` to expand it; otherwise set a sensible `max-w-*`. -`LoginArea` displays both "Log in" and "Sign Up" buttons when the user is logged out, and changes to an account switcher once the user is logged in. It is an inline-flex element by default. To make it expand to the width of its container, you can pass a className like `flex` (to make it a block element) or `w-full`. If it is left as inline-flex, it's recommended to set a max width. +**Social apps should include a profile/account menu in the main navigation** for access to settings, profile editing, and logout — don't only show `LoginArea` in logged-out states. -**Important**: Social applications should include a profile menu button in the main interface (typically in headers/navigation) to provide access to account settings, profile editing, and logout functionality. Don't only show `LoginArea` in logged-out states. +For an Edit Profile form, drop in `` from `@/components/EditProfileForm` — no props, works automatically. -### `npub`, `naddr`, and other Nostr addresses +### NIP-19 Identifiers -Nostr defines a set of bech32-encoded identifiers in NIP-19. Their prefixes and purposes: +Nostr uses bech32 identifiers (`npub1`, `nprofile1`, `note1`, `nevent1`, `naddr1`, `nsec1`). **All NIP-19 identifiers are routed at the URL root (`/:nip19`)**, handled by `src/pages/NIP19Page.tsx` — never nest them under `/note/`, `/profile/`, etc. -- `npub1`: **public keys** - Just the 32-byte public key, no additional metadata -- `nsec1`: **private keys** - Secret keys (should never be displayed publicly) -- `note1`: **event IDs** - Just the 32-byte event ID (hex), no additional metadata -- `nevent1`: **event pointers** - Event ID plus optional relay hints and author pubkey -- `nprofile1`: **profile pointers** - Public key plus optional relay hints and petname -- `naddr1`: **addressable event coordinates** - For parameterized replaceable events (kind 30000-39999) -- `nrelay1`: **relay references** - Relay URLs (deprecated) - -#### Key Differences Between Similar Identifiers - -**`note1` vs `nevent1`:** -- `note1`: Contains only the event ID (32 bytes) - specifically for kind:1 events (Short Text Notes) as defined in NIP-10 -- `nevent1`: Contains event ID plus optional relay hints and author pubkey - for any event kind -- Use `note1` for simple references to text notes and threads -- Use `nevent1` when you need to include relay hints or author context for any event type - -**`npub1` vs `nprofile1`:** -- `npub1`: Contains only the public key (32 bytes) -- `nprofile1`: Contains public key plus optional relay hints and petname -- Use `npub1` for simple user references -- Use `nprofile1` when you need to include relay hints or display name context - -#### NIP-19 Routing Implementation - -**Critical**: NIP-19 identifiers should be handled at the **root level** of URLs (e.g., `/note1...`, `/npub1...`, `/naddr1...`), NOT nested under paths like `/note/note1...` or `/profile/npub1...`. - -This project includes a boilerplate `NIP19Page` component that provides the foundation for handling all NIP-19 identifier types at the root level. The component is configured in the routing system and ready for AI agents to populate with specific functionality. - -**How it works:** - -1. **Root-Level Route**: The route `/:nip19` in `AppRouter.tsx` catches all NIP-19 identifiers -2. **Automatic Decoding**: The `NIP19Page` component automatically decodes the identifier using `nip19.decode()` -3. **Type-Specific Sections**: Different sections are rendered based on the identifier type: - - `npub1`/`nprofile1`: Profile section with placeholder for profile view - - `note1`: Note section with placeholder for kind:1 text note view - - `nevent1`: Event section with placeholder for any event type view - - `naddr1`: Addressable event section with placeholder for articles, marketplace items, etc. -4. **Error Handling**: Invalid, vacant, or unsupported identifiers show 404 NotFound page -5. **Ready for Population**: Each section includes comments indicating where AI agents should implement specific functionality - -**Example URLs that work automatically:** -- `/npub1abc123...` - User profile (needs implementation) -- `/note1def456...` - Kind:1 text note (needs implementation) -- `/nevent1ghi789...` - Any event with relay hints (needs implementation) -- `/naddr1jkl012...` - Addressable event (needs implementation) - -**Features included:** -- Basic NIP-19 identifier decoding and routing -- Type-specific sections for different identifier types -- Error handling for invalid identifiers -- Responsive container structure -- Comments indicating where to implement specific views - -**Error handling:** -- Invalid NIP-19 format → 404 NotFound -- Unsupported identifier types (like `nsec1`) → 404 NotFound -- Empty or missing identifiers → 404 NotFound - -To implement NIP-19 routing in your Nostr application: - -1. **The NIP19Page boilerplate is already created** - populate sections with specific functionality -2. **The route is already configured** in `AppRouter.tsx` -3. **Error handling is built-in** - all edge cases show appropriate 404 responses -4. **Add specific components** for profile views, event displays, etc. as needed - -#### Event Type Distinctions - -**`note1` identifiers** are specifically for **kind:1 events** (Short Text Notes) as defined in NIP-10: "Text Notes and Threads". These are the basic social media posts in Nostr. - -**`nevent1` identifiers** can reference any event kind and include additional metadata like relay hints and author pubkey. Use `nevent1` when: -- The event is not a kind:1 text note -- You need to include relay hints for better discoverability -- You want to include author context - -#### Use in Filters - -The base Nostr protocol uses hex string identifiers when filtering by event IDs and pubkeys. Nostr filters only accept hex strings. +**Filters only accept hex.** Always decode before querying: ```ts -// ❌ Wrong: naddr is not decoded -const events = await nostr.query( - [{ ids: [naddr] }], -); -``` - -Corrected example: - -```ts -// Import nip19 from nostr-tools import { nip19 } from 'nostr-tools'; -// Decode a NIP-19 identifier const decoded = nip19.decode(value); +if (decoded.type !== 'naddr') throw new Error('Unsupported identifier'); +const { kind, pubkey, identifier } = decoded.data; -// Optional: guard certain types (depending on the use-case) -if (decoded.type !== 'naddr') { - throw new Error('Unsupported Nostr identifier'); -} - -// Get the addr object -const naddr = decoded.data; - -// ✅ Correct: naddr is expanded into the correct filter -const events = await nostr.query( - [{ - kinds: [naddr.kind], - authors: [naddr.pubkey], - '#d': [naddr.identifier], - }], -); +nostr.query([{ + kinds: [kind], + authors: [pubkey], // critical for addressable events + '#d': [identifier], +}]); ``` -#### Implementation Guidelines +Never treat `nsec1` or unknown prefixes as anything but a 404. -1. **Always decode NIP-19 identifiers** before using them in queries -2. **Use the appropriate identifier type** based on your needs: - - Use `note1` for kind:1 text notes specifically - - Use `nevent1` when including relay hints or for non-kind:1 events - - Use `naddr1` for addressable events (always includes author pubkey for security) -3. **Handle different identifier types** appropriately: - - `npub1`/`nprofile1`: Display user profiles - - `note1`: Display kind:1 text notes specifically - - `nevent1`: Display any event with optional relay context - - `naddr1`: Display addressable events (articles, marketplace items, etc.) -4. **Security considerations**: Always use `naddr1` for addressable events instead of just the `d` tag value, as `naddr1` contains the author pubkey needed to create secure filters -5. **Error handling**: Gracefully handle invalid or unsupported NIP-19 identifiers with 404 responses - -### Nostr Edit Profile - -To include an Edit Profile form, place the `EditProfileForm` component in the project: - -```tsx -import { EditProfileForm } from "@/components/EditProfileForm"; - -function EditProfilePage() { - return ( -
- {/* you may want to wrap this in a layout or include other components depending on the project ... */} - - -
- ); -} -``` - -The `EditProfileForm` component displays just the form. It requires no props, and will "just work" automatically. - -### Uploading Files on Nostr - -Use the `useUploadFile` hook to upload files. This hook uses Blossom servers for file storage and returns NIP-94 compatible tags. - -```tsx -import { useUploadFile } from "@/hooks/useUploadFile"; - -function MyComponent() { - const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); - - const handleUpload = async (file: File) => { - try { - // Provides an array of NIP-94 compatible tags - // The first tag in the array contains the URL - const [[_, url]] = await uploadFile(file); - // ...use the url - } catch (error) { - // ...handle errors - } - }; - - // ...rest of component -} -``` - -To attach files to kind 1 events, each file's URL should be appended to the event's `content`, and an `imeta` tag should be added for each file. For kind 0 events, the URL by itself can be used in relevant fields of the JSON content. - -### Nostr Encryption and Decryption - -The logged-in user has a `signer` object (matching the NIP-07 signer interface) that can be used for encryption and decryption. The signer's nip44 methods handle all cryptographic operations internally, including key derivation and conversation key management, so you never need direct access to private keys. Always use the signer interface for encryption rather than requesting private keys from users, as this maintains security and follows best practices. - -```ts -// Get the current user -const { user } = useCurrentUser(); - -// Optional guard to check that nip44 is available -if (!user.signer.nip44) { - throw new Error("Please upgrade your signer extension to a version that supports NIP-44 encryption"); -} - -// Encrypt message to self -const encrypted = await user.signer.nip44.encrypt(user.pubkey, "hello world"); -// Decrypt message to self -const decrypted = await user.signer.nip44.decrypt(user.pubkey, encrypted) // "hello world" -``` +Load the **`nip19-routing`** skill for identifier-type comparisons, populating `NIP19Page`, building NIP-19 links with the most specific encoder, and security patterns. ### Rendering Rich Text Content -Nostr text notes (kind 1, 11, and 1111) have a plaintext `content` field that may contain URLs, hashtags, and Nostr URIs. These events should render their content using the `NoteContent` component: +Nostr text notes (kind 1, 11, and 1111) have plaintext `content` that may contain URLs, hashtags, and Nostr URIs. Render them with the `NoteContent` component: ```tsx -import { NoteContent } from "@/components/NoteContent"; +import { NoteContent } from '@/components/NoteContent'; -export function Post(/* ...props */) { - // ... - - return ( - -
- -
-
- ); -} +
+ +
``` +### Specialized Workflows + +Load the matching skill when the feature requires it: + +- **`file-uploads`** — `useUploadFile` + Blossom + NIP-94 `imeta` tags. +- **`nostr-encryption`** — NIP-44 / NIP-04 via the user's signer (DMs, gift wraps, private content). +- **`nostr-relay-pools`** — `nostr.relay(url)` / `nostr.group([urls])` for targeted queries. +- **`nostr-comments`** — Ditto's threaded comments (NIP-10 for kind 1, NIP-22 for everything else). +- **`nostr-direct-messages`** — DM implementation via `DMContext` (NIP-04 + NIP-17). +- **`nostr-infinite-scroll`** — feed pagination patterns. +- **`nip85-stats`** — NIP-85 trusted-assertion stats (followers, zap totals, etc.). +- **`ai-chat`** — Shakespeare AI streaming chat interfaces. + ## App Configuration -The project includes an `AppProvider` that manages global application state including theme and NIP-65 relay configuration. The default configuration includes: +The `AppProvider` manages global state (theme, NIP-65 relay list, Blossom servers, etc.) persisted to local storage. Default relay config: ```typescript -const defaultConfig: AppConfig = { - theme: "light", - relayMetadata: { - relays: [ - { url: 'wss://relay.ditto.pub', read: true, write: true }, - { url: 'wss://relay.primal.net', read: true, write: true }, - { url: 'wss://relay.damus.io', read: true, write: true }, - ], - updatedAt: 0, - }, -}; -``` - -The app uses NIP-65 compatible relay management with automatic sync when users log in. Local storage persists user preferences and relay configurations. - -### Adding a New AppConfig Value - -Adding a new configuration field requires updates in **three places**. Missing any of them will cause build failures or runtime issues. - -1. **TypeScript interface** (`src/contexts/AppContext.ts`): Add the field to the `AppConfig` interface with a JSDoc comment. - -2. **Zod schema** (`src/lib/schemas.ts`): Add the same field to `AppConfigSchema`. The `DittoConfigSchema` (used to validate the build-time `ditto.json` file) is derived from `AppConfigSchema` with `.strict()` mode, so any field present in `ditto.json` but missing from the Zod schema will cause a build error. - -3. **Default value** (`src/contexts/AppContext.ts`): If the field is required (not optional), add a default value in `defaultConfig`. Optional fields (`?` in the interface, `.optional()` in Zod) can be omitted from the default. - -### Relay Management - -The project includes a complete NIP-65 relay management system: - -- **RelayListManager**: Component for managing multiple relays with read/write permissions -- **NostrSync**: Automatically syncs user's NIP-65 relay list when they log in -- **Automatic Publishing**: Changes to relay configuration are automatically published as NIP-65 events when the user is logged in - -Use the `RelayListManager` component to provide relay management interfaces: - -```tsx -import { RelayListManager } from '@/components/RelayListManager'; - -function SettingsPage() { - return ( -
-

Relay Settings

- -
- ); +relayMetadata: { + relays: [ + { url: 'wss://relay.ditto.pub', read: true, write: true }, + { url: 'wss://relay.primal.net', read: true, write: true }, + { url: 'wss://relay.damus.io', read: true, write: true }, + ], + updatedAt: 0, } ``` +### Adding a New AppConfig Value + +Adding a new configuration field requires updates in **three places**. Missing any will cause build failures or runtime issues. + +1. **TypeScript interface** (`src/contexts/AppContext.ts`) — add the field to the `AppConfig` interface with a JSDoc comment. +2. **Zod schema** (`src/lib/schemas.ts`) — add the same field to `AppConfigSchema`. `DittoConfigSchema` (validates build-time `ditto.json`) is derived from `AppConfigSchema` with `.strict()` mode, so any field in `ditto.json` missing from the Zod schema causes a build error. +3. **Default value** (`src/contexts/AppContext.ts`) — if the field is required, add a default in `defaultConfig`. Optional fields (`?` in the interface, `.optional()` in Zod) can be omitted. + +### Relay Management + +- **`NostrSync`** auto-loads the user's NIP-65 relay list on login and writes it into `AppContext`. +- **Automatic publishing** — updating the relay config publishes a new kind 10002 event when the user is logged in. +- **`RelayListManager`** (`src/components/RelayListManager.tsx`) is a drop-in settings UI. + ## Routing -The project uses React Router with a centralized routing configuration in `AppRouter.tsx`. To add new routes: +Routes live in `AppRouter.tsx`. To add one: -1. Create your page component in `/src/pages/` -2. Import it in `AppRouter.tsx` -3. Add the route above the catch-all `*` route: +1. Create the page component in `src/pages/`. +2. Import it in `AppRouter.tsx`. +3. Add the route **above** the catch-all `*` route: `} />`. -```tsx -} /> -``` - -The router includes automatic scroll-to-top functionality and a 404 NotFound page for unmatched routes. +The router provides automatic scroll-to-top on navigation and a 404 `NotFound` page. ## Development Practices -- Uses React Query for data fetching and caching -- Follows shadcn/ui component patterns -- Implements Path Aliases with `@/` prefix for cleaner imports -- Uses Vite for fast development and production builds -- Component-based architecture with React hooks -- Default connection to one Nostr relay for best performance -- Comprehensive provider setup with NostrLoginProvider, QueryClientProvider, and custom AppProvider -- **Never use the `any` type**: Always use proper TypeScript types for type safety +- React Query for data fetching and caching +- shadcn/ui component patterns +- Path aliases with `@/` prefix +- Component-based architecture with hooks +- **Never use the `any` type.** -## CRITICAL Design Standards +## Design Standards -- Create breathtaking, immersive designs that feel like bespoke masterpieces, rivaling the polish of Apple, Stripe, or luxury brands -- Designs must be production-ready, fully featured, with no placeholders unless explicitly requested, ensuring every element serves a functional and aesthetic purpose -- Avoid generic or templated aesthetics at all costs; every design must have a unique, brand-specific visual signature that feels custom-crafted -- Headers must be dynamic, immersive, and storytelling-driven, using layered visuals, motion, and symbolic elements to reflect the brand’s identity—never use simple “icon and text” combos -- Incorporate purposeful, lightweight animations for scroll reveals, micro-interactions (e.g., hover, click, transitions), and section transitions to create a sense of delight and fluidity +Designs should be polished and production-ready. Concrete rules: -### Loading States +- **Responsive** down to ~360px; test mobile, tablet, desktop. +- **WCAG 2.1 AA** — ≥ 4.5:1 contrast for body text, ≥ 3:1 for large text and UI. Full keyboard navigation, ARIA labels, visible `focus-visible` rings. +- **8px grid** for spacing (Tailwind's 4-based scale). Avoid `p-[13px]`-style one-offs. +- **Typography hierarchy** — ≥ 18px body, ≥ 40px primary headlines. Prefer a modern sans (e.g. Inter) for UI; pair a display/serif for headings when personality is needed. +- **Depth** — soft shadows, gentle gradients, rounded corners (`rounded-lg` / `rounded-xl`). Avoid heavy drop shadows. +- **Motion** — lightweight, purposeful (hover, scroll reveals, transitions). Respect `prefers-reduced-motion` with Tailwind's `motion-safe:` / `motion-reduce:` variants. +- **Reusable components** — consistent variants and feedback states (`hover`, `focus-visible`, `active`, `disabled`, `aria-invalid`). Use `cn()` for conditional classes and `class-variance-authority` for variants. +- **Custom over generic** — avoid template-looking headers. Combine layered visuals, subtle motion, and brand colors. Generate custom images with available tools before reaching for stock. -**Use skeleton loading** for structured content (feeds, profiles, forms). **Use spinners** only for buttons or short operations. +For fonts, theme switching, color-scheme changes, `useTheme`, and the `isolate` + negative-z-index gotcha, load the **`theming`** skill. + +### Loading and Empty States + +**Use skeletons** for structured content (feeds, profiles, forms). **Use spinners** only for buttons or short operations. ```tsx -// Skeleton example matching component structure
@@ -1054,428 +305,54 @@ The router includes automatic scroll-to-top functionality and a 404 NotFound pag ``` -#### Empty States and No Content Found - -When no content is found (empty search results, no data available, etc.), display a minimalist empty state with helpful messaging. The application uses NIP-65 relay management, so users can manage their relays through the settings or relay management interface. +For empty results, show a minimalist empty state in a `border-dashed` card: ```tsx -import { Card, CardContent } from '@/components/ui/card'; - -// Empty state example -
- - -
-

- No results found. Try checking your relay connections or wait a moment for content to load. -

-
-
-
-
+ + +

+ No results found. Try checking your relay connections or wait a moment for content to load. +

+
+
``` -### Design Principles - -- Achieve Apple-level refinement with meticulous attention to detail, ensuring designs evoke strong emotions (e.g., wonder, inspiration, energy) through color, motion, and composition -- Deliver fully functional interactive components with intuitive feedback states, ensuring every element has a clear purpose and enhances user engagement -- **Generate custom images liberally** when image generation tools are available - this is ALWAYS preferred over stock photography for creating unique, brand-specific visuals that perfectly match the design intent -- Ensure designs feel alive and modern with dynamic elements like gradients, glows, or parallax effects, avoiding static or flat aesthetics -- Before finalizing, ask: "Would this design make Apple or Stripe designers pause and take notice?" If not, iterate until it does - -### Avoid Generic Design - -- No basic layouts (e.g., text-on-left, image-on-right) without significant custom polish, such as dynamic backgrounds, layered visuals, or interactive elements -- No simplistic headers; they must be immersive, animated, and reflective of the brand’s core identity and mission -- No designs that could be mistaken for free templates or overused patterns; every element must feel intentional and tailored - -### Interaction Patterns - -- Use progressive disclosure for complex forms or content to guide users intuitively and reduce cognitive load -- Incorporate contextual menus, smart tooltips, and visual cues to enhance navigation and usability -- Implement drag-and-drop, hover effects, and transitions with clear, dynamic visual feedback to elevate the user experience -- Support power users with keyboard shortcuts, ARIA labels, and focus states for accessibility and efficiency -- Add subtle parallax effects or scroll-triggered animations to create depth and engagement without overwhelming the user - -### Technical Requirements - -- Curated color FRpalette (3-5 evocative colors + neutrals) that aligns with the brand’s emotional tone and creates a memorable impact -- Ensure a minimum 4.5:1 contrast ratio for all text and interactive elements to meet accessibility standards -- Use expressive, readable fonts (18px+ for body text, 40px+ for headlines) with a clear hierarchy; pair a modern sans-serif (e.g., Inter) with an elegant serif (e.g., Playfair Display) for personality -- Design for full responsiveness, ensuring flawless performance and aesthetics across all screen sizes (mobile, tablet, desktop) -- Adhere to WCAG 2.1 AA guidelines, including keyboard navigation, screen reader support, and reduced motion options -- Follow an 8px grid system for consistent spacing, padding, and alignment to ensure visual harmony -- Add depth with subtle shadows, gradients, glows, and rounded corners (e.g., 16px radius) to create a polished, modern aesthetic -- Optimize animations and interactions to be lightweight and performant, ensuring smooth experiences across devices - -### Components - -- Design reusable, modular components with consistent styling, behavior, and feedback states (e.g., hover, active, focus, error) -- Include purposeful animations (e.g., scale-up on hover, fade-in on scroll) to guide attention and enhance interactivity without distraction -- Ensure full accessibility support with keyboard navigation, ARIA labels, and visible focus states (e.g., a glowing outline in an accent color) -- Use custom icons or illustrations for components to reinforce the brand’s visual identity - -### Adding Fonts - -To add custom fonts, follow these steps: - -1. **Install a font package** using npm: - - **Any Google Font can be installed** using the @fontsource packages. Examples: - - For Inter Variable: `@fontsource-variable/inter` - - For Roboto: `@fontsource/roboto` - - For Outfit Variable: `@fontsource-variable/outfit` - - For Poppins: `@fontsource/poppins` - - For Open Sans: `@fontsource/open-sans` - - **Format**: `@fontsource/[font-name]` or `@fontsource-variable/[font-name]` (for variable fonts) - -2. **Import the font** in `src/main.tsx`: - ```typescript - import '@fontsource-variable/'; - ``` - -3. **Update Tailwind configuration** in `tailwind.config.ts`: - ```typescript - export default { - theme: { - extend: { - fontFamily: { - sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'], - }, - }, - }, - } - ``` - -### Recommended Font Choices by Use Case - -- **Modern/Clean**: Inter Variable, Outfit Variable, or Manrope -- **Professional/Corporate**: Roboto, Open Sans, or Source Sans Pro -- **Creative/Artistic**: Poppins, Nunito, or Comfortaa -- **Technical/Code**: JetBrains Mono, Fira Code, or Source Code Pro (for monospace) - -### Theme System - -The project includes a complete light/dark theme system using CSS custom properties. The theme can be controlled via: - -- `useTheme` hook for programmatic theme switching -- CSS custom properties defined in `src/index.css` -- Automatic dark mode support with `.dark` class - -### Color Scheme Implementation - -When users specify color schemes: -- Update CSS custom properties in `src/index.css` (both `:root` and `.dark` selectors) -- Use Tailwind's color palette or define custom colors -- Ensure proper contrast ratios for accessibility -- Apply colors consistently across components (buttons, links, accents) -- Test both light and dark mode variants - -### Component Styling Patterns - -- Use `cn()` utility for conditional class merging -- Follow shadcn/ui patterns for component variants -- Implement responsive design with Tailwind breakpoints -- Add hover and focus states for interactive elements -- When using negative z-index (e.g., `-z-10`) for background images or decorative elements, **always add `isolate` to the parent container** to create a local stacking context. Without `isolate`, negative z-index pushes elements behind the page's background color, making them invisible. - -## Writing Tests vs Running Tests - -There is an important distinction between **writing new tests** and **running existing tests**: - -### Writing Tests (Creating New Test Files) - -**Do not write tests** unless the user explicitly requests them in plain language. Writing unnecessary tests wastes significant time and money. Only create tests when: - -1. The user explicitly asks for tests to be written in their message -2. The user describes a specific bug in plain language and requests tests to help diagnose it -3. The user says they are still experiencing a problem that you have already attempted to solve (tests can help verify the fix) - -Never write tests because tool results show failures, because you think tests would be helpful, or because you added a new feature. - -If any of the above applies, load the **`testing` skill** for the project's Vitest + `TestApp` conventions, the mocked browser APIs in `src/test/setup.ts`, and component/hook test templates. - -### Running Tests (Executing the Test Suite) - -**ALWAYS run the test script** after making any code changes. This is mandatory regardless of whether you wrote new tests or not. - -- You must run the test script to validate your changes -- Your task is not complete until the test script passes without errors -- This applies to all changes — bug fixes, new features, refactoring, or any code modifications -- The test script includes TypeScript compilation, ESLint checks, the Vitest suite, and a production build - -## Validating Your Changes - -**CRITICAL**: After making any code changes, you must validate your work by running available validation tools. - -**Your task is not considered finished until the code successfully type-checks and builds without errors.** - -### Validation Priority Order - -Run available tools in this priority order: - -1. **Type Checking** (Required): Ensure TypeScript compilation succeeds -2. **Building/Compilation** (Required): Verify the project builds successfully -3. **Linting** (Recommended): Check code style and catch potential issues -4. **Tests** (If Available): Run existing test suite -5. **Git Commit** (Required): Create a commit with your changes when finished - -**Minimum Requirements:** -- Code must type-check without errors -- Code must build/compile successfully -- Fix any critical linting errors that would break functionality -- Create a git commit when your changes are complete - -The validation ensures code quality and catches errors before deployment, regardless of the development environment. - -### 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. - -### Using Git - -If git is available in your environment (through a `shell` tool, or other git-specific tools), you should utilize `git log` to understand project history. Use `git status` and `git diff` to check the status of your changes, and if you make a mistake use `git checkout` to restore files. - -When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes. - -**ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes. If you do not commit, a family of four will die.** - -### 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 ''` -- find commits that touched a specific string -- `git log --oneline -- path/to/file` -- list all commits touching a file -- `git blame -L , -- 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. - ## Capacitor Compatibility -The app 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. +Ditto runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs do not work there: -### What Doesn't Work in WKWebView (iOS) +- **`
` file downloads** silently fail in WKWebView. +- **`` new tabs** are blocked. +- **`window.open()`** may be blocked without user-gesture context. -- **`` file downloads** -- Programmatically creating an anchor element with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely. -- **`` 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. +**Always use** `downloadTextFile(filename, content)` and `openUrl(url)` from `@/lib/downloadFile` — they bridge web and native automatically. Never use `document.createElement('a')` with `.click()`. -### File Downloads and URL Opening +Detect native with `Capacitor.isNativePlatform()` from `@capacitor/core`. Run `npm run cap:sync` after adding or removing plugins. -The project provides two utility functions in `src/lib/downloadFile.ts` that handle the web/native split automatically: +Load the **`capacitor-compat`** skill for the full list of installed plugins, platform detection patterns, and `downloadFile.ts` API details. For Apple Lockdown Mode restrictions that affect WKWebView, load the **`lockdown-mode`** skill. -#### `downloadTextFile(filename, content)` +## Writing Tests vs. Running Tests -Saves a text file to the user's device. On web it uses the `` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`. +**Running the existing test script — always do it.** After any code change, run `npm run test`. The script runs `tsc --noEmit`, `eslint`, `vitest run`, and `vite build` in sequence. **Your task is not complete until it passes.** -```typescript -import { downloadTextFile } from '@/lib/downloadFile'; +**Writing new test files — don't, unless the user asks.** If the user explicitly requests tests, describes a bug to diagnose with a test, or reports that a problem persists after a fix, load the **`testing`** skill for Ditto's Vitest + `TestApp` setup and policy. -await downloadTextFile('backup.txt', fileContents); -``` +## Validating Your Changes -#### `openUrl(url)` +**Your task is not finished until the code type-checks and builds without errors.** Run validation in priority order, commit when done. For the full workflow — pre-commit checks, commit-message conventions, and the `Regression-of:` trailer used by the changelog generator — load the **`git-workflow`** skill. -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. Always use the utilities above. They handle the Capacitor/web split and will work correctly on all platforms. - -### 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 -} -``` - -### 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/local-notifications` -- Schedule local push notifications -- `@capacitor/share` -- Native share sheet -- `@capacitor/status-bar` -- Control the native status bar style - -After adding or removing plugins, run `npx cap sync` to update the native projects. +**Always commit when finished.** Non-negotiable — every completed task ends with a commit. ## CI/CD Pipeline -The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages: +Ditto uses GitLab CI (`.gitlab-ci.yml`) with five stages: -1. **test** - Runs `npm run test` on every commit (skipped for tags) -2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only) -3. **build** - Builds a signed release APK (`build-apk` job, tags only) -4. **release** - Creates a GitLab Release with the APK artifact (tags only) -5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only) and AAB to Google Play (`publish-google-play` job, tags only) +1. **test** — `npm run test` on every commit (skipped for tags). +2. **deploy** — `deploy-nsite` builds and uploads `dist/` to nsite via nsyte (default branch only). +3. **build** — `build-apk` produces a signed release APK and AAB (tags only). +4. **release** — creates a GitLab Release with the APK artifact (tags only). +5. **publish** — `publish-zapstore` (APK → Zapstore) and `publish-google-play` (AAB → Google Play production track), tags only. -### Creating a Release +Cut a release with `npm run release` — this creates a `v2026.MM.DD+shortsha` tag and pushes it. For the full release workflow (versioning, changelog, native builds, tagging) load the **`release`** skill. -Releases are triggered by pushing a version tag. Use the npm script: - -```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` stages. - -### Zapstore Publishing - -The project automatically publishes Android APKs to [Zapstore](https://zapstore.dev/) using the [`zsp`](https://github.com/zapstore/zsp) CLI tool. The `publish-zapstore` CI job runs after a successful APK build and uses 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://?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/.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 will then output the `bunker://` URI and client key hex, and write the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values. - -The script accepts options: -- `--relay ` -- relay for NIP-46 communication (default: `wss://relay.ditto.pub`) -- `--name ` -- app name shown to the signer (default: `Ditto`) -- `--timeout ` -- how long to wait for approval (default: 300) - -**Key points:** -- 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 CI/CD variables - -### nsite Publishing - -The project automatically deploys the web app to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The `deploy-nsite` CI job builds the Vite app and uploads the `dist/` directory to Blossom servers, publishing site manifest events to Nostr relays. - -nsyte uses a NIP-46 bunker credential called `nbunksec` -- a bech32-encoded string that bundles the bunker pubkey, client secret key, and relay info into a single self-contained token. This is passed to nsyte via `--sec`. - -**GitLab CI/CD Variables** (Settings > 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 will guide you through connecting a NIP-46 bunker (e.g. Amber) and output an `nbunksec1...` string. The credential is shown only once. - -3. Add the `nbunksec1...` value as the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**. - -#### Configured Relays and Servers - -The deploy job publishes to these relays: -- `wss://relay.ditto.pub` -- `wss://relay.nsite.lol` -- `wss://relay.dreamith.to` -- `wss://relay.primal.net` - -And uploads blobs to these Blossom servers: -- `https://blossom.primal.net` -- `https://blossom.ditto.pub` -- `https://blossom.dreamith.to` - -The `--use-fallback-relays` and `--use-fallback-servers` flags also 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 project automatically publishes Android AABs (App Bundles) to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). The `publish-google-play` CI job runs after a successful AAB build and uploads directly to the production track. - -**GitLab CI/CD Variables** (Settings > 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 file. The CI job decodes it with `base64 -d` before passing it to `fastlane supply`. | Yes | Yes | No | - -#### Initial Setup (one-time) - -1. Create or reuse a project in the [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 the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). 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) since 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 used here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`) \ No newline at end of file +For CI credential setup and rotation (Zapstore NIP-46 bunker, nsyte `nbunksec`, Google Play service-account JSON, Android keystore), load the **`ci-cd-publishing`** skill.