Adds an Internationalization section to AGENTS.md spelling out that edits to user-facing strings must propagate across every locale in the same change, not just en.json. Lists the ten translated locales (ar, es, fa, fr, km, ps, pt, ru, sn, zh), gives rules for edits / new keys / removals, notes that locales.test.ts only catches the structural direction (extra keys in a locale) — missing keys silently fall back to English — and recommends parallelizing the per-language work with subagents for one-string-across-ten-locales edits.
22 KiB
Project Overview
Agora 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 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
BrowserRouterand automatic scroll-to-top - TanStack Query — data fetching, caching, state
- TypeScript — type-safe JS. Never use the
anytype. - 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 withls src/hooks/. Key ones:useNostr,useAuthor,useCurrentUser,useNostrPublish,useUploadFile,useAppContext,useTheme,useToast,useLoggedInAccounts,useLoginActions,useIsMobile,useZaps,useWallet,useNWC,useShakespeare./src/pages/— page components wired intoAppRouter.tsx. The catch-all/:nip19route is handled byNIP19Page.tsx(see thenip19-routingskill)./src/lib/— utility functions and shared logic./src/contexts/— React context providers (AppContext,NWCContext)./src/test/— testing utilities including theTestAppwrapper./public/— static assets.App.tsx— already configured withQueryClientProvider,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 thenostr-kind-designskill).
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
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 theNIP.mddocumentation 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 theAppConfigtriple 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
alttag. - Relays only index single-letter tags. Use
ttags for categories. - Use
contentfor freeform text or industry-standard JSON only. Structured queryable data belongs in tags. - Update
NIP.mdwhenever 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, ordocument.writewith 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/sanitizeUrlbefore it lands inhref,src,srcSet,poster, iframesrc, or CSSurl(). It returnsundefinedfor anything that isn't a well-formedhttps:URL. Prefer sanitizing at the parse layer. - Sanitize event-sourced strings interpolated into CSS with
sanitizeCssString()from@/lib/fontLoader. URLs in CSSurl()still go throughsanitizeUrl(). - 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 includeauthorsin its filter. - Don't filter by
authorsfor public UGC (kind 1 notes, reactions, zaps, discovery feeds) — anyone can post there by design.
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:
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:
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:
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:
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.
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:
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:
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-94imetatags.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:
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.
- TypeScript interface (
src/contexts/AppContext.ts) — add the field to theAppConfiginterface with a JSDoc comment. - Zod schema (
src/lib/schemas.ts) — add the same field toAppConfigSchema.DittoConfigSchema(validates build-timeditto.json) is derived fromAppConfigSchemawith.strict()mode, so any field inditto.jsonmissing from the Zod schema causes a build error. - Default value (
src/contexts/AppContext.ts) — if the field is required, add a default indefaultConfig. Optional fields (?in the interface,.optional()in Zod) can be omitted.
Relay Management
NostrSyncauto-loads the user's NIP-65 relay list on login and writes it intoAppContext.- 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:
- Create the page component in
src/pages/. - Import it in
AppRouter.tsx. - 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; ten other locales ship alongside it: ar, es, fa, fr, km, ps, pt, ru, sn, zh.
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.jsonfirst, then update the corresponding key in all ten 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.jsonfirst, then add the same key with a translated value in every other locale.src/test/locales.test.tsfails the build if any locale ships a key that doesn't exist inen.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.jsonand every other locale together. Leftover keys are dead translations and clutter future diffs. - Parallelize the translation work — when updating one English string across all ten locales, dispatch the per-language edits to subagents in parallel rather than translating ten 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
anytype.
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-visiblerings. - 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-motionwith Tailwind'smotion-safe:/motion-reduce:variants. - Reusable components — consistent variants and feedback states (
hover,focus-visible,active,disabled,aria-invalid). Usecn()for conditional classes andclass-variance-authorityfor 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.
<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:
<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:
- test —
npm run teston every commit (skipped for tags). - deploy —
deploy-nsitebuilds and uploadsdist/to nsite via nsyte (default branch only). - build —
build-apkproduces a signed APK and AAB (Linux);build-ipaproduces a signed IPA on the self-hosted Mac runner;release-notesextracts the changelog section + summary paragraph fromCHANGELOG.md. All three run on tags only. - release — creates a GitLab Release with the changelog body and APK / AAB / IPA artifacts (tags only).
- publish —
publish-zapstore(APK → Zapstore),publish-google-play(AAB → Google Play with the release summary as "What's new"), andpublish-app-store(IPA → App Store Connect with the release summary as "What's New", runs on the self-hosted Mac runner becausefastlane delivershells 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.