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/nip85-stats/SKILL.md b/.agents/skills/nip85-stats/SKILL.md new file mode 100644 index 00000000..7e01e18d --- /dev/null +++ b/.agents/skills/nip85-stats/SKILL.md @@ -0,0 +1,190 @@ +--- +name: nip85-stats +description: Fetch pre-computed engagement stats (follower count, post count, reply count, reaction count, zap amounts, etc.) for users, events, and addressable events via a NIP-85 Trusted Assertion provider. Provides useNip85UserStats, useNip85EventStats, and useNip85AddrStats hooks backed by a configurable provider pubkey in AppConfig. +--- + +# NIP-85 Trusted Assertion Stats + +[NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) defines "Trusted Assertions" — events published by a service provider that carry pre-computed stats (follower counts, reaction counts, zap totals, etc.) for users and events. Clients that would otherwise need to load thousands of events to compute these numbers can instead query a single addressable event from a trusted provider. + +This skill adds three hooks — `useNip85UserStats`, `useNip85EventStats`, `useNip85AddrStats` — and a configurable `nip85StatsPubkey` field on `AppConfig` so you can swap providers. + +## Kinds Used + +| Kind | Subject | `d` tag value | +| ----- | ---------------------------- | -------------------------- | +| 30382 | User | user pubkey (hex) | +| 30383 | Event (regular, kind 1 etc.) | event id (hex) | +| 30384 | Addressable event | `::` | + +The hooks query one replaceable event at a time (`limit: 1`), filtered by `authors: [statsPubkey]` and `#d`. **Filtering by `authors` is required** — without it, anyone could publish a fake assertion with the same `d` tag and the client would accept it. + +## Files Provided by This Skill + +| Skill file | Copy to | +|---|---| +| `files/hooks/useNip85Stats.ts` | `src/hooks/useNip85Stats.ts` | + +## Setup Instructions + +### 1. Copy the Hooks File + +Copy `.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts` into `src/hooks/useNip85Stats.ts`. It imports `@nostrify/react`, `@tanstack/react-query`, and `@/hooks/useAppContext`, all already present in the template. + +### 2. Add `nip85StatsPubkey` to `AppConfig` + +In `src/contexts/AppContext.ts`, add the field to the `AppConfig` interface: + +```typescript +export interface AppConfig { + // ...existing fields... + /** Hex pubkey of the NIP-85 Trusted Assertion provider. Empty = disabled. */ + nip85StatsPubkey: string; +} +``` + +### 3. Update the Zod Schema in `AppProvider.tsx` + +In `src/components/AppProvider.tsx`, add the field to `AppConfigSchema`: + +```typescript +const AppConfigSchema = z.object({ + // ...existing fields... + nip85StatsPubkey: z.string().refine( + (val) => val.length === 0 || /^[0-9a-f]{64}$/i.test(val), + { message: 'Must be empty or a 64-character hex pubkey' }, + ), +}) satisfies z.ZodType; +``` + +### 4. Set the Default in `App.tsx` + +Pick a provider pubkey and add it to `defaultConfig`. The ditto.pub provider is a reasonable default: + +```typescript +const defaultConfig: AppConfig = { + // ...existing fields... + nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea', +}; +``` + +Set to `''` to ship with stats disabled. + +### 5. Update `TestApp.tsx` + +In `src/test/TestApp.tsx`, add the field to the test default config. Use an empty string so tests don't hit a live provider: + +```typescript +const defaultConfig: AppConfig = { + // ...existing fields... + nip85StatsPubkey: '', +}; +``` + +## Usage + +### User stats (kind 30382) + +```tsx +import { useNip85UserStats } from '@/hooks/useNip85Stats'; + +function FollowerCount({ pubkey }: { pubkey: string }) { + const { data: stats } = useNip85UserStats(pubkey); + if (!stats) return null; // no provider configured or no assertion yet + return {stats.followers.toLocaleString()} followers; +} +``` + +### Event stats (kind 30383) + +```tsx +import { useNip85EventStats } from '@/hooks/useNip85Stats'; + +function NoteStats({ eventId }: { eventId: string }) { + const { data: stats } = useNip85EventStats(eventId); + if (!stats) return null; + return ( +
+ {stats.reactionCount} reactions + {stats.repostCount} reposts + {stats.commentCount} comments + {stats.zapAmount} sats +
+ ); +} +``` + +### Addressable event stats (kind 30384) + +The `addr` argument is the full NIP-01 event address `::`: + +```tsx +import { useNip85AddrStats } from '@/hooks/useNip85Stats'; + +function ArticleStats({ kind, pubkey, identifier }: { kind: number; pubkey: string; identifier: string }) { + const { data: stats } = useNip85AddrStats(`${kind}:${pubkey}:${identifier}`); + if (!stats) return null; + return {stats.reactionCount} reactions; +} +``` + +## Behavior Notes + +- **Graceful degradation:** The hooks return `null` (not an error) when `nip85StatsPubkey` is empty or the provider has no assertion for the subject. Always render defensively — NIP-85 is an optimization, not a source of truth. +- **Short timeouts:** Each query is wrapped in a 2-second `AbortSignal.timeout` so a slow stats relay never blocks the UI. +- **Cached by TanStack Query:** `staleTime` is 30s for event/addr stats and 60s for user stats. Results are keyed on `[kind, subject, statsPubkey]`, so swapping providers invalidates the cache automatically. +- **Missing tags = 0:** A tag absent from the assertion is reported as `0` rather than `undefined`, matching NIP-85's "no data" semantics. +- **Not the source of truth:** For interactive features (did *this* user like *this* post?) you still need to query the underlying reaction/zap/repost events. NIP-85 only provides aggregate counts. + +## Extending the Stats + +The hooks expose a small subset of the tags defined in NIP-85. To surface more (e.g. `zap_amt_sent`, `rank`, `first_created_at`), extend the return types and pull additional tags via `getIntTag`: + +```typescript +export interface Nip85UserStats { + followers: number; + postCount: number; + rank: number; // new + zapAmtReceived: number; // new +} + +// inside useNip85UserStats queryFn +return { + followers: getIntTag(tags, 'followers'), + postCount: getIntTag(tags, 'post_cnt'), + rank: getIntTag(tags, 'rank'), + zapAmtReceived: getIntTag(tags, 'zap_amt_recd'), +}; +``` + +See the full tag table in [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md). + +## Exposing a Provider Picker (Optional) + +If you want the user to change providers at runtime, add an input bound to `config.nip85StatsPubkey` and call `updateConfig` with a validated 64-char hex value: + +```tsx +import { useAppContext } from '@/hooks/useAppContext'; + +function StatsProviderInput() { + const { config, updateConfig } = useAppContext(); + return ( + { + const v = e.target.value.trim().toLowerCase(); + if (v === '' || /^[0-9a-f]{64}$/.test(v)) { + updateConfig(() => ({ nip85StatsPubkey: v })); + } + }} + placeholder="64-char hex pubkey (blank to disable)" + /> + ); +} +``` + +## Related NIPs + +- [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) — Trusted Assertions (this skill) +- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Addressable event addressing (`::`) +- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Zaps (the underlying events `zap_amount` / `zap_cnt` aggregate) diff --git a/.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts b/.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts new file mode 100644 index 00000000..38f87993 --- /dev/null +++ b/.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts @@ -0,0 +1,156 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import { useAppContext } from '@/hooks/useAppContext'; + +/** Engagement counts exposed by NIP-85 kind 30383 (events) and 30384 (addressable events). */ +export interface Nip85EventStats { + commentCount: number; + repostCount: number; + reactionCount: number; + zapCount: number; + /** Zap amount in sats. */ + zapAmount: number; +} + +/** A subset of NIP-85 kind 30382 (user) stats — extend as needed. */ +export interface Nip85UserStats { + followers: number; + postCount: number; +} + +/** + * Read an integer tag value from a NIP-85 assertion event. Returns 0 when missing + * or unparseable, which mirrors the semantics of "no data" in NIP-85. + */ +function getIntTag(tags: string[][], tagName: string): number { + const tag = tags.find(([name]) => name === tagName); + if (!tag?.[1]) return 0; + const n = parseInt(tag[1], 10); + return Number.isFinite(n) ? n : 0; +} + +/** + * Fetches NIP-85 event stats (kind 30383) from the configured stats pubkey. + * Returns `null` when no stats pubkey is configured or the provider has no + * assertion for this event. + */ +export function useNip85EventStats(eventId: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-event-stats', eventId, statsPubkey], + queryFn: async ({ signal }) => { + if (!eventId || !statsPubkey) return null; + + const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]); + + try { + const events = await nostr.query( + [{ kinds: [30383], authors: [statsPubkey], '#d': [eventId], limit: 1 }], + { signal: combined }, + ); + + if (events.length === 0) return null; + + const { tags } = events[0]; + return { + commentCount: getIntTag(tags, 'comment_cnt'), + repostCount: getIntTag(tags, 'repost_cnt'), + reactionCount: getIntTag(tags, 'reaction_cnt'), + zapCount: getIntTag(tags, 'zap_cnt'), + zapAmount: getIntTag(tags, 'zap_amount'), + }; + } catch { + return null; + } + }, + enabled: !!eventId && !!statsPubkey, + staleTime: 30 * 1000, + retry: false, + }); +} + +/** + * Fetches NIP-85 user stats (kind 30382) from the configured stats pubkey. + * Returns `null` when no stats pubkey is configured or the provider has no + * assertion for this pubkey. + */ +export function useNip85UserStats(pubkey: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-user-stats', pubkey, statsPubkey], + queryFn: async ({ signal }) => { + if (!pubkey || !statsPubkey) return null; + + const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]); + + try { + const events = await nostr.query( + [{ kinds: [30382], authors: [statsPubkey], '#d': [pubkey], limit: 1 }], + { signal: combined }, + ); + + if (events.length === 0) return null; + + const { tags } = events[0]; + return { + followers: getIntTag(tags, 'followers'), + postCount: getIntTag(tags, 'post_cnt'), + }; + } catch { + return null; + } + }, + enabled: !!pubkey && !!statsPubkey, + staleTime: 60 * 1000, + retry: false, + }); +} + +/** + * Fetches NIP-85 addressable event stats (kind 30384) from the configured + * stats pubkey. The `addr` argument is the full NIP-01 event address string, + * e.g. `30023::`. + */ +export function useNip85AddrStats(addr: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-addr-stats', addr, statsPubkey], + queryFn: async ({ signal }) => { + if (!addr || !statsPubkey) return null; + + const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]); + + try { + const events = await nostr.query( + [{ kinds: [30384], authors: [statsPubkey], '#d': [addr], limit: 1 }], + { signal: combined }, + ); + + if (events.length === 0) return null; + + const { tags } = events[0]; + return { + commentCount: getIntTag(tags, 'comment_cnt'), + repostCount: getIntTag(tags, 'repost_cnt'), + reactionCount: getIntTag(tags, 'reaction_cnt'), + zapCount: getIntTag(tags, 'zap_cnt'), + zapAmount: getIntTag(tags, 'zap_amount'), + }; + } catch { + return null; + } + }, + enabled: !!addr && !!statsPubkey, + staleTime: 30 * 1000, + retry: false, + }); +} diff --git a/.agents/skills/nostr-direct-messages/SKILL.md b/.agents/skills/nostr-direct-messages/SKILL.md deleted file mode 100644 index 26f3ef56..00000000 --- a/.agents/skills/nostr-direct-messages/SKILL.md +++ /dev/null @@ -1,478 +0,0 @@ ---- -name: nostr-direct-messages -description: Implement Nostr direct messaging features, build chat interfaces, or work with encrypted peer-to-peer communication (NIP-04 and NIP-17). ---- - -# Direct Messaging on Nostr - -This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage. - -**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application. - -## Setup Instructions - -### 1. Add DMProvider to Your App - -First, add the `DMProvider` to your app's provider tree in `src/App.tsx`: - -```tsx -// Add these imports at the top of src/App.tsx -import { DMProvider, type DMConfig } from '@/components/DMProvider'; -import { PROTOCOL_MODE } from '@/lib/dmConstants'; - -// Add this configuration before your App component -const dmConfig: DMConfig = { - // Enable or disable DMs entirely - enabled: true, // Set to true to enable messaging functionality - - // Choose one protocol mode: - // PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only - // PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only - // PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17) - protocolMode: PROTOCOL_MODE.NIP17_ONLY, // Recommended for new apps -}; - -// Then wrap your app components with DMProvider: -export function App() { - return ( - - - - - - - - - - - - - - - - - - - - ); -} -``` - -### 2. Configure DM Settings - -The `DMConfig` object supports the following options: - -- `enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed. -- `protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support: - - `PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only - - `PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended) - - `PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility) - -**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain. - -## Quick Start - -### 1. Send Messages - -```tsx -import { useDMContext } from '@/hooks/useDMContext'; -import { MESSAGE_PROTOCOL } from '@/lib/dmConstants'; - -function ComposeMessage({ recipientPubkey }: { recipientPubkey: string }) { - const { sendMessage } = useDMContext(); - const [content, setContent] = useState(''); - - const handleSend = async () => { - await sendMessage({ - recipientPubkey, - content, - protocol: MESSAGE_PROTOCOL.NIP17, // Uses NIP-44 encryption + gift wrapping - }); - setContent(''); - }; - - return ( -
{ e.preventDefault(); handleSend(); }}> -