Files
lemon 97f5f82b05 Rework login modal: translations, layout, and UX
Overhaul AuthDialog's login step:
- Add translation keys for every previously-hardcoded string (titles,
  buttons, placeholders, status labels, validation/errors) across all
  sixteen locales.
- The secret-key form is no longer collapsible — it's always open and is
  the first option, followed by 'Log in with extension', then a
  text-with-arrow link to the remote-signer step.
- Move the key-file upload icon onto the same row as the nsec input
  (instead of beside the submit button); submit button is now full-width
  below.
- Surface extension-login errors as a destructive toast rather than
  writing them into the nsec input's inline error.
- Replace the centered 'Back' text buttons with a back arrow in the
  top-left of the dialog header.

Also correct AGENTS.md's i18n section: the project ships fifteen
non-English locales (hi, id, sw, tr, zh-Hant were missing from the list).
2026-06-21 00:08:52 -07:00

384 lines
23 KiB
Markdown

# Project Overview
Agora is a peer-to-peer crowdfunding Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
Donations are **on-chain Bitcoin** — donors pay a campaign's Bitcoin address directly. Agora ships an integrated **non-custodial HD Bitcoin wallet** (deterministically derived from the user's Nostr key) with BIP-86 Taproot and **BIP-352 silent-payment** support. The app never custodies or converts funds; it is a non-custodial UI that connects donors and campaigns peer-to-peer.
**This is not a Lightning project.** Lightning (`useZaps`, `useWallet`, `useNWC`, LNURL/NWC/WebLN) survives only as a secondary *tipping* path for notes/profiles and a deprecated Breez/Spark wallet in recovery-only mode — never for campaign donations. The crowdfunding core is strictly on-chain.
## Technology Stack
- **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. `ui/` holds shadcn primitives; `auth/` holds login components.
- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Core Nostr: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`. **On-chain wallet & crowdfunding (the headline feature):** `useHdWallet`, `useHdWalletSp` (BIP-352 silent payments), `useBitcoinSigner`, `useDonateCampaign`, `useCampaign`/`useCampaigns`, `useCampaignDonations`, `useOnchainZap`. **Lightning (secondary tipping only, not campaigns):** `useZaps`, `useWallet` (NWC/WebLN status — *not* the on-chain wallet), `useNWC`.
- `/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`).
- `/src/test/` — testing utilities including the `TestApp` wrapper.
- `/public/` — static assets.
- `App.tsx`**already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider`, `AppProvider`, `NostrLoginProvider`, `NWCContext`. Read before editing; changes are rarely needed.
- `AppRouter.tsx` — React Router configuration.
- `NIP.md` — custom kinds documented by this project (see the `nostr-kind-design` skill).
**Always read an existing file before modifying it.** Never overwrite `App.tsx`, `AppRouter.tsx`, or `NostrProvider` without first reading their contents.
## UI Components
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/<name>` — all common primitives are present (buttons, inputs, dialogs, dropdowns, forms, tables, carousels, sidebars, etc.).
## System Prompt Management
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
### The `useNostr` Hook
```ts
import { useNostr } from '@nostrify/react';
function useCustomHook() {
const { nostr } = useNostr();
// nostr.query(filters) / nostr.event(event) / nostr.req(filters)
}
```
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.
### Kinds, Tags, and NIP.md
Two skills split the work of working with kinds:
- **`nostr-kind-design`** — load when minting a new kind, extending an existing NIP with new tags, or deciding whether an existing NIP covers a use case. Covers the NIP-vs-custom decision framework, kind ranges, tag design (single-letter indexed tags, content vs. tags), and the `NIP.md` documentation requirement.
- **`nostr-kind-rendering`** — load when adding UI for an event kind Ditto doesn't yet display, when asked to "support" / "display" / "render" a specific NIP or kind number, or when a kind renders blank / as "Kind 12345" / as "This event kind is not supported". Covers Ditto's multi-location UI registration checklist — feed cards, detail pages, embedded previews, kind-label maps (`KIND_LABELS`, `KIND_HEADER_MAP`, `NOTIFICATION_KIND_NOUNS`, `CommentContext`), notifications, routes, and the `AppConfig` triple that must stay in sync.
Summary rules:
- **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.
- **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.
```typescript
import { ADMIN_PUBKEYS } from '@/lib/admins';
// ❌ Anyone can publish kind 30078 with this d-tag and self-appoint as an organizer
nostr.query([{ kinds: [30078], '#d': ['pathos-organizers'], limit: 1 }]);
// ✅ Only trust the admin list
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.
### Querying Nostr Data
The standard pattern is a custom hook combining `useNostr` and `useQuery`:
```ts
function usePosts() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['posts'],
queryFn: async (c) => nostr.query([{ kinds: [1], limit: 20 }], { signal: c.signal }),
});
}
```
**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.
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
Fetch kind 0 profile metadata for a pubkey:
```tsx
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
function Post({ event }: { event: NostrEvent }) {
const author = useAuthor(event.pubkey);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(event.pubkey);
const profileImage = metadata?.picture;
}
```
`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.
### Publishing Events
Publishes go through `useNostrPublish`, which auto-adds a `client` tag. Always guard with `useCurrentUser`:
```tsx
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
export function PostForm() {
const { user } = useCurrentUser();
const { mutate: createEvent } = useNostrPublish();
if (!user) return <span>You must be logged in.</span>;
return <button onClick={() => createEvent({ kind: 1, content: 'hello' })}>Post</button>;
}
```
**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`:
```ts
const prev = await fetchFreshEvent(nostr, { kinds: [10003], authors: [user.pubkey] });
await publishEvent({ kind: 10003, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
```
**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.
Load the **`nostr-publishing`** skill for the full pattern: the `prev` property contract, bookmark/follow/mute examples, and d-tag collision prevention.
### Nostr Login
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';
<LoginArea className="max-w-60" />
```
`LoginArea` is inline-flex by default. Pass `flex` or `w-full` to expand it; otherwise set a sensible `max-w-*`.
**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.
For an Edit Profile form, drop in `<EditProfileForm />` from `@/components/EditProfileForm` — no props, works automatically.
### NIP-19 Identifiers
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.
**Filters only accept hex.** Always decode before querying:
```ts
import { nip19 } from 'nostr-tools';
const decoded = nip19.decode(value);
if (decoded.type !== 'naddr') throw new Error('Unsupported identifier');
const { kind, pubkey, identifier } = decoded.data;
nostr.query([{
kinds: [kind],
authors: [pubkey], // critical for addressable events
'#d': [identifier],
}]);
```
Never treat `nsec1` or unknown prefixes as anything but a 404.
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 plaintext `content` that may contain URLs, hashtags, and Nostr URIs. Render them with the `NoteContent` component:
```tsx
import { NoteContent } from '@/components/NoteContent';
<div className="whitespace-pre-wrap break-words">
<NoteContent event={post} className="text-sm" />
</div>
```
### 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-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 `AppProvider` manages global state (theme, NIP-65 relay list, Blossom servers, etc.) persisted to local storage. Default relay config:
```typescript
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
Routes live in `AppRouter.tsx`. To add one:
1. Create the page component in `src/pages/`.
2. Import it in `AppRouter.tsx`.
3. Add the route **above** the catch-all `*` route: `<Route path="/your-path" element={<YourComponent />} />`.
The router provides automatic scroll-to-top on navigation and a 404 `NotFound` page.
## Internationalization
All user-facing strings live in `src/locales/<lang>.json`. `en.json` is the source of truth; fifteen other locales ship alongside it: `ar`, `es`, `fa`, `fr`, `hi`, `id`, `km`, `ps`, `pt`, `ru`, `sn`, `sw`, `tr`, `zh`, `zh-Hant`.
**When you edit, add, or remove a translated string, update every locale in the same change — not just `en.json`.** Leaving the other locales stale ships an inconsistent app: users in other languages either see outdated copy or get an English fallback in the middle of a localized screen. This applies to FAQ entries, guide bodies, button labels, error messages — every value reachable through `t()`.
Concrete rules:
- **Edits to an existing key** — change the value in `en.json` first, then update the corresponding key in all fifteen other locales. Translate the new content into each language; don't paste English. Preserve `{{interpolation}}` placeholders, markdown links, and technical tokens (`sp1…`, `BIP-352`, kind numbers, etc.) verbatim.
- **New keys** — add to `en.json` first, then add the same key with a translated value in every other locale. `src/test/locales.test.ts` fails the build if any locale ships a key that doesn't exist in `en.json`, but the inverse (a key missing from a non-English locale) is allowed and falls back to English at runtime — which is exactly the user-visible mess you're trying to avoid.
- **Removed keys** — delete from `en.json` and every other locale together. Leftover keys are dead translations and clutter future diffs.
- **Parallelize the translation work** — when updating one English string across all fifteen locales, dispatch the per-language edits to subagents in parallel rather than translating fifteen files sequentially. Provide each subagent the new English source, the existing translation snippet (so it matches established voice), and explicit instructions to preserve placeholders and technical tokens.
Always run `npm run test` after locale changes — `locales.test.ts` catches structural drift, and the wider suite catches any `t()` calls that referenced a key you renamed.
## Development Practices
- 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.**
## Design Standards
Designs should be polished and production-ready. Concrete rules:
- **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.
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
<Card>
<CardHeader>
<div className="flex items-center space-x-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</CardContent>
</Card>
```
For empty results, show a minimalist empty state in a `border-dashed` card:
```tsx
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<p className="text-muted-foreground max-w-sm mx-auto">
No results found. Try checking your relay connections or wait a moment for content to load.
</p>
</CardContent>
</Card>
```
## Capacitor Compatibility
Ditto runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs do not work there:
- **`<a download>` file downloads** silently fail in WKWebView.
- **`<a target="_blank">` new tabs** are blocked.
- **`window.open()`** may be blocked 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()`.
Detect native with `Capacitor.isNativePlatform()` from `@capacitor/core`. Run `npm run cap:sync` after adding or removing plugins.
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.
## Writing Tests vs. Running Tests
**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.**
**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.
## Validating Your Changes
**Your task is not finished until the code type-checks and builds without errors.** Run validation in priority order. 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.
## Always Commit
**Every completed task ends with a git commit. This overrides any global default about waiting for explicit commit requests.** Once validation passes (or the task is non-code and there's nothing to validate), commit immediately — do not ask, do not leave changes uncommitted, do not stop at "ready to commit." The user expects a clean working tree at the end of every turn.
Pushing is still the user's call — commit, but do not push unless asked.
## CI/CD Pipeline
Ditto uses GitLab CI (`.gitlab-ci.yml`) with five stages:
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 APK and AAB (Linux); `build-ipa` produces a signed IPA on the self-hosted Mac runner; `release-notes` extracts the changelog section + summary paragraph from `CHANGELOG.md`. All three run on tags only.
4. **release** — creates a GitLab Release with the changelog body and APK / AAB / IPA artifacts (tags only).
5. **publish**`publish-zapstore` (APK → Zapstore), `publish-google-play` (AAB → Google Play with the release summary as "What's new"), and `publish-app-store` (IPA → App Store Connect with the release summary as "What's New", runs on the self-hosted Mac runner because `fastlane deliver` shells out to Apple's iTMSTransporter to push the binary and that tool only exists inside Xcode), tags only.
To cut a release, load the **`release`** skill — it walks through version bumping (`X.Y.Z`), changelog generation, native build-file updates, and tagging/pushing (`vX.Y.Z`) to trigger the CI pipeline.
For CI credential setup and rotation (Zapstore NIP-46 bunker, nsyte `nbunksec`, Google Play service-account JSON, Android keystore, App Store Connect API key, fastlane match), load the **`ci-cd-publishing`** skill. For Mac runner operations (SSH access, restarting, debugging fastlane locally, yearly cert rotation), load the **`mac-runner`** skill.