33 Commits

Author SHA1 Message Date
woikos
5183a4fc0a Add Unlicense (public domain)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 13:05:26 +01:00
woikos
a2d0a9bd32 Add privacy policy for extension store submissions
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 10:45:39 +01:00
woikos
5cf0fed4ed Add store screenshots and descriptions for Chrome/Firefox
- Chrome Web Store screenshots (1280x800)
- Firefox AMO screenshots (1280x800)
- Store listing descriptions for both platforms
- Source screenshots from extension UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 10:31:24 +01:00
woikos
4a2bc4fe72 Release v1.1.5 - Remove debug and signing logs
- Disable debug() logging function in background scripts
- Remove backgroundLogNip07Action calls for NIP-07 operations
- Remove backgroundLogPermissionStored calls for permission events
- Clean up unused imports and result variables
- Simplify switch statement returns in processNip07Request

Files modified:
- package.json (version bump)
- projects/chrome/src/background-common.ts
- projects/chrome/src/background.ts
- projects/firefox/src/background-common.ts
- projects/firefox/src/background.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:07:56 +01:00
a2e47d8612 Release v1.1.4 - Improve ncryptsec export page UX
- Auto-focus password input when page loads
- Move QR code above password input form (displays after generation)
- Move explanation text below the form
- Replace ncryptsec text output with clickable QR code button
- Add hover/active effects and "Copy to clipboard" tooltip to QR code
- Remove redundant copy button and text display

Files modified:
- package.json (version bump)
- projects/chrome/public/manifest.json
- projects/chrome/src/app/components/edit-identity/ncryptsec/*
- projects/firefox/public/manifest.json
- projects/firefox/src/app/components/edit-identity/ncryptsec/*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 10:04:07 +02:00
2074c409f0 Release v1.1.3 - Add NIP-49 ncryptsec export feature
- Add ncryptsec page for exporting encrypted private keys (NIP-49)
- Implement password-based encryption using scrypt + XChaCha20-Poly1305
- Display QR code for easy mobile scanning of encrypted key
- Add click-to-copy functionality for ncryptsec string
- Add privkeyToNcryptsec() method to NostrHelper using nostr-tools nip49

Files modified:
- projects/common/src/lib/helpers/nostr-helper.ts
- projects/chrome/src/app/app.routes.ts
- projects/chrome/src/app/components/edit-identity/keys/keys.component.*
- projects/chrome/src/app/components/edit-identity/ncryptsec/ (new)
- projects/firefox/src/app/app.routes.ts
- projects/firefox/src/app/components/edit-identity/keys/keys.component.*
- projects/firefox/src/app/components/edit-identity/ncryptsec/ (new)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:39:47 +02:00
woikos
c11887dfa8 Release v1.1.2 - DDD refactoring with domain layer and ubiquitous language
- Add domain layer with value objects (IdentityId, Nickname, NostrKeyPair, etc.)
- Add rich domain entities (Identity, Permission, Relay) with behavior
- Add domain events for identity lifecycle (Created, Renamed, Selected, etc.)
- Add repository interfaces and infrastructure implementations
- Rename storage types to ubiquitous language (EncryptedVault, VaultSession, etc.)
- Fix PermissionChecker to prioritize kind-specific rules over blanket rules
- Add comprehensive test coverage for domain layer (113 tests passing)
- Maintain backwards compatibility with @deprecated aliases

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 05:29:42 +01:00
woikos
d98a0ef76e Implement DDD refactoring phases 1-4 with domain layer and ubiquitous language
Phase 1-3: Domain Layer Foundation
- Add value objects: IdentityId, PermissionId, RelayId, WalletId, Nickname, NostrKeyPair
- Add rich domain entities: Identity, Permission, Relay with behavior
- Add domain events: IdentityCreated, IdentityRenamed, IdentitySelected, etc.
- Add repository interfaces for Identity, Permission, Relay
- Add infrastructure layer with repository implementations
- Add EncryptionService abstraction

Phase 4: Ubiquitous Language Cleanup
- Rename BrowserSyncData → EncryptedVault (encrypted vault storage)
- Rename BrowserSessionData → VaultSession (decrypted session state)
- Rename SignerMetaData → ExtensionSettings (extension configuration)
- Rename Identity_ENCRYPTED → StoredIdentity (storage DTO)
- Rename Identity_DECRYPTED → IdentityData (session DTO)
- Similar renames for Permission, Relay, NwcConnection, CashuMint
- Add backwards compatibility aliases with @deprecated markers

Test Coverage
- Add comprehensive tests for all value objects
- Add tests for domain entities and their behavior
- Add tests for domain events
- Fix PermissionChecker to prioritize kind-specific rules over blanket rules
- Fix pre-existing component test issues (IconButton, Pubkey)

All 113 tests pass. Both Chrome and Firefox builds succeed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 05:21:44 +01:00
woikos
87d76bb4a8 Release v1.1.1 - Add permission prompt queue system and batch actions
- Add single-active-prompt queue to prevent permission window spam
- Implement request deduplication using hash-based matching
- Add 30-second timeout for unanswered prompts with cleanup
- Add window close event handling for orphaned prompts
- Add queue size limit (100 requests max)
- Add "All Queued" row with Reject All/Approve All buttons
- Hide batch buttons when queue size is 1 or less
- Add 'reject-all' and 'approve-all' response types to PromptResponse

Files modified:
- package.json
- projects/chrome/public/prompt.html
- projects/chrome/src/background-common.ts
- projects/chrome/src/background.ts
- projects/chrome/src/prompt.ts
- projects/firefox/public/prompt.html
- projects/firefox/src/background-common.ts
- projects/firefox/src/background.ts
- projects/firefox/src/prompt.ts
- releases/plebeian-signer-chrome-v1.1.1.zip
- releases/plebeian-signer-firefox-v1.1.1.zip

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:44:14 +01:00
woikos
57434681f9 Release v1.1.0 - Add WebLN API support for Lightning wallet integration
- Add window.webln API for web app Lightning wallet integration
- Implement webln.enable(), getInfo(), sendPayment(), makeInvoice() methods
- Add WebLN permission prompts with proper amount display for payments
- Dispatch webln:ready and webln:enabled events per WebLN standard
- Add NWC client caching for persistent wallet connections
- Implement inline BOLT11 invoice amount parsing
- Always prompt for sendPayment (security-critical, irreversible)
- Add signMessage/verifyMessage stubs that return "not supported"
- Fix response handling for undefined returns in content script

Files modified:
- projects/common/src/lib/models/nostr.ts (WeblnMethod, ExtensionMethod types)
- projects/common/src/lib/models/webln.ts (new - WebLN response types)
- projects/common/src/public-api.ts (export webln types)
- projects/{chrome,firefox}/src/plebian-signer-extension.ts (window.webln)
- projects/{chrome,firefox}/src/background.ts (WebLN handlers)
- projects/{chrome,firefox}/src/background-common.ts (WebLN permissions)
- projects/{chrome,firefox}/public/prompt.html (WebLN cards)
- projects/{chrome,firefox}/src/prompt.ts (WebLN method handling)
- projects/common/src/lib/services/storage/types.ts (ExtensionMethod type)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:02:05 +01:00
woikos
586e2ab23f Release v1.0.11 - Add dev mode with test prompt button on all headers
- Add Dev Mode toggle to settings that persists in vault metadata
- Add test permission prompt button () to all page headers when dev mode enabled
- Move devMode and onTestPrompt to NavComponent base class for inheritance
- Refactor all home components to extend NavComponent
- Simplify permission prompt layout: remove duplicate domain from header
- Convert permission descriptions to flowing single paragraphs
- Update header-buttons styling for consistent lock/magic button layout

Files modified:
- projects/common/src/lib/common/nav-component.ts (devMode, onTestPrompt)
- projects/common/src/lib/services/storage/types.ts (devMode property)
- projects/common/src/lib/services/storage/signer-meta-handler.ts (setDevMode)
- projects/common/src/lib/styles/_common.scss (header-buttons styling)
- projects/*/src/app/components/home/*/settings.component.* (dev mode UI)
- projects/*/src/app/components/home/*/*.component.* (extend NavComponent)
- projects/*/public/prompt.html (simplified layout)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 07:41:51 +01:00
woikos
5ca6eb177c Release v1.0.10 - Add unlock popup for locked vault
- Add unlock popup window that appears when vault is locked and a NIP-07
  request is made (similar to permission prompt popup)
- Implement standalone vault unlock logic in background script using
  Argon2id key derivation and AES-GCM decryption
- Queue pending NIP-07 requests while waiting for unlock, process after
  success
- Add unlock.html and unlock.ts for both Chrome and Firefox extensions

Files modified:
- package.json (version bump to v1.0.10)
- projects/chrome/public/unlock.html (new)
- projects/chrome/src/unlock.ts (new)
- projects/chrome/src/background.ts
- projects/chrome/src/background-common.ts
- projects/chrome/custom-webpack.config.ts
- projects/chrome/tsconfig.app.json
- projects/firefox/public/unlock.html (new)
- projects/firefox/src/unlock.ts (new)
- projects/firefox/src/background.ts
- projects/firefox/src/background-common.ts
- projects/firefox/custom-webpack.config.ts
- projects/firefox/tsconfig.app.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 06:23:36 +01:00
woikos
ebc96e7201 Release v1.0.9 - Add wallet tab with Cashu and Lightning support
- Add wallet tab with NWC (Nostr Wallet Connect) Lightning support
- Add Cashu ecash wallet with mint management, send/receive tokens
- Add Cashu deposit feature (mint via Lightning invoice)
- Add token viewer showing proof amounts and timestamps
- Add refresh button with auto-refresh for spent proof detection
- Add browser sync warning for Cashu users on welcome screen
- Add Cashu onboarding info panel with storage considerations
- Add settings page sync info note explaining how to change sync
- Add backups page for vault snapshot management
- Add About section to identity (You) page
- Fix lint accessibility issues in wallet component

Files modified:
- projects/common/src/lib/services/nwc/* (new)
- projects/common/src/lib/services/cashu/* (new)
- projects/common/src/lib/services/storage/* (extended)
- projects/chrome/src/app/components/home/wallet/*
- projects/firefox/src/app/components/home/wallet/*
- projects/chrome/src/app/components/welcome/*
- projects/firefox/src/app/components/welcome/*
- projects/chrome/src/app/components/home/settings/*
- projects/firefox/src/app/components/home/settings/*
- projects/chrome/src/app/components/home/identity/*
- projects/firefox/src/app/components/home/identity/*
- projects/chrome/src/app/components/home/backups/* (new)
- projects/firefox/src/app/components/home/backups/* (new)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 15:40:25 +01:00
woikos
1f8d478cd7 Update repository URLs to GitHub
Change all references from git.mleku.dev/mleku/plebeian-signer to
github.com/PlebeianApp/plebeian-signer for the public release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 13:45:23 +01:00
woikos
3750e99e61 Release v1.0.8 - Add wallet tab and UI improvements
- Add new wallet tab with placeholder for future functionality
- Reorder bottom tabs: You, Identities, Wallet, Bookmarks, Settings
- Move Reset Extension button to bottom-right corner on login page
- Improve header layout consistency across all pages
- Add custom scrollbar styling for Chrome (thin, dark track, light thumb)
- Update edit button to use emoji icon on identity page
- Fix horizontal overflow issues in global styles

Files modified:
- package.json (version bump)
- projects/{chrome,firefox}/src/app/app.routes.ts (wallet route)
- projects/{chrome,firefox}/src/app/components/home/wallet/* (new)
- projects/{chrome,firefox}/src/app/components/home/home.component.* (tabs)
- projects/{chrome,firefox}/src/app/components/vault-login/* (reset btn)
- projects/{chrome,firefox}/src/styles.scss (scrollbar, overflow)
- Various component HTML/SCSS files (header consistency)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:30:55 +01:00
woikos
2c1f3265b7 Release v1.0.7 - Move lock button to page headers
- Move lock button from bottom navigation bar to each page header
- Add lock button styling to common SCSS with hover effect
- Remove logs and info tabs from bottom navigation
- Standardize header height to 48px across all pages
- Simplify home component by removing lock logic

Files modified:
- package.json, manifest.json (both browsers)
- home.component.html/ts (both browsers)
- identities, identity, bookmarks, logs, info, settings components
- projects/common/src/lib/styles/_common.scss

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 05:15:21 +01:00
woikos
7ff8e257dd Add prebuild script to fetch event kinds from nostr library
- Add scripts/fetch-kinds.js to fetch kinds.json from central source
- Add event-kinds.ts with TypeScript types and 184 event kinds
- Update package.json build scripts to fetch kinds before building
- Source: https://git.mleku.dev/mleku/nostr/raw/branch/main/encoders/kind/kinds.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 05:08:00 +01:00
woikos
8b6ead1f81 Release v1.0.6 - Add store publishing documentation
- Store description for Chrome Web Store and Firefox Add-ons
- Privacy policy (no data collection, local-only storage)
- Comprehensive publishing guide with step-by-step checklists
- Concise publishing checklist for quick reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:32:55 +01:00
woikos
38d9a9ef9f Add concise publishing checklist
Streamlined guide for completing Chrome/Firefox submissions once screenshots are ready.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:24:40 +01:00
woikos
b55a3f01b6 Add extension store publishing documentation
- Store description for Chrome Web Store and Firefox Add-ons
- Privacy policy (no data collection, local-only storage)
- Comprehensive publishing guide with checklists

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:17:36 +01:00
b7bedf085a Release v1.0.5 - Update release command to clean old zips
- Updated /release command to delete old zip files before creating new ones
- Ensures releases/ folder only contains the latest version

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:50:17 +01:00
ff82e41012 Update release command to delete old zips before creating new ones
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:44:56 +01:00
45b1fb58e9 Release v1.0.4 - Add logging system, lock button, and emoji navigation
- Comprehensive logging system with chrome.storage.session persistence
- NIP-07 action logging in background scripts with standalone functions
- Vault operation logging (unlock, lock, create, reset, import, export)
- Profile and bookmark operation logging
- Logs page with refresh functionality and category icons
- Lock button (🔒) in navigation bar to quickly lock vault
- Reduced nav bar size (40px height, 16px font) with emoji icons
- Reordered navigation: You, Permissions, Bookmarks, Logs, About, Lock
- Bookmarks functionality for saving frequently used Nostr apps
- Fixed lock/unlock flow by properly clearing in-memory session data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:42:19 +01:00
b535a7b967 Release v1.0.3 - Add zip file creation to release process
- Update /release command to create zip files in releases/ folder
- Add v1.0.3 zip files for both Chrome and Firefox extensions

Files modified:
- .claude/commands/release.md
- releases/plebeian-signer-chrome-v1.0.3.zip (new)
- releases/plebeian-signer-firefox-v1.0.3.zip (new)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 09:08:34 +01:00
abd4a21f8f Release v1.0.2 - Fix Buffer polyfill race condition in prompt
- Fix race condition where permission prompts failed on first request
  due to Buffer polyfill not being initialized during module evaluation
- Replace Buffer.from() with native browser APIs (atob + TextDecoder)
  in prompt.ts for reliable base64 decoding
- Add debug logging to reckless mode approval checks
- Update permission encryption to support v2 vault key format
- Enhance LoggerService with warn/error/debug methods and log storage
- Add logs component for viewing extension activity
- Simplify deriving modal component
- Rename icon files from gooti to plebian-signer
- Update permissions component with improved styling

Files modified:
- projects/chrome/src/prompt.ts
- projects/firefox/src/prompt.ts
- projects/*/src/background-common.ts
- projects/common/src/lib/services/logger/logger.service.ts
- projects/*/src/app/components/home/logs/ (new)
- projects/*/public/*.svg, *.png (renamed)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 08:52:44 +01:00
4b2d23e942 Release v1.0.1 - Improve CLAUDE.md documentation
- Add Vault Encryption section documenting Argon2id + AES-256-GCM scheme
- Document key derivation parameters (256MB memory, 4 threads, 8 iterations)
- Add Permission System section with reckless mode and whitelist info
- Update Angular version to 19, add Argon2Crypto to common library exports
- Expand SignerMetaHandler description with reckless mode and whitelist

Files modified:
- CLAUDE.md
- package.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:24:48 +01:00
ebe2b695cc Release v1.0.0 - Major security upgrade with Argon2id encryption
- Upgrade vault encryption from PBKDF2 (1000 iterations) to Argon2id
  (256MB memory, 8 iterations, 4 threads, ~3 second derivation)
- Add automatic migration from v1 to v2 vault format on unlock
- Add WebAssembly CSP support for hash-wasm Argon2id implementation
- Add NIP-42 relay authentication support for auth-required relays
- Add profile edit feature with pencil icon on identity page
- Add direct NIP-05 validation (removes NDK dependency for validation)
- Add deriving modal with progress timer during key derivation
- Add client tag "plebeian-signer" to profile events
- Fix modal colors (dark theme for visibility)
- Fix NIP-05 badge styling to include check/error indicator
- Add release zip packages for Chrome and Firefox

New files:
- projects/common/src/lib/helpers/argon2-crypto.ts
- projects/common/src/lib/helpers/websocket-auth.ts
- projects/common/src/lib/helpers/nip05-validator.ts
- projects/common/src/lib/components/deriving-modal/
- projects/{chrome,firefox}/src/app/components/profile-edit/
- releases/plebeian-signer-{chrome,firefox}-v1.0.0.zip

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 12:30:10 +01:00
ddb74c61b2 Release v0.0.9 - Add reckless mode with whitelisted apps
- Add reckless mode checkbox to auto-approve signing requests
- Implement whitelisted apps management page
- Reckless mode logic: allow all if whitelist empty, otherwise only whitelisted hosts
- Add shouldRecklessModeApprove() in background service worker
- Update default avatar to Plebeian Market Account icon
- Fix manifest version scripts to strip v prefix for browsers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 10:56:45 +01:00
5550d41293 Release v0.0.8 - Adopt standard semver versioning
- Update release command to use standard semver with v prefix (v0.0.0 format)
- Add seamless migration from legacy non-prefixed versions (0.0.x -> v0.0.x)
- Document version format in release command instructions
- Bump version from 0.0.7 to v0.0.8 in package.json

Files modified:
- .claude/commands/release.md
- package.json

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 09:39:21 +01:00
1491ac13af Release v0.0.7 - Identity list UI redesign with avatars
- Redesign identities list to show avatar and display name from Nostr profile
- Replace star icon selection with clickable row for identity switching
- Add gear icon for direct access to identity settings
- Highlight selected identity with primary color border
- Remove credits box from information tab
- Add rebuild instruction to CLAUDE.md
- Clean up unused imports in info components
- Replace HTML autofocus with programmatic focus for accessibility

Files modified:
- CLAUDE.md
- package.json
- projects/chrome/src/app/components/home/identities/*
- projects/chrome/src/app/components/home/info/*
- projects/chrome/src/app/components/vault-login/*
- projects/firefox/src/app/components/home/identities/*
- projects/firefox/src/app/components/home/info/*
- projects/firefox/src/app/components/vault-login/*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 09:07:36 +01:00
578f3e08ff Add NIP-65 relay list display and improve identity UI
- Add NIP-65 relay list service to fetch kind 10002 events from relays
- Replace configurable relay page with read-only NIP-65 relay display
- Update identity page to show display name and username in same badge
- Use reglisse heading font for titles throughout the UI
- Navigate to You page after vault unlock instead of identities list
- Add autofocus to vault password input field
- Add profile metadata service for fetching kind 0 events
- Add readonly mode to relay-rw component

Files modified:
- package.json (version bump to 0.0.6)
- projects/common/src/lib/services/relay-list/relay-list.service.ts (new)
- projects/common/src/lib/services/profile-metadata/profile-metadata.service.ts (new)
- projects/common/src/lib/constants/fallback-relays.ts (new)
- projects/*/src/app/components/home/identity/* (UI improvements)
- projects/*/src/app/components/edit-identity/relays/* (NIP-65 display)
- projects/*/src/app/components/vault-login/* (autofocus, navigation)
- projects/common/src/lib/styles/* (heading fonts)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 15:21:57 +01:00
fe886d2101 Rebrand to Plebeian Signer with updated colors and UI improvements
- Fix project name from "Plebian" to "Plebeian" throughout codebase
- Replace gooti logo with Plebeian Market logo across all screens
- Update color scheme to match Plebeian Market (pink accent #ff3eb5)
- Add IBM Plex Mono fonts and Reglisse heading font
- Center vault create/import screen content vertically
- Reduce spacing on sync preference screen to prevent scrolling
- Add Enter key support on vault login password field
- Update options page with new logo and color scheme
- Bump version to 0.0.5

Files modified:
- package.json (version bump, name fix)
- projects/*/public/{logo.svg,options.html,manifest.json}
- projects/*/src/app/components/vault-*/*.{html,scss}
- projects/*/src/app/components/welcome/*.html
- projects/common/src/lib/styles/*.scss
- Various component files for branding updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:47:38 +01:00
3c63e6555c Rename project from Gooti to Plebian Signer and add Claude Code config
- Rename all gooti-* files to plebian-signer-* across Chrome and Firefox
- Rename GootiMetaHandler to SignerMetaHandler in common library
- Update all references to use new naming convention
- Add CLAUDE.md with project build/architecture documentation
- Add Claude Code release command tailored for this npm/Angular project
- Add NWC-IMPLEMENTATION.md design document
- Add Claude skills for nostr, typescript, react, svelte, and applesauce libs
- Update README and various component templates with new branding

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 09:29:00 +01:00
326 changed files with 49016 additions and 2495 deletions

View File

@@ -0,0 +1,79 @@
# Release Command
Review all changes in the repository and create a release with proper commit message, version tag, and push to origin.
## Argument: $ARGUMENTS
The argument should be one of:
- `patch` - Bump the patch version (e.g., v0.0.4 -> v0.0.5)
- `minor` - Bump the minor version and reset patch to 0 (e.g., v0.0.4 -> v0.1.0)
- `major` - Bump the major version and reset minor/patch to 0 (e.g., v0.0.4 -> v1.0.0)
If no argument provided, default to `patch`.
## Version Format
This project uses **standard semver with `v` prefix** (e.g., `v0.0.8`, `v1.2.3`).
## Steps to perform:
1. **Read the current version** from `package.json` (the `version` field)
- Strip any existing `v` prefix if present (for backward compatibility with old `0.0.x` format)
- The raw version should be in format: MAJOR.MINOR.PATCH
2. **Calculate the new version** based on the argument:
- Parse the current version (format: MAJOR.MINOR.PATCH)
- If `patch`: increment PATCH by 1
- If `minor`: increment MINOR by 1, set PATCH to 0
- If `major`: increment MAJOR by 1, set MINOR and PATCH to 0
3. **Update package.json** with the new version (with `v` prefix) in all three places:
- `version` -> `vX.Y.Z`
- `custom.chrome.version` -> `vX.Y.Z`
- `custom.firefox.version` -> `vX.Y.Z`
4. **Review changes** using `git status` and `git diff --stat HEAD`
5. **Verify the build** before committing:
```
npm run lint
npm run build:chrome
npm run build:firefox
```
If any step fails, fix issues before proceeding.
6. **Create release zip files** in the `releases/` folder:
```
mkdir -p releases
rm -f releases/plebeian-signer-chrome-v*.zip releases/plebeian-signer-firefox-v*.zip
cd dist/chrome && zip -r ../../releases/plebeian-signer-chrome-vX.Y.Z.zip . && cd ../..
cd dist/firefox && zip -r ../../releases/plebeian-signer-firefox-vX.Y.Z.zip . && cd ../..
```
Replace `vX.Y.Z` with the actual version number. Old zip files are deleted to keep only the latest release.
7. **Compose a commit message** following this format:
- First line: 72 chars max, imperative mood summary (e.g., "Release v0.0.8")
- Blank line
- Bullet points describing each significant change
- "Files modified:" section listing affected files
- Footer with Claude Code attribution
8. **Stage all changes** with `git add -A`
9. **Create the commit** with the composed message
10. **Create a git tag** matching the version (e.g., `v0.0.8`)
11. **Push to origin** with tags:
```
git push origin main --tags
```
12. **Report completion** with the new version and commit hash
## Important:
- This is a browser extension with separate Chrome and Firefox builds
- All three version fields in package.json must be updated together
- Always verify both Chrome and Firefox builds compile before committing
- Version format is standard semver with `v` prefix: `vMAJOR.MINOR.PATCH`
- Legacy versions without `v` prefix (e.g., `0.0.7`) are automatically upgraded to the new format

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(sudo tee:*)",
"Bash(sudo apt install:*)",
"Bash(sudo sed:*)",
"Bash(sudo systemctl enable:*)",
"Bash(sudo systemctl start:*)",
"Bash(sudo tlp start:*)",
"Bash(chown:*)",
"Bash(find:*)"
]
}
}

View File

@@ -0,0 +1,634 @@
---
name: applesauce-core
description: This skill should be used when working with applesauce-core library for Nostr client development, including event stores, queries, observables, and client utilities. Provides comprehensive knowledge of applesauce patterns for building reactive Nostr applications.
---
# applesauce-core Skill
This skill provides comprehensive knowledge and patterns for working with applesauce-core, a library that provides reactive utilities and patterns for building Nostr clients.
## When to Use This Skill
Use this skill when:
- Building reactive Nostr applications
- Managing event stores and caches
- Working with observable patterns for Nostr
- Implementing real-time updates
- Building timeline and feed views
- Managing replaceable events
- Working with profiles and metadata
- Creating efficient Nostr queries
## Core Concepts
### applesauce-core Overview
applesauce-core provides:
- **Event stores** - Reactive event caching and management
- **Queries** - Declarative event querying patterns
- **Observables** - RxJS-based reactive patterns
- **Profile helpers** - Profile metadata management
- **Timeline utilities** - Feed and timeline building
- **NIP helpers** - NIP-specific utilities
### Installation
```bash
npm install applesauce-core
```
### Basic Architecture
applesauce-core is built on reactive principles:
- Events are stored in reactive stores
- Queries return observables that update when new events arrive
- Components subscribe to observables for real-time updates
## Event Store
### Creating an Event Store
```javascript
import { EventStore } from 'applesauce-core';
// Create event store
const eventStore = new EventStore();
// Add events
eventStore.add(event1);
eventStore.add(event2);
// Add multiple events
eventStore.addMany([event1, event2, event3]);
// Check if event exists
const exists = eventStore.has(eventId);
// Get event by ID
const event = eventStore.get(eventId);
// Remove event
eventStore.remove(eventId);
// Clear all events
eventStore.clear();
```
### Event Store Queries
```javascript
// Get all events
const allEvents = eventStore.getAll();
// Get events by filter
const filtered = eventStore.filter({
kinds: [1],
authors: [pubkey]
});
// Get events by author
const authorEvents = eventStore.getByAuthor(pubkey);
// Get events by kind
const textNotes = eventStore.getByKind(1);
```
### Replaceable Events
applesauce-core handles replaceable events automatically:
```javascript
// For kind 0 (profile), only latest is kept
eventStore.add(profileEvent1); // stored
eventStore.add(profileEvent2); // replaces if newer
// For parameterized replaceable (30000-39999)
eventStore.add(articleEvent); // keyed by author + kind + d-tag
// Get replaceable event
const profile = eventStore.getReplaceable(0, pubkey);
const article = eventStore.getReplaceable(30023, pubkey, 'article-slug');
```
## Queries
### Query Patterns
```javascript
import { createQuery } from 'applesauce-core';
// Create a query
const query = createQuery(eventStore, {
kinds: [1],
limit: 50
});
// Subscribe to query results
query.subscribe(events => {
console.log('Current events:', events);
});
// Query updates automatically when new events added
eventStore.add(newEvent); // Subscribers notified
```
### Timeline Query
```javascript
import { TimelineQuery } from 'applesauce-core';
// Create timeline for user's notes
const timeline = new TimelineQuery(eventStore, {
kinds: [1],
authors: [userPubkey]
});
// Get observable of timeline
const timeline$ = timeline.events$;
// Subscribe
timeline$.subscribe(events => {
// Events sorted by created_at, newest first
renderTimeline(events);
});
```
### Profile Query
```javascript
import { ProfileQuery } from 'applesauce-core';
// Query profile metadata
const profileQuery = new ProfileQuery(eventStore, pubkey);
// Get observable
const profile$ = profileQuery.profile$;
profile$.subscribe(profile => {
if (profile) {
console.log('Name:', profile.name);
console.log('Picture:', profile.picture);
}
});
```
## Observables
### Working with RxJS
applesauce-core uses RxJS observables:
```javascript
import { map, filter, distinctUntilChanged } from 'rxjs/operators';
// Transform query results
const names$ = profileQuery.profile$.pipe(
filter(profile => profile !== null),
map(profile => profile.name),
distinctUntilChanged()
);
// Combine multiple observables
import { combineLatest } from 'rxjs';
const combined$ = combineLatest([
timeline$,
profile$
]).pipe(
map(([events, profile]) => ({
events,
authorName: profile?.name
}))
);
```
### Creating Custom Observables
```javascript
import { Observable } from 'rxjs';
function createEventObservable(store, filter) {
return new Observable(subscriber => {
// Initial emit
subscriber.next(store.filter(filter));
// Subscribe to store changes
const unsubscribe = store.onChange(() => {
subscriber.next(store.filter(filter));
});
// Cleanup
return () => unsubscribe();
});
}
```
## Profile Helpers
### Profile Metadata
```javascript
import { parseProfile, ProfileContent } from 'applesauce-core';
// Parse kind 0 content
const profileEvent = await getProfileEvent(pubkey);
const profile = parseProfile(profileEvent);
// Profile fields
console.log(profile.name); // Display name
console.log(profile.about); // Bio
console.log(profile.picture); // Avatar URL
console.log(profile.banner); // Banner image URL
console.log(profile.nip05); // NIP-05 identifier
console.log(profile.lud16); // Lightning address
console.log(profile.website); // Website URL
```
### Profile Store
```javascript
import { ProfileStore } from 'applesauce-core';
const profileStore = new ProfileStore(eventStore);
// Get profile observable
const profile$ = profileStore.getProfile(pubkey);
// Get multiple profiles
const profiles$ = profileStore.getProfiles([pubkey1, pubkey2]);
// Request profile load (triggers fetch if not cached)
profileStore.requestProfile(pubkey);
```
## Timeline Utilities
### Building Feeds
```javascript
import { Timeline } from 'applesauce-core';
// Create timeline
const timeline = new Timeline(eventStore);
// Add filter
timeline.setFilter({
kinds: [1, 6],
authors: followedPubkeys
});
// Get events observable
const events$ = timeline.events$;
// Load more (pagination)
timeline.loadMore(50);
// Refresh (get latest)
timeline.refresh();
```
### Thread Building
```javascript
import { ThreadBuilder } from 'applesauce-core';
// Build thread from root event
const thread = new ThreadBuilder(eventStore, rootEventId);
// Get thread observable
const thread$ = thread.thread$;
thread$.subscribe(threadData => {
console.log('Root:', threadData.root);
console.log('Replies:', threadData.replies);
console.log('Reply count:', threadData.replyCount);
});
```
### Reactions and Zaps
```javascript
import { ReactionStore, ZapStore } from 'applesauce-core';
// Reactions
const reactionStore = new ReactionStore(eventStore);
const reactions$ = reactionStore.getReactions(eventId);
reactions$.subscribe(reactions => {
console.log('Likes:', reactions.likes);
console.log('Custom:', reactions.custom);
});
// Zaps
const zapStore = new ZapStore(eventStore);
const zaps$ = zapStore.getZaps(eventId);
zaps$.subscribe(zaps => {
console.log('Total sats:', zaps.totalAmount);
console.log('Zap count:', zaps.count);
});
```
## NIP Helpers
### NIP-05 Verification
```javascript
import { verifyNip05 } from 'applesauce-core';
// Verify NIP-05
const result = await verifyNip05('alice@example.com', expectedPubkey);
if (result.valid) {
console.log('NIP-05 verified');
} else {
console.log('Verification failed:', result.error);
}
```
### NIP-10 Reply Parsing
```javascript
import { parseReplyTags } from 'applesauce-core';
// Parse reply structure
const parsed = parseReplyTags(event);
console.log('Root event:', parsed.root);
console.log('Reply to:', parsed.reply);
console.log('Mentions:', parsed.mentions);
```
### NIP-65 Relay Lists
```javascript
import { parseRelayList } from 'applesauce-core';
// Parse relay list event (kind 10002)
const relays = parseRelayList(relayListEvent);
console.log('Read relays:', relays.read);
console.log('Write relays:', relays.write);
```
## Integration with nostr-tools
### Using with SimplePool
```javascript
import { SimplePool } from 'nostr-tools';
import { EventStore } from 'applesauce-core';
const pool = new SimplePool();
const eventStore = new EventStore();
// Load events into store
pool.subscribeMany(relays, [filter], {
onevent(event) {
eventStore.add(event);
}
});
// Query store reactively
const timeline$ = createTimelineQuery(eventStore, filter);
```
### Publishing Events
```javascript
import { finalizeEvent } from 'nostr-tools';
// Create event
const event = finalizeEvent({
kind: 1,
content: 'Hello!',
created_at: Math.floor(Date.now() / 1000),
tags: []
}, secretKey);
// Add to local store immediately (optimistic update)
eventStore.add(event);
// Publish to relays
await pool.publish(relays, event);
```
## Svelte Integration
### Using in Svelte Components
```svelte
<script>
import { onMount, onDestroy } from 'svelte';
import { EventStore, TimelineQuery } from 'applesauce-core';
export let pubkey;
const eventStore = new EventStore();
let events = [];
let subscription;
onMount(() => {
const timeline = new TimelineQuery(eventStore, {
kinds: [1],
authors: [pubkey]
});
subscription = timeline.events$.subscribe(e => {
events = e;
});
});
onDestroy(() => {
subscription?.unsubscribe();
});
</script>
{#each events as event}
<div class="event">
{event.content}
</div>
{/each}
```
### Svelte Store Adapter
```javascript
import { readable } from 'svelte/store';
// Convert RxJS observable to Svelte store
function fromObservable(observable, initialValue) {
return readable(initialValue, set => {
const subscription = observable.subscribe(set);
return () => subscription.unsubscribe();
});
}
// Usage
const events$ = timeline.events$;
const eventsStore = fromObservable(events$, []);
```
```svelte
<script>
import { eventsStore } from './stores.js';
</script>
{#each $eventsStore as event}
<div>{event.content}</div>
{/each}
```
## Best Practices
### Store Management
1. **Single store instance** - Use one EventStore per app
2. **Clear stale data** - Implement cache limits
3. **Handle replaceable events** - Let store manage deduplication
4. **Unsubscribe** - Clean up subscriptions on component destroy
### Query Optimization
1. **Use specific filters** - Narrow queries perform better
2. **Limit results** - Use limit for initial loads
3. **Cache queries** - Reuse query instances
4. **Debounce updates** - Throttle rapid changes
### Memory Management
1. **Limit store size** - Implement LRU or time-based eviction
2. **Clean up observables** - Unsubscribe when done
3. **Use weak references** - For profile caches
4. **Paginate large feeds** - Don't load everything at once
### Reactive Patterns
1. **Prefer observables** - Over imperative queries
2. **Use operators** - Transform data with RxJS
3. **Combine streams** - For complex views
4. **Handle loading states** - Show placeholders
## Common Patterns
### Event Deduplication
```javascript
// EventStore handles deduplication automatically
eventStore.add(event1);
eventStore.add(event1); // No duplicate
// For manual deduplication
const seen = new Set();
events.filter(e => {
if (seen.has(e.id)) return false;
seen.add(e.id);
return true;
});
```
### Optimistic Updates
```javascript
async function publishNote(content) {
// Create event
const event = await createEvent(content);
// Add to store immediately (optimistic)
eventStore.add(event);
try {
// Publish to relays
await pool.publish(relays, event);
} catch (error) {
// Remove on failure
eventStore.remove(event.id);
throw error;
}
}
```
### Loading States
```javascript
import { BehaviorSubject, combineLatest } from 'rxjs';
const loading$ = new BehaviorSubject(true);
const events$ = timeline.events$;
const state$ = combineLatest([loading$, events$]).pipe(
map(([loading, events]) => ({
loading,
events,
empty: !loading && events.length === 0
}))
);
// Start loading
loading$.next(true);
await loadEvents();
loading$.next(false);
```
### Infinite Scroll
```javascript
function createInfiniteScroll(timeline, pageSize = 50) {
let loading = false;
async function loadMore() {
if (loading) return;
loading = true;
await timeline.loadMore(pageSize);
loading = false;
}
function onScroll(event) {
const { scrollTop, scrollHeight, clientHeight } = event.target;
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
loadMore();
}
}
return { loadMore, onScroll };
}
```
## Troubleshooting
### Common Issues
**Events not updating:**
- Check subscription is active
- Verify events are being added to store
- Ensure filter matches events
**Memory growing:**
- Implement store size limits
- Clean up subscriptions
- Use weak references where appropriate
**Slow queries:**
- Add indexes for common queries
- Use more specific filters
- Implement pagination
**Stale data:**
- Implement refresh mechanisms
- Set up real-time subscriptions
- Handle replaceable event updates
## References
- **applesauce GitHub**: https://github.com/hzrd149/applesauce
- **RxJS Documentation**: https://rxjs.dev
- **nostr-tools**: https://github.com/nbd-wtf/nostr-tools
- **Nostr Protocol**: https://github.com/nostr-protocol/nostr
## Related Skills
- **nostr-tools** - Lower-level Nostr operations
- **applesauce-signers** - Event signing abstractions
- **svelte** - Building reactive UIs
- **nostr** - Nostr protocol fundamentals

View File

@@ -0,0 +1,757 @@
---
name: applesauce-signers
description: This skill should be used when working with applesauce-signers library for Nostr event signing, including NIP-07 browser extensions, NIP-46 remote signing, and custom signer implementations. Provides comprehensive knowledge of signing patterns and signer abstractions.
---
# applesauce-signers Skill
This skill provides comprehensive knowledge and patterns for working with applesauce-signers, a library that provides signing abstractions for Nostr applications.
## When to Use This Skill
Use this skill when:
- Implementing event signing in Nostr applications
- Integrating with NIP-07 browser extensions
- Working with NIP-46 remote signers
- Building custom signer implementations
- Managing signing sessions
- Handling signing requests and permissions
- Implementing multi-signer support
## Core Concepts
### applesauce-signers Overview
applesauce-signers provides:
- **Signer abstraction** - Unified interface for different signers
- **NIP-07 integration** - Browser extension support
- **NIP-46 support** - Remote signing (Nostr Connect)
- **Simple signers** - Direct key signing
- **Permission handling** - Manage signing requests
- **Observable patterns** - Reactive signing states
### Installation
```bash
npm install applesauce-signers
```
### Signer Interface
All signers implement a common interface:
```typescript
interface Signer {
// Get public key
getPublicKey(): Promise<string>;
// Sign event
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
// Encrypt (NIP-04)
nip04Encrypt?(pubkey: string, plaintext: string): Promise<string>;
nip04Decrypt?(pubkey: string, ciphertext: string): Promise<string>;
// Encrypt (NIP-44)
nip44Encrypt?(pubkey: string, plaintext: string): Promise<string>;
nip44Decrypt?(pubkey: string, ciphertext: string): Promise<string>;
}
```
## Simple Signer
### Using Secret Key
```javascript
import { SimpleSigner } from 'applesauce-signers';
import { generateSecretKey } from 'nostr-tools';
// Create signer with existing key
const signer = new SimpleSigner(secretKey);
// Or generate new key
const newSecretKey = generateSecretKey();
const newSigner = new SimpleSigner(newSecretKey);
// Get public key
const pubkey = await signer.getPublicKey();
// Sign event
const unsignedEvent = {
kind: 1,
content: 'Hello Nostr!',
created_at: Math.floor(Date.now() / 1000),
tags: []
};
const signedEvent = await signer.signEvent(unsignedEvent);
```
### NIP-04 Encryption
```javascript
// Encrypt message
const ciphertext = await signer.nip04Encrypt(
recipientPubkey,
'Secret message'
);
// Decrypt message
const plaintext = await signer.nip04Decrypt(
senderPubkey,
ciphertext
);
```
### NIP-44 Encryption
```javascript
// Encrypt with NIP-44 (preferred)
const ciphertext = await signer.nip44Encrypt(
recipientPubkey,
'Secret message'
);
// Decrypt
const plaintext = await signer.nip44Decrypt(
senderPubkey,
ciphertext
);
```
## NIP-07 Signer
### Browser Extension Integration
```javascript
import { Nip07Signer } from 'applesauce-signers';
// Check if extension is available
if (window.nostr) {
const signer = new Nip07Signer();
// Get public key (may prompt user)
const pubkey = await signer.getPublicKey();
// Sign event (prompts user)
const signedEvent = await signer.signEvent(unsignedEvent);
}
```
### Handling Extension Availability
```javascript
function getAvailableSigner() {
if (typeof window !== 'undefined' && window.nostr) {
return new Nip07Signer();
}
return null;
}
// Wait for extension to load
async function waitForExtension(timeout = 3000) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (window.nostr) {
return new Nip07Signer();
}
await new Promise(r => setTimeout(r, 100));
}
return null;
}
```
### Extension Permissions
```javascript
// Some extensions support granular permissions
const signer = new Nip07Signer();
// Request specific permissions
try {
// This varies by extension
await window.nostr.enable();
} catch (error) {
console.log('User denied permission');
}
```
## NIP-46 Remote Signer
### Nostr Connect
```javascript
import { Nip46Signer } from 'applesauce-signers';
// Create remote signer
const signer = new Nip46Signer({
// Remote signer's pubkey
remotePubkey: signerPubkey,
// Relays for communication
relays: ['wss://relay.example.com'],
// Local secret key for encryption
localSecretKey: localSecretKey,
// Optional: custom client name
clientName: 'My Nostr App'
});
// Connect to remote signer
await signer.connect();
// Get public key
const pubkey = await signer.getPublicKey();
// Sign event
const signedEvent = await signer.signEvent(unsignedEvent);
// Disconnect when done
signer.disconnect();
```
### Connection URL
```javascript
// Parse nostrconnect:// URL
function parseNostrConnectUrl(url) {
const parsed = new URL(url);
return {
pubkey: parsed.pathname.replace('//', ''),
relay: parsed.searchParams.get('relay'),
secret: parsed.searchParams.get('secret')
};
}
// Create signer from URL
const { pubkey, relay, secret } = parseNostrConnectUrl(connectUrl);
const signer = new Nip46Signer({
remotePubkey: pubkey,
relays: [relay],
localSecretKey: generateSecretKey(),
secret: secret
});
```
### Bunker URL
```javascript
// Parse bunker:// URL (NIP-46)
function parseBunkerUrl(url) {
const parsed = new URL(url);
return {
pubkey: parsed.pathname.replace('//', ''),
relays: parsed.searchParams.getAll('relay'),
secret: parsed.searchParams.get('secret')
};
}
const { pubkey, relays, secret } = parseBunkerUrl(bunkerUrl);
```
## Signer Management
### Signer Store
```javascript
import { SignerStore } from 'applesauce-signers';
const signerStore = new SignerStore();
// Set active signer
signerStore.setSigner(signer);
// Get active signer
const activeSigner = signerStore.getSigner();
// Clear signer (logout)
signerStore.clearSigner();
// Observable for signer changes
signerStore.signer$.subscribe(signer => {
if (signer) {
console.log('Logged in');
} else {
console.log('Logged out');
}
});
```
### Multi-Account Support
```javascript
class AccountManager {
constructor() {
this.accounts = new Map();
this.activeAccount = null;
}
addAccount(pubkey, signer) {
this.accounts.set(pubkey, signer);
}
removeAccount(pubkey) {
this.accounts.delete(pubkey);
if (this.activeAccount === pubkey) {
this.activeAccount = null;
}
}
switchAccount(pubkey) {
if (this.accounts.has(pubkey)) {
this.activeAccount = pubkey;
return this.accounts.get(pubkey);
}
return null;
}
getActiveSigner() {
return this.activeAccount
? this.accounts.get(this.activeAccount)
: null;
}
}
```
## Custom Signers
### Implementing a Custom Signer
```javascript
class CustomSigner {
constructor(options) {
this.options = options;
}
async getPublicKey() {
// Return public key
return this.options.pubkey;
}
async signEvent(event) {
// Implement signing logic
// Could call external API, hardware wallet, etc.
const signedEvent = await this.externalSign(event);
return signedEvent;
}
async nip04Encrypt(pubkey, plaintext) {
// Implement NIP-04 encryption
throw new Error('NIP-04 not supported');
}
async nip04Decrypt(pubkey, ciphertext) {
throw new Error('NIP-04 not supported');
}
async nip44Encrypt(pubkey, plaintext) {
// Implement NIP-44 encryption
throw new Error('NIP-44 not supported');
}
async nip44Decrypt(pubkey, ciphertext) {
throw new Error('NIP-44 not supported');
}
}
```
### Hardware Wallet Signer
```javascript
class HardwareWalletSigner {
constructor(devicePath) {
this.devicePath = devicePath;
}
async connect() {
// Connect to hardware device
this.device = await connectToDevice(this.devicePath);
}
async getPublicKey() {
// Get public key from device
return await this.device.getNostrPubkey();
}
async signEvent(event) {
// Sign on device (user confirms on device)
const signature = await this.device.signNostrEvent(event);
return {
...event,
pubkey: await this.getPublicKey(),
id: getEventHash(event),
sig: signature
};
}
}
```
### Read-Only Signer
```javascript
class ReadOnlySigner {
constructor(pubkey) {
this.pubkey = pubkey;
}
async getPublicKey() {
return this.pubkey;
}
async signEvent(event) {
throw new Error('Read-only mode: cannot sign events');
}
async nip04Encrypt(pubkey, plaintext) {
throw new Error('Read-only mode: cannot encrypt');
}
async nip04Decrypt(pubkey, ciphertext) {
throw new Error('Read-only mode: cannot decrypt');
}
}
```
## Signing Utilities
### Event Creation Helper
```javascript
async function createAndSignEvent(signer, template) {
const pubkey = await signer.getPublicKey();
const event = {
...template,
pubkey,
created_at: template.created_at || Math.floor(Date.now() / 1000)
};
return await signer.signEvent(event);
}
// Usage
const signedNote = await createAndSignEvent(signer, {
kind: 1,
content: 'Hello!',
tags: []
});
```
### Batch Signing
```javascript
async function signEvents(signer, events) {
const signed = [];
for (const event of events) {
const signedEvent = await signer.signEvent(event);
signed.push(signedEvent);
}
return signed;
}
// With parallelization (if signer supports)
async function signEventsParallel(signer, events) {
return Promise.all(
events.map(event => signer.signEvent(event))
);
}
```
## Svelte Integration
### Signer Context
```svelte
<!-- SignerProvider.svelte -->
<script>
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
const signer = writable(null);
setContext('signer', {
signer,
setSigner: (s) => signer.set(s),
clearSigner: () => signer.set(null)
});
</script>
<slot />
```
```svelte
<!-- Component using signer -->
<script>
import { getContext } from 'svelte';
const { signer } = getContext('signer');
async function publishNote(content) {
if (!$signer) {
alert('Please login first');
return;
}
const event = await $signer.signEvent({
kind: 1,
content,
created_at: Math.floor(Date.now() / 1000),
tags: []
});
// Publish event...
}
</script>
```
### Login Component
```svelte
<script>
import { getContext } from 'svelte';
import { Nip07Signer, SimpleSigner } from 'applesauce-signers';
const { setSigner, clearSigner, signer } = getContext('signer');
let nsec = '';
async function loginWithExtension() {
if (window.nostr) {
setSigner(new Nip07Signer());
} else {
alert('No extension found');
}
}
function loginWithNsec() {
try {
const decoded = nip19.decode(nsec);
if (decoded.type === 'nsec') {
setSigner(new SimpleSigner(decoded.data));
nsec = '';
}
} catch (e) {
alert('Invalid nsec');
}
}
function logout() {
clearSigner();
}
</script>
{#if $signer}
<button on:click={logout}>Logout</button>
{:else}
<button on:click={loginWithExtension}>
Login with Extension
</button>
<div>
<input
type="password"
bind:value={nsec}
placeholder="nsec..."
/>
<button on:click={loginWithNsec}>
Login with Key
</button>
</div>
{/if}
```
## Best Practices
### Security
1. **Never store secret keys in plain text** - Use secure storage
2. **Prefer NIP-07** - Let extensions manage keys
3. **Clear keys on logout** - Don't leave in memory
4. **Validate before signing** - Check event content
### User Experience
1. **Show signing status** - Loading states
2. **Handle rejections gracefully** - User may cancel
3. **Provide fallbacks** - Multiple login options
4. **Remember preferences** - Store signer type
### Error Handling
```javascript
async function safeSign(signer, event) {
try {
return await signer.signEvent(event);
} catch (error) {
if (error.message.includes('rejected')) {
console.log('User rejected signing');
return null;
}
if (error.message.includes('timeout')) {
console.log('Signing timed out');
return null;
}
throw error;
}
}
```
### Permission Checking
```javascript
function hasEncryptionSupport(signer) {
return typeof signer.nip04Encrypt === 'function' ||
typeof signer.nip44Encrypt === 'function';
}
function getEncryptionMethod(signer) {
// Prefer NIP-44
if (typeof signer.nip44Encrypt === 'function') {
return 'nip44';
}
if (typeof signer.nip04Encrypt === 'function') {
return 'nip04';
}
return null;
}
```
## Common Patterns
### Signer Detection
```javascript
async function detectSigners() {
const available = [];
// Check NIP-07
if (typeof window !== 'undefined' && window.nostr) {
available.push({
type: 'nip07',
name: 'Browser Extension',
create: () => new Nip07Signer()
});
}
// Check stored credentials
const storedKey = localStorage.getItem('nsec');
if (storedKey) {
available.push({
type: 'stored',
name: 'Saved Key',
create: () => new SimpleSigner(storedKey)
});
}
return available;
}
```
### Auto-Reconnect for NIP-46
```javascript
class ReconnectingNip46Signer {
constructor(options) {
this.options = options;
this.signer = null;
}
async connect() {
this.signer = new Nip46Signer(this.options);
await this.signer.connect();
}
async signEvent(event) {
try {
return await this.signer.signEvent(event);
} catch (error) {
if (error.message.includes('disconnected')) {
await this.connect();
return await this.signer.signEvent(event);
}
throw error;
}
}
}
```
### Signer Type Persistence
```javascript
const SIGNER_KEY = 'nostr_signer_type';
function saveSigner(type, data) {
localStorage.setItem(SIGNER_KEY, JSON.stringify({ type, data }));
}
async function restoreSigner() {
const saved = localStorage.getItem(SIGNER_KEY);
if (!saved) return null;
const { type, data } = JSON.parse(saved);
switch (type) {
case 'nip07':
if (window.nostr) {
return new Nip07Signer();
}
break;
case 'simple':
// Don't store secret keys!
break;
case 'nip46':
const signer = new Nip46Signer(data);
await signer.connect();
return signer;
}
return null;
}
```
## Troubleshooting
### Common Issues
**Extension not detected:**
- Wait for page load
- Check window.nostr exists
- Verify extension is enabled
**Signing rejected:**
- User cancelled in extension
- Handle gracefully with error message
**NIP-46 connection fails:**
- Check relay is accessible
- Verify remote signer is online
- Check secret matches
**Encryption not supported:**
- Check signer has encrypt methods
- Fall back to alternative method
- Show user appropriate error
## References
- **applesauce GitHub**: https://github.com/hzrd149/applesauce
- **NIP-07 Specification**: https://github.com/nostr-protocol/nips/blob/master/07.md
- **NIP-46 Specification**: https://github.com/nostr-protocol/nips/blob/master/46.md
- **nostr-tools**: https://github.com/nbd-wtf/nostr-tools
## Related Skills
- **nostr-tools** - Event creation and signing utilities
- **applesauce-core** - Event stores and queries
- **nostr** - Nostr protocol fundamentals
- **svelte** - Building Nostr UIs

View File

@@ -0,0 +1,767 @@
---
name: nostr-tools
description: This skill should be used when working with nostr-tools library for Nostr protocol operations, including event creation, signing, filtering, relay communication, and NIP implementations. Provides comprehensive knowledge of nostr-tools APIs and patterns.
---
# nostr-tools Skill
This skill provides comprehensive knowledge and patterns for working with nostr-tools, the most popular JavaScript/TypeScript library for Nostr protocol development.
## When to Use This Skill
Use this skill when:
- Building Nostr clients or applications
- Creating and signing Nostr events
- Connecting to Nostr relays
- Implementing NIP features
- Working with Nostr keys and cryptography
- Filtering and querying events
- Building relay pools or connections
- Implementing NIP-44/NIP-04 encryption
## Core Concepts
### nostr-tools Overview
nostr-tools provides:
- **Event handling** - Create, sign, verify events
- **Key management** - Generate, convert, encode keys
- **Relay communication** - Connect, subscribe, publish
- **NIP implementations** - NIP-04, NIP-05, NIP-19, NIP-44, etc.
- **Cryptographic operations** - Schnorr signatures, encryption
- **Filter building** - Query events by various criteria
### Installation
```bash
npm install nostr-tools
```
### Basic Imports
```javascript
// Core functionality
import {
SimplePool,
generateSecretKey,
getPublicKey,
finalizeEvent,
verifyEvent
} from 'nostr-tools';
// NIP-specific imports
import { nip04, nip05, nip19, nip44 } from 'nostr-tools';
// Relay operations
import { Relay } from 'nostr-tools/relay';
```
## Key Management
### Generating Keys
```javascript
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
// Generate new secret key (Uint8Array)
const secretKey = generateSecretKey();
// Derive public key
const publicKey = getPublicKey(secretKey);
console.log('Secret key:', bytesToHex(secretKey));
console.log('Public key:', publicKey); // hex string
```
### Key Encoding (NIP-19)
```javascript
import { nip19 } from 'nostr-tools';
// Encode to bech32
const nsec = nip19.nsecEncode(secretKey);
const npub = nip19.npubEncode(publicKey);
const note = nip19.noteEncode(eventId);
console.log(nsec); // nsec1...
console.log(npub); // npub1...
console.log(note); // note1...
// Decode from bech32
const { type, data } = nip19.decode(npub);
// type: 'npub', data: publicKey (hex)
// Encode profile reference (nprofile)
const nprofile = nip19.nprofileEncode({
pubkey: publicKey,
relays: ['wss://relay.example.com']
});
// Encode event reference (nevent)
const nevent = nip19.neventEncode({
id: eventId,
relays: ['wss://relay.example.com'],
author: publicKey,
kind: 1
});
// Encode address (naddr) for replaceable events
const naddr = nip19.naddrEncode({
identifier: 'my-article',
pubkey: publicKey,
kind: 30023,
relays: ['wss://relay.example.com']
});
```
## Event Operations
### Event Structure
```javascript
// Unsigned event template
const eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Hello Nostr!'
};
// Signed event (after finalizeEvent)
const signedEvent = {
id: '...', // 32-byte sha256 hash as hex
pubkey: '...', // 32-byte public key as hex
created_at: 1234567890,
kind: 1,
tags: [],
content: 'Hello Nostr!',
sig: '...' // 64-byte Schnorr signature as hex
};
```
### Creating and Signing Events
```javascript
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure';
// Create event template
const eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', publicKey], // Mention
['e', eventId, '', 'reply'], // Reply
['t', 'nostr'] // Hashtag
],
content: 'Hello Nostr!'
};
// Sign event
const signedEvent = finalizeEvent(eventTemplate, secretKey);
// Verify event
const isValid = verifyEvent(signedEvent);
console.log('Event valid:', isValid);
```
### Event Kinds
```javascript
// Common event kinds
const KINDS = {
Metadata: 0, // Profile metadata (NIP-01)
Text: 1, // Short text note (NIP-01)
RecommendRelay: 2, // Relay recommendation
Contacts: 3, // Contact list (NIP-02)
EncryptedDM: 4, // Encrypted DM (NIP-04)
EventDeletion: 5, // Delete events (NIP-09)
Repost: 6, // Repost (NIP-18)
Reaction: 7, // Reaction (NIP-25)
ChannelCreation: 40, // Channel (NIP-28)
ChannelMessage: 42, // Channel message
Zap: 9735, // Zap receipt (NIP-57)
Report: 1984, // Report (NIP-56)
RelayList: 10002, // Relay list (NIP-65)
Article: 30023, // Long-form content (NIP-23)
};
```
### Creating Specific Events
```javascript
// Profile metadata (kind 0)
const profileEvent = finalizeEvent({
kind: 0,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: JSON.stringify({
name: 'Alice',
about: 'Nostr enthusiast',
picture: 'https://example.com/avatar.jpg',
nip05: 'alice@example.com',
lud16: 'alice@getalby.com'
})
}, secretKey);
// Contact list (kind 3)
const contactsEvent = finalizeEvent({
kind: 3,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', pubkey1, 'wss://relay1.com', 'alice'],
['p', pubkey2, 'wss://relay2.com', 'bob'],
['p', pubkey3, '', 'carol']
],
content: '' // Or JSON relay preferences
}, secretKey);
// Reply to an event
const replyEvent = finalizeEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', rootEventId, '', 'root'],
['e', parentEventId, '', 'reply'],
['p', parentEventPubkey]
],
content: 'This is a reply'
}, secretKey);
// Reaction (kind 7)
const reactionEvent = finalizeEvent({
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', eventId],
['p', eventPubkey]
],
content: '+' // or '-' or emoji
}, secretKey);
// Delete event (kind 5)
const deleteEvent = finalizeEvent({
kind: 5,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', eventIdToDelete],
['e', anotherEventIdToDelete]
],
content: 'Deletion reason'
}, secretKey);
```
## Relay Communication
### Using SimplePool
SimplePool is the recommended way to interact with multiple relays:
```javascript
import { SimplePool } from 'nostr-tools/pool';
const pool = new SimplePool();
const relays = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band'
];
// Subscribe to events
const subscription = pool.subscribeMany(
relays,
[
{
kinds: [1],
authors: [publicKey],
limit: 10
}
],
{
onevent(event) {
console.log('Received event:', event);
},
oneose() {
console.log('End of stored events');
}
}
);
// Close subscription when done
subscription.close();
// Publish event to all relays
const results = await Promise.allSettled(
pool.publish(relays, signedEvent)
);
// Query events (returns Promise)
const events = await pool.querySync(relays, {
kinds: [0],
authors: [publicKey]
});
// Get single event
const event = await pool.get(relays, {
ids: [eventId]
});
// Close pool when done
pool.close(relays);
```
### Direct Relay Connection
```javascript
import { Relay } from 'nostr-tools/relay';
const relay = await Relay.connect('wss://relay.damus.io');
console.log(`Connected to ${relay.url}`);
// Subscribe
const sub = relay.subscribe([
{
kinds: [1],
limit: 100
}
], {
onevent(event) {
console.log('Event:', event);
},
oneose() {
console.log('EOSE');
sub.close();
}
});
// Publish
await relay.publish(signedEvent);
// Close
relay.close();
```
### Handling Connection States
```javascript
import { Relay } from 'nostr-tools/relay';
const relay = await Relay.connect('wss://relay.example.com');
// Listen for disconnect
relay.onclose = () => {
console.log('Relay disconnected');
};
// Check connection status
console.log('Connected:', relay.connected);
```
## Filters
### Filter Structure
```javascript
const filter = {
// Event IDs
ids: ['abc123...'],
// Authors (pubkeys)
authors: ['pubkey1', 'pubkey2'],
// Event kinds
kinds: [1, 6, 7],
// Tags (single-letter keys)
'#e': ['eventId1', 'eventId2'],
'#p': ['pubkey1'],
'#t': ['nostr', 'bitcoin'],
'#d': ['article-identifier'],
// Time range
since: 1704067200, // Unix timestamp
until: 1704153600,
// Limit results
limit: 100,
// Search (NIP-50, if relay supports)
search: 'nostr protocol'
};
```
### Common Filter Patterns
```javascript
// User's recent posts
const userPosts = {
kinds: [1],
authors: [userPubkey],
limit: 50
};
// User's profile
const userProfile = {
kinds: [0],
authors: [userPubkey]
};
// User's contacts
const userContacts = {
kinds: [3],
authors: [userPubkey]
};
// Replies to an event
const replies = {
kinds: [1],
'#e': [eventId]
};
// Reactions to an event
const reactions = {
kinds: [7],
'#e': [eventId]
};
// Feed from followed users
const feed = {
kinds: [1, 6],
authors: followedPubkeys,
limit: 100
};
// Events mentioning user
const mentions = {
kinds: [1],
'#p': [userPubkey],
limit: 50
};
// Hashtag search
const hashtagEvents = {
kinds: [1],
'#t': ['bitcoin'],
limit: 100
};
// Replaceable event by d-tag
const replaceableEvent = {
kinds: [30023],
authors: [authorPubkey],
'#d': ['article-slug']
};
```
### Multiple Filters
```javascript
// Subscribe with multiple filters (OR logic)
const filters = [
{ kinds: [1], authors: [userPubkey], limit: 20 },
{ kinds: [1], '#p': [userPubkey], limit: 20 }
];
pool.subscribeMany(relays, filters, {
onevent(event) {
// Receives events matching ANY filter
}
});
```
## Encryption
### NIP-04 (Legacy DMs)
```javascript
import { nip04 } from 'nostr-tools';
// Encrypt message
const ciphertext = await nip04.encrypt(
secretKey,
recipientPubkey,
'Hello, this is secret!'
);
// Create encrypted DM event
const dmEvent = finalizeEvent({
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', recipientPubkey]],
content: ciphertext
}, secretKey);
// Decrypt message
const plaintext = await nip04.decrypt(
secretKey,
senderPubkey,
ciphertext
);
```
### NIP-44 (Modern Encryption)
```javascript
import { nip44 } from 'nostr-tools';
// Get conversation key (cache this for multiple messages)
const conversationKey = nip44.getConversationKey(
secretKey,
recipientPubkey
);
// Encrypt
const ciphertext = nip44.encrypt(
'Hello with NIP-44!',
conversationKey
);
// Decrypt
const plaintext = nip44.decrypt(
ciphertext,
conversationKey
);
```
## NIP Implementations
### NIP-05 (DNS Identifier)
```javascript
import { nip05 } from 'nostr-tools';
// Query NIP-05 identifier
const profile = await nip05.queryProfile('alice@example.com');
if (profile) {
console.log('Pubkey:', profile.pubkey);
console.log('Relays:', profile.relays);
}
// Verify NIP-05 for a pubkey
const isValid = await nip05.queryProfile('alice@example.com')
.then(p => p?.pubkey === expectedPubkey);
```
### NIP-10 (Reply Threading)
```javascript
import { nip10 } from 'nostr-tools';
// Parse reply tags
const parsed = nip10.parse(event);
console.log('Root:', parsed.root); // Original event
console.log('Reply:', parsed.reply); // Direct parent
console.log('Mentions:', parsed.mentions); // Other mentions
console.log('Profiles:', parsed.profiles); // Mentioned pubkeys
```
### NIP-21 (nostr: URIs)
```javascript
// Parse nostr: URIs
const uri = 'nostr:npub1...';
const { type, data } = nip19.decode(uri.replace('nostr:', ''));
```
### NIP-27 (Content References)
```javascript
// Parse nostr:npub and nostr:note references in content
const content = 'Check out nostr:npub1abc... and nostr:note1xyz...';
const references = content.match(/nostr:(n[a-z]+1[a-z0-9]+)/g);
references?.forEach(ref => {
const decoded = nip19.decode(ref.replace('nostr:', ''));
console.log(decoded.type, decoded.data);
});
```
### NIP-57 (Zaps)
```javascript
import { nip57 } from 'nostr-tools';
// Validate zap receipt
const zapReceipt = await pool.get(relays, {
kinds: [9735],
'#e': [eventId]
});
const validatedZap = await nip57.validateZapRequest(zapReceipt);
```
## Utilities
### Hex and Bytes Conversion
```javascript
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
// Convert secret key to hex
const secretKeyHex = bytesToHex(secretKey);
// Convert hex back to bytes
const secretKeyBytes = hexToBytes(secretKeyHex);
```
### Event ID Calculation
```javascript
import { getEventHash } from 'nostr-tools/pure';
// Calculate event ID without signing
const eventId = getEventHash(unsignedEvent);
```
### Signature Operations
```javascript
import {
getSignature,
verifyEvent
} from 'nostr-tools/pure';
// Sign event data
const signature = getSignature(unsignedEvent, secretKey);
// Verify complete event
const isValid = verifyEvent(signedEvent);
```
## Best Practices
### Connection Management
1. **Use SimplePool** - Manages connections efficiently
2. **Limit concurrent connections** - Don't connect to too many relays
3. **Handle disconnections** - Implement reconnection logic
4. **Close subscriptions** - Always close when done
### Event Handling
1. **Verify events** - Always verify signatures
2. **Deduplicate** - Events may come from multiple relays
3. **Handle replaceable events** - Latest by created_at wins
4. **Validate content** - Don't trust event content blindly
### Key Security
1. **Never expose secret keys** - Keep in secure storage
2. **Use NIP-07 in browsers** - Let extensions handle signing
3. **Validate input** - Check key formats before use
### Performance
1. **Cache events** - Avoid re-fetching
2. **Use filters wisely** - Be specific, use limits
3. **Batch operations** - Combine related queries
4. **Close idle connections** - Free up resources
## Common Patterns
### Building a Feed
```javascript
const pool = new SimplePool();
const relays = ['wss://relay.damus.io', 'wss://nos.lol'];
async function loadFeed(followedPubkeys) {
const events = await pool.querySync(relays, {
kinds: [1, 6],
authors: followedPubkeys,
limit: 100
});
// Sort by timestamp
return events.sort((a, b) => b.created_at - a.created_at);
}
```
### Real-time Updates
```javascript
function subscribeToFeed(followedPubkeys, onEvent) {
return pool.subscribeMany(
relays,
[{ kinds: [1, 6], authors: followedPubkeys }],
{
onevent: onEvent,
oneose() {
console.log('Caught up with stored events');
}
}
);
}
```
### Profile Loading
```javascript
async function loadProfile(pubkey) {
const [metadata] = await pool.querySync(relays, {
kinds: [0],
authors: [pubkey],
limit: 1
});
if (metadata) {
return JSON.parse(metadata.content);
}
return null;
}
```
### Event Deduplication
```javascript
const seenEvents = new Set();
function handleEvent(event) {
if (seenEvents.has(event.id)) {
return; // Skip duplicate
}
seenEvents.add(event.id);
// Process event...
}
```
## Troubleshooting
### Common Issues
**Events not publishing:**
- Check relay is writable
- Verify event is properly signed
- Check relay's accepted kinds
**Subscription not receiving events:**
- Verify filter syntax
- Check relay has matching events
- Ensure subscription isn't closed
**Signature verification fails:**
- Check event structure is correct
- Verify keys are in correct format
- Ensure event hasn't been modified
**NIP-05 lookup fails:**
- Check CORS headers on server
- Verify .well-known path is correct
- Handle network timeouts
## References
- **nostr-tools GitHub**: https://github.com/nbd-wtf/nostr-tools
- **Nostr Protocol**: https://github.com/nostr-protocol/nostr
- **NIPs Repository**: https://github.com/nostr-protocol/nips
- **NIP-01 (Basic Protocol)**: https://github.com/nostr-protocol/nips/blob/master/01.md
## Related Skills
- **nostr** - Nostr protocol fundamentals
- **svelte** - Building Nostr UIs with Svelte
- **applesauce-core** - Higher-level Nostr client utilities
- **applesauce-signers** - Nostr signing abstractions

View File

@@ -0,0 +1,162 @@
# Nostr Protocol Skill
A comprehensive Claude skill for working with the Nostr protocol and implementing Nostr clients and relays.
## Overview
This skill provides expert-level knowledge of the Nostr protocol, including:
- Complete NIP (Nostr Implementation Possibilities) reference
- Event structure and cryptographic operations
- Client-relay WebSocket communication
- Event kinds and their behaviors
- Best practices and common pitfalls
## Contents
### SKILL.md
The main skill file containing:
- Core protocol concepts
- Event structure and signing
- WebSocket communication patterns
- Cryptographic operations
- Common implementation patterns
- Quick reference guides
### Reference Files
#### references/nips-overview.md
Comprehensive documentation of all standard NIPs including:
- Core protocol NIPs (NIP-01, NIP-02, etc.)
- Social features (reactions, reposts, channels)
- Identity and discovery (NIP-05, NIP-65)
- Security and privacy (NIP-44, NIP-42)
- Lightning integration (NIP-47, NIP-57)
- Advanced features
#### references/event-kinds.md
Complete reference for all Nostr event kinds:
- Core events (0-999)
- Regular events (1000-9999)
- Replaceable events (10000-19999)
- Ephemeral events (20000-29999)
- Parameterized replaceable events (30000-39999)
- Event lifecycle behaviors
- Common patterns and examples
#### references/common-mistakes.md
Detailed guide on implementation pitfalls:
- Event creation and signing errors
- WebSocket communication issues
- Filter query problems
- Threading mistakes
- Relay management errors
- Security vulnerabilities
- UX considerations
- Testing strategies
## When to Use
Use this skill when:
- Implementing Nostr clients or relays
- Working with Nostr events and messages
- Handling cryptographic signatures and keys
- Implementing any NIP
- Building social features on Nostr
- Debugging Nostr applications
- Discussing Nostr protocol architecture
## Key Features
### Complete NIP Coverage
All standard NIPs documented with:
- Purpose and status
- Implementation details
- Code examples
- Usage patterns
- Interoperability notes
### Cryptographic Operations
Detailed guidance on:
- Event signing with Schnorr signatures
- Event ID calculation
- Signature verification
- Key management (BIP-39, NIP-06)
- Encryption (NIP-04, NIP-44)
### WebSocket Protocol
Complete reference for:
- Message types (EVENT, REQ, CLOSE, OK, EOSE, etc.)
- Filter queries and optimization
- Subscription management
- Connection handling
- Error handling
### Event Lifecycle
Understanding of:
- Regular events (immutable)
- Replaceable events (latest only)
- Ephemeral events (real-time only)
- Parameterized replaceable events (by identifier)
### Best Practices
Comprehensive guidance on:
- Multi-relay architecture
- NIP-65 relay lists
- Event caching
- Optimistic UI
- Security considerations
- Performance optimization
## Quick Start Examples
### Publishing a Note
```javascript
const event = {
pubkey: userPublicKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: "Hello Nostr!"
}
event.id = calculateId(event)
event.sig = signEvent(event, privateKey)
ws.send(JSON.stringify(["EVENT", event]))
```
### Subscribing to Events
```javascript
const filter = {
kinds: [1],
authors: [followedPubkey],
limit: 50
}
ws.send(JSON.stringify(["REQ", "sub-id", filter]))
```
### Replying to a Note
```javascript
const reply = {
kind: 1,
tags: [
["e", originalEventId, "", "root"],
["p", originalAuthorPubkey]
],
content: "Great post!"
}
```
## Official Resources
- **NIPs Repository**: https://github.com/nostr-protocol/nips
- **Nostr Website**: https://nostr.com
- **Nostr Documentation**: https://nostr.how
- **NIP Status**: https://nostr-nips.com
## Skill Maintenance
This skill is based on the official Nostr NIPs repository. As new NIPs are proposed and implemented, this skill should be updated to reflect the latest standards and best practices.
## License
Based on public Nostr protocol specifications (MIT License).

View File

@@ -0,0 +1,459 @@
---
name: nostr
description: This skill should be used when working with the Nostr protocol, implementing Nostr clients or relays, handling Nostr events, or discussing Nostr Implementation Possibilities (NIPs). Provides comprehensive knowledge of Nostr's decentralized protocol, event structure, cryptographic operations, and all standard NIPs.
---
# Nostr Protocol Expert
## Purpose
This skill provides expert-level assistance with the Nostr protocol, a simple, open protocol for global, decentralized, and censorship-resistant social networks. The protocol is built on relays and cryptographic keys, enabling direct peer-to-peer communication without central servers.
## When to Use
Activate this skill when:
- Implementing Nostr clients or relays
- Working with Nostr events and messages
- Handling cryptographic signatures and keys (schnorr signatures on secp256k1)
- Implementing any Nostr Implementation Possibility (NIP)
- Building social networking features on Nostr
- Querying or filtering Nostr events
- Discussing Nostr protocol architecture
- Implementing WebSocket communication with relays
## Core Concepts
### The Protocol Foundation
Nostr operates on two main components:
1. **Clients** - Applications users run to read/write data
2. **Relays** - Servers that store and forward messages
Key principles:
- Everyone runs a client
- Anyone can run a relay
- Users identified by public keys
- Messages signed with private keys
- No central authority or trusted servers
### Events Structure
All data in Nostr is represented as events. An event is a JSON object with this structure:
```json
{
"id": "<32-bytes lowercase hex-encoded sha256 of the serialized event data>",
"pubkey": "<32-bytes lowercase hex-encoded public key of the event creator>",
"created_at": "<unix timestamp in seconds>",
"kind": "<integer identifying event type>",
"tags": [
["<tag name>", "<tag value>", "<optional third param>", "..."]
],
"content": "<arbitrary string>",
"sig": "<64-bytes lowercase hex of the schnorr signature of the sha256 hash of the serialized event data>"
}
```
### Event Kinds
Standard event kinds (from various NIPs):
- `0` - Metadata (user profile)
- `1` - Text note (short post)
- `2` - Recommend relay
- `3` - Contacts (following list)
- `4` - Encrypted direct messages
- `5` - Event deletion
- `6` - Repost
- `7` - Reaction (like, emoji reaction)
- `40` - Channel creation
- `41` - Channel metadata
- `42` - Channel message
- `43` - Channel hide message
- `44` - Channel mute user
- `1000-9999` - Regular events
- `10000-19999` - Replaceable events
- `20000-29999` - Ephemeral events
- `30000-39999` - Parameterized replaceable events
### Tags
Common tag types:
- `["e", "<event-id>", "<relay-url>", "<marker>"]` - Reference to an event
- `["p", "<pubkey>", "<relay-url>"]` - Reference to a user
- `["a", "<kind>:<pubkey>:<d-tag>", "<relay-url>"]` - Reference to a replaceable event
- `["d", "<identifier>"]` - Identifier for parameterized replaceable events
- `["r", "<url>"]` - Reference/link to a web resource
- `["t", "<hashtag>"]` - Hashtag
- `["g", "<geohash>"]` - Geolocation
- `["nonce", "<number>", "<difficulty>"]` - Proof of work
- `["subject", "<subject>"]` - Subject/title
- `["client", "<client-name>"]` - Client application used
## Key NIPs Reference
For detailed specifications, refer to **references/nips-overview.md**.
### Core Protocol NIPs
#### NIP-01: Basic Protocol Flow
The foundation of Nostr. Defines:
- Event structure and validation
- Event ID calculation (SHA256 of serialized event)
- Signature verification (schnorr signatures)
- Client-relay communication via WebSocket
- Message types: EVENT, REQ, CLOSE, EOSE, OK, NOTICE
#### NIP-02: Contact List and Petnames
Event kind `3` for following lists:
- Each `p` tag represents a followed user
- Optional relay URL and petname in tag
- Replaceable event (latest overwrites)
#### NIP-04: Encrypted Direct Messages
Event kind `4` for private messages:
- Content encrypted with shared secret (ECDH)
- `p` tag for recipient pubkey
- Deprecated in favor of NIP-44
#### NIP-05: Mapping Nostr Keys to DNS
Internet identifier format: `name@domain.com`
- `.well-known/nostr.json` endpoint
- Maps names to pubkeys
- Optional relay list
#### NIP-09: Event Deletion
Event kind `5` to request deletion:
- Contains `e` tags for events to delete
- Relays should delete referenced events
- Only works for own events
#### NIP-10: Text Note References (Threads)
Conventions for `e` and `p` tags in replies:
- Root event reference
- Reply event reference
- Mentions
- Marker types: "root", "reply", "mention"
#### NIP-11: Relay Information Document
HTTP endpoint for relay metadata:
- GET request to relay URL
- Returns JSON with relay information
- Supported NIPs, software, limitations
### Social Features NIPs
#### NIP-25: Reactions
Event kind `7` for reactions:
- Content usually "+" (like) or emoji
- `e` tag for reacted event
- `p` tag for event author
#### NIP-42: Authentication
Client authentication to relays:
- AUTH message from relay (challenge)
- Client responds with event kind `22242` signed auth event
- Proves key ownership
**CRITICAL: Clients MUST wait for OK response after AUTH**
- Relays MUST respond to AUTH with an OK message (same as EVENT)
- An OK with `true` confirms the relay has stored the authenticated pubkey
- An OK with `false` indicates authentication failed:
1. **Alert the user** that authentication failed
2. **Assume the relay will reject** subsequent events requiring auth
3. Check the `reason` field for error details (e.g., "error: failed to parse auth event")
- Do NOT send events requiring authentication until OK `true` is received
- If no OK is received within timeout, assume connection issues and retry or alert user
#### NIP-50: Search
Query filter extension for full-text search:
- `search` field in REQ filters
- Implementation-defined behavior
### Advanced NIPs
#### NIP-19: bech32-encoded Entities
Human-readable identifiers:
- `npub`: public key
- `nsec`: private key (sensitive!)
- `note`: note/event ID
- `nprofile`: profile with relay hints
- `nevent`: event with relay hints
- `naddr`: replaceable event coordinate
#### NIP-44: Encrypted Payloads
Improved encryption for direct messages:
- Versioned encryption scheme
- Better security than NIP-04
- ChaCha20-Poly1305 AEAD
#### NIP-65: Relay List Metadata
Event kind `10002` for relay lists:
- Read/write relay preferences
- Optimizes relay discovery
- Replaceable event
## Client-Relay Communication
### WebSocket Messages
#### From Client to Relay
**EVENT** - Publish an event:
```json
["EVENT", <event JSON>]
```
**REQ** - Request events (subscription):
```json
["REQ", <subscription_id>, <filters JSON>, <filters JSON>, ...]
```
**CLOSE** - Stop a subscription:
```json
["CLOSE", <subscription_id>]
```
**AUTH** - Respond to auth challenge:
```json
["AUTH", <signed event kind 22242>]
```
#### From Relay to Client
**EVENT** - Send event to client:
```json
["EVENT", <subscription_id>, <event JSON>]
```
**OK** - Acceptance/rejection notice:
```json
["OK", <event_id>, <true|false>, <message>]
```
**EOSE** - End of stored events:
```json
["EOSE", <subscription_id>]
```
**CLOSED** - Subscription closed:
```json
["CLOSED", <subscription_id>, <message>]
```
**NOTICE** - Human-readable message:
```json
["NOTICE", <message>]
```
**AUTH** - Authentication challenge:
```json
["AUTH", <challenge>]
```
### Filter Objects
Filters select events in REQ messages:
```json
{
"ids": ["<event-id>", ...],
"authors": ["<pubkey>", ...],
"kinds": [<kind number>, ...],
"#e": ["<event-id>", ...],
"#p": ["<pubkey>", ...],
"#a": ["<coordinate>", ...],
"#t": ["<hashtag>", ...],
"since": <unix timestamp>,
"until": <unix timestamp>,
"limit": <max number of events>
}
```
Filtering rules:
- Arrays are ORed together
- Different fields are ANDed
- Tag filters: `#<single-letter>` matches tag values
- Prefix matching allowed for `ids` and `authors`
## Cryptographic Operations
### Key Management
- **Private Key**: 32-byte random value, keep secure
- **Public Key**: Derived via secp256k1
- **Encoding**: Hex (lowercase) or bech32
### Event Signing (schnorr)
Steps to create a signed event:
1. Set all fields except `id` and `sig`
2. Serialize event data to JSON (specific order)
3. Calculate SHA256 hash → `id`
4. Sign `id` with schnorr signature → `sig`
Serialization format for ID calculation:
```json
[
0,
<pubkey>,
<created_at>,
<kind>,
<tags>,
<content>
]
```
### Event Verification
Steps to verify an event:
1. Verify ID matches SHA256 of serialized data
2. Verify signature is valid schnorr signature
3. Check created_at is reasonable (not far future)
4. Validate event structure and required fields
## Implementation Best Practices
### For Clients
1. **Connect to Multiple Relays**: Don't rely on single relay
2. **Cache Events**: Reduce redundant relay queries
3. **Verify Signatures**: Always verify event signatures
4. **Handle Replaceable Events**: Keep only latest version
5. **Respect User Privacy**: Careful with sensitive data
6. **Implement NIP-65**: Use user's preferred relays
7. **Proper Error Handling**: Handle relay disconnections
8. **Pagination**: Use `limit`, `since`, `until` for queries
### For Relays
1. **Validate Events**: Check signatures, IDs, structure
2. **Rate Limiting**: Prevent spam and abuse
3. **Storage Management**: Ephemeral events, retention policies
4. **Implement NIP-11**: Provide relay information
5. **WebSocket Optimization**: Handle many connections
6. **Filter Optimization**: Efficient event querying
7. **Consider NIP-42**: Authentication for write access
8. **Performance**: Index by pubkey, kind, tags, timestamp
### Security Considerations
1. **Never Expose Private Keys**: Handle nsec carefully
2. **Validate All Input**: Prevent injection attacks
3. **Use NIP-44**: For encrypted messages (not NIP-04)
4. **Check Event Timestamps**: Reject far-future events
5. **Implement Proof of Work**: NIP-13 for spam prevention
6. **Sanitize Content**: XSS prevention in displayed content
7. **Relay Trust**: Don't trust single relay for critical data
## Common Patterns
### Publishing a Note
```javascript
const event = {
pubkey: userPublicKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: "Hello Nostr!",
}
// Calculate ID and sign
event.id = calculateId(event)
event.sig = signEvent(event, privateKey)
// Publish to relay
ws.send(JSON.stringify(["EVENT", event]))
```
### Subscribing to Notes
```javascript
const filter = {
kinds: [1],
authors: [followedPubkey1, followedPubkey2],
limit: 50
}
ws.send(JSON.stringify(["REQ", "my-sub", filter]))
```
### Replying to a Note
```javascript
const reply = {
kind: 1,
tags: [
["e", originalEventId, relayUrl, "root"],
["p", originalAuthorPubkey]
],
content: "Great post!",
// ... other fields
}
```
### Reacting to a Note
```javascript
const reaction = {
kind: 7,
tags: [
["e", eventId],
["p", eventAuthorPubkey]
],
content: "+", // or emoji
// ... other fields
}
```
## Development Resources
### Essential NIPs for Beginners
Start with these NIPs in order:
1. **NIP-01** - Basic protocol (MUST read)
2. **NIP-19** - Bech32 identifiers
3. **NIP-02** - Following lists
4. **NIP-10** - Threaded conversations
5. **NIP-25** - Reactions
6. **NIP-65** - Relay lists
### Testing and Development
- **Relay Implementations**: nostream, strfry, relay.py
- **Test Relays**: wss://relay.damus.io, wss://nos.lol
- **Libraries**: nostr-tools (JS), rust-nostr (Rust), python-nostr (Python)
- **Development Tools**: NostrDebug, Nostr Army Knife, nostril
- **Reference Clients**: Damus (iOS), Amethyst (Android), Snort (Web)
### Key Repositories
- **NIPs Repository**: https://github.com/nostr-protocol/nips
- **Awesome Nostr**: https://github.com/aljazceru/awesome-nostr
- **Nostr Resources**: https://nostr.how
## Reference Files
For comprehensive NIP details, see:
- **references/nips-overview.md** - Detailed descriptions of all standard NIPs
- **references/event-kinds.md** - Complete event kinds reference
- **references/common-mistakes.md** - Pitfalls and how to avoid them
## Quick Checklist
When implementing Nostr:
- [ ] Events have all required fields (id, pubkey, created_at, kind, tags, content, sig)
- [ ] Event IDs calculated correctly (SHA256 of serialization)
- [ ] Signatures verified (schnorr on secp256k1)
- [ ] WebSocket messages properly formatted
- [ ] Filter queries optimized with appropriate limits
- [ ] Handling replaceable events correctly
- [ ] Connected to multiple relays for redundancy
- [ ] Following relevant NIPs for features implemented
- [ ] Private keys never exposed or transmitted
- [ ] Event timestamps validated
## Official Resources
- **NIPs Repository**: https://github.com/nostr-protocol/nips
- **Nostr Website**: https://nostr.com
- **Nostr Documentation**: https://nostr.how
- **NIP Status**: https://nostr-nips.com

View File

@@ -0,0 +1,657 @@
# Common Nostr Implementation Mistakes and How to Avoid Them
This document highlights frequent errors made when implementing Nostr clients and relays, along with solutions.
## Event Creation and Signing
### Mistake 1: Incorrect Event ID Calculation
**Problem**: Wrong serialization order or missing fields when calculating SHA256.
**Correct Serialization**:
```json
[
0, // Must be integer 0
<pubkey>, // Lowercase hex string
<created_at>, // Unix timestamp integer
<kind>, // Integer
<tags>, // Array of arrays
<content> // String
]
```
**Common errors**:
- Using string "0" instead of integer 0
- Including `id` or `sig` fields in serialization
- Wrong field order
- Not using compact JSON (no spaces)
- Using uppercase hex
**Fix**: Serialize exactly as shown, compact JSON, SHA256 the UTF-8 bytes.
### Mistake 2: Wrong Signature Algorithm
**Problem**: Using ECDSA instead of Schnorr signatures.
**Correct**:
- Use Schnorr signatures (BIP-340)
- Curve: secp256k1
- Sign the 32-byte event ID
**Libraries**:
- JavaScript: noble-secp256k1
- Rust: secp256k1
- Go: btcsuite/btcd/btcec/v2/schnorr
- Python: secp256k1-py
### Mistake 3: Invalid created_at Timestamps
**Problem**: Events with far-future timestamps or very old timestamps.
**Best practices**:
- Use current Unix time: `Math.floor(Date.now() / 1000)`
- Relays often reject if `created_at > now + 15 minutes`
- Don't backdate events to manipulate ordering
**Fix**: Always use current time when creating events.
### Mistake 4: Malformed Tags
**Problem**: Tags that aren't arrays or have wrong structure.
**Correct format**:
```json
{
"tags": [
["e", "event-id", "relay-url", "marker"],
["p", "pubkey", "relay-url"],
["t", "hashtag"]
]
}
```
**Common errors**:
- Using objects instead of arrays: `{"e": "..."}`
- Missing inner arrays: `["e", "event-id"]` when nested in tags is wrong
- Wrong nesting depth
- Non-string values (except for specific NIPs)
### Mistake 5: Not Handling Replaceable Events
**Problem**: Showing multiple versions of replaceable events.
**Event types**:
- **Replaceable (10000-19999)**: Same author + kind → replace
- **Parameterized Replaceable (30000-39999)**: Same author + kind + d-tag → replace
**Fix**:
```javascript
// For replaceable events
const key = `${event.pubkey}:${event.kind}`
if (latestEvents[key]?.created_at < event.created_at) {
latestEvents[key] = event
}
// For parameterized replaceable events
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${event.kind}:${dTag}`
if (latestEvents[key]?.created_at < event.created_at) {
latestEvents[key] = event
}
```
## WebSocket Communication
### Mistake 6: Not Handling EOSE
**Problem**: Loading indicators never finish or show wrong state.
**Solution**:
```javascript
const receivedEvents = new Set()
let eoseReceived = false
ws.onmessage = (msg) => {
const [type, ...rest] = JSON.parse(msg.data)
if (type === 'EVENT') {
const [subId, event] = rest
receivedEvents.add(event.id)
displayEvent(event)
}
if (type === 'EOSE') {
eoseReceived = true
hideLoadingSpinner()
}
}
```
### Mistake 7: Not Closing Subscriptions
**Problem**: Memory leaks and wasted bandwidth from unclosed subscriptions.
**Fix**: Always send CLOSE when done:
```javascript
ws.send(JSON.stringify(['CLOSE', subId]))
```
**Best practices**:
- Close when component unmounts
- Close before opening new subscription with same ID
- Use unique subscription IDs
- Track active subscriptions
### Mistake 8: Ignoring OK Messages
**Problem**: Not knowing if events were accepted or rejected.
**Solution**:
```javascript
ws.onmessage = (msg) => {
const [type, eventId, accepted, message] = JSON.parse(msg.data)
if (type === 'OK') {
if (!accepted) {
console.error(`Event ${eventId} rejected: ${message}`)
handleRejection(eventId, message)
}
}
}
```
**Common rejection reasons**:
- `pow:` - Insufficient proof of work
- `blocked:` - Pubkey or content blocked
- `rate-limited:` - Too many requests
- `invalid:` - Failed validation
### Mistake 9: Sending Events Before WebSocket Ready
**Problem**: Events lost because WebSocket not connected.
**Fix**:
```javascript
const sendWhenReady = (ws, message) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message)
} else {
ws.addEventListener('open', () => ws.send(message), { once: true })
}
}
```
### Mistake 10: Not Handling WebSocket Disconnections
**Problem**: App breaks when relay goes offline.
**Solution**: Implement reconnection with exponential backoff:
```javascript
let reconnectDelay = 1000
const maxDelay = 30000
const connect = () => {
const ws = new WebSocket(relayUrl)
ws.onclose = () => {
setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, maxDelay)
connect()
}, reconnectDelay)
}
ws.onopen = () => {
reconnectDelay = 1000 // Reset on successful connection
resubscribe() // Re-establish subscriptions
}
}
```
## Filter Queries
### Mistake 11: Overly Broad Filters
**Problem**: Requesting too many events, overwhelming relay and client.
**Bad**:
```json
{
"kinds": [1],
"limit": 10000
}
```
**Good**:
```json
{
"kinds": [1],
"authors": ["<followed-users>"],
"limit": 50,
"since": 1234567890
}
```
**Best practices**:
- Always set reasonable `limit` (50-500)
- Filter by `authors` when possible
- Use `since`/`until` for time ranges
- Be specific with `kinds`
- Multiple smaller queries > one huge query
### Mistake 12: Not Using Prefix Matching
**Problem**: Full hex strings in filters unnecessarily.
**Optimization**:
```json
{
"ids": ["abc12345"], // 8 chars enough for uniqueness
"authors": ["def67890"]
}
```
Relays support prefix matching for `ids` and `authors`.
### Mistake 13: Duplicate Filter Fields
**Problem**: Redundant filter conditions.
**Bad**:
```json
{
"authors": ["pubkey1", "pubkey1"],
"kinds": [1, 1]
}
```
**Good**:
```json
{
"authors": ["pubkey1"],
"kinds": [1]
}
```
Deduplicate filter arrays.
## Threading and References
### Mistake 14: Incorrect Thread Structure
**Problem**: Missing root/reply markers or wrong tag order.
**Correct reply structure** (NIP-10):
```json
{
"kind": 1,
"tags": [
["e", "<root-event-id>", "<relay>", "root"],
["e", "<parent-event-id>", "<relay>", "reply"],
["p", "<author1-pubkey>"],
["p", "<author2-pubkey>"]
]
}
```
**Key points**:
- Root event should have "root" marker
- Direct parent should have "reply" marker
- Include `p` tags for all mentioned users
- Relay hints are optional but helpful
### Mistake 15: Missing p Tags in Replies
**Problem**: Authors not notified of replies.
**Fix**: Always add `p` tag for:
- Original author
- Authors mentioned in content
- Authors in the thread chain
```json
{
"tags": [
["e", "event-id", "", "reply"],
["p", "original-author"],
["p", "mentioned-user1"],
["p", "mentioned-user2"]
]
}
```
### Mistake 16: Not Using Markers
**Problem**: Ambiguous thread structure.
**Solution**: Always use markers in `e` tags:
- `root` - Root of thread
- `reply` - Direct parent
- `mention` - Referenced but not replied to
Without markers, clients must guess thread structure.
## Relay Management
### Mistake 17: Relying on Single Relay
**Problem**: Single point of failure, censorship vulnerability.
**Solution**: Connect to multiple relays (5-15 common):
```javascript
const relays = [
'wss://relay1.com',
'wss://relay2.com',
'wss://relay3.com'
]
const connections = relays.map(url => connect(url))
```
**Best practices**:
- Publish to 3-5 write relays
- Read from 5-10 read relays
- Use NIP-65 for user's preferred relays
- Fall back to NIP-05 relays
- Implement relay rotation on failure
### Mistake 18: Not Implementing NIP-65
**Problem**: Querying wrong relays, missing user's events.
**Correct flow**:
1. Fetch user's kind `10002` event (relay list)
2. Connect to their read relays to fetch their content
3. Connect to their write relays to send them messages
```javascript
async function getUserRelays(pubkey) {
// Fetch kind 10002
const relayList = await fetchEvent({
kinds: [10002],
authors: [pubkey]
})
const readRelays = []
const writeRelays = []
relayList.tags.forEach(([tag, url, mode]) => {
if (tag === 'r') {
if (!mode || mode === 'read') readRelays.push(url)
if (!mode || mode === 'write') writeRelays.push(url)
}
})
return { readRelays, writeRelays }
}
```
### Mistake 19: Not Respecting Relay Limitations
**Problem**: Violating relay policies, getting rate limited or banned.
**Solution**: Fetch and respect NIP-11 relay info:
```javascript
const getRelayInfo = async (relayUrl) => {
const url = relayUrl.replace('wss://', 'https://').replace('ws://', 'http://')
const response = await fetch(url, {
headers: { 'Accept': 'application/nostr+json' }
})
return response.json()
}
// Respect limitations
const info = await getRelayInfo(relayUrl)
const maxLimit = info.limitation?.max_limit || 500
const maxFilters = info.limitation?.max_filters || 10
```
## Security
### Mistake 20: Exposing Private Keys
**Problem**: Including nsec in client code, logs, or network requests.
**Never**:
- Store nsec in localStorage without encryption
- Log private keys
- Send nsec over network
- Display nsec to user unless explicitly requested
- Hard-code private keys
**Best practices**:
- Use NIP-07 (browser extension) when possible
- Encrypt keys at rest
- Use NIP-46 (remote signing) for web apps
- Warn users when showing nsec
### Mistake 21: Not Verifying Signatures
**Problem**: Accepting invalid events, vulnerability to attacks.
**Always verify**:
```javascript
const verifyEvent = (event) => {
// 1. Verify ID
const calculatedId = sha256(serializeEvent(event))
if (calculatedId !== event.id) return false
// 2. Verify signature
const signatureValid = schnorr.verify(
event.sig,
event.id,
event.pubkey
)
if (!signatureValid) return false
// 3. Check timestamp
const now = Math.floor(Date.now() / 1000)
if (event.created_at > now + 900) return false // 15 min future
return true
}
```
**Verify before**:
- Displaying to user
- Storing in database
- Using event data for logic
### Mistake 22: Using NIP-04 Encryption
**Problem**: Weak encryption, vulnerable to attacks.
**Solution**: Use NIP-44 instead:
- Modern authenticated encryption
- ChaCha20-Poly1305 AEAD
- Proper key derivation
- Version byte for upgradability
**Migration**: Update to NIP-44 for all new encrypted messages.
### Mistake 23: Not Sanitizing Content
**Problem**: XSS vulnerabilities in displayed content.
**Solution**: Sanitize before rendering:
```javascript
import DOMPurify from 'dompurify'
const safeContent = DOMPurify.sanitize(event.content, {
ALLOWED_TAGS: ['b', 'i', 'u', 'a', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'target', 'rel']
})
```
**Especially critical for**:
- Markdown rendering
- Link parsing
- Image URLs
- User-provided HTML
## User Experience
### Mistake 24: Not Caching Events
**Problem**: Re-fetching same events repeatedly, poor performance.
**Solution**: Implement event cache:
```javascript
const eventCache = new Map()
const cacheEvent = (event) => {
eventCache.set(event.id, event)
}
const getCachedEvent = (eventId) => {
return eventCache.get(eventId)
}
```
**Cache strategies**:
- LRU eviction for memory management
- IndexedDB for persistence
- Invalidate replaceable events on update
- Cache metadata (kind 0) aggressively
### Mistake 25: Not Implementing Optimistic UI
**Problem**: Slow feeling app, waiting for relay confirmation.
**Solution**: Show user's events immediately:
```javascript
const publishEvent = async (event) => {
// Immediately show to user
displayEvent(event, { pending: true })
// Publish to relays
const results = await Promise.all(
relays.map(relay => relay.publish(event))
)
// Update status based on results
const success = results.some(r => r.accepted)
displayEvent(event, { pending: false, success })
}
```
### Mistake 26: Poor Loading States
**Problem**: User doesn't know if app is working.
**Solution**: Clear loading indicators:
- Show spinner until EOSE
- Display "Loading..." placeholder
- Show how many relays responded
- Indicate connection status per relay
### Mistake 27: Not Handling Large Threads
**Problem**: Loading entire thread at once, performance issues.
**Solution**: Implement pagination:
```javascript
const loadThread = async (eventId, cursor = null) => {
const filter = {
"#e": [eventId],
kinds: [1],
limit: 20,
until: cursor
}
const replies = await fetchEvents(filter)
return { replies, nextCursor: replies[replies.length - 1]?.created_at }
}
```
## Testing
### Mistake 28: Not Testing with Multiple Relays
**Problem**: App works with one relay but fails with others.
**Solution**: Test with:
- Fast relays
- Slow relays
- Unreliable relays
- Paid relays (auth required)
- Relays with different NIP support
### Mistake 29: Not Testing Edge Cases
**Critical tests**:
- Empty filter results
- WebSocket disconnections
- Malformed events
- Very long content
- Invalid signatures
- Relay errors
- Rate limiting
- Concurrent operations
### Mistake 30: Not Monitoring Performance
**Metrics to track**:
- Event verification time
- WebSocket latency per relay
- Events per second processed
- Memory usage (event cache)
- Subscription count
- Failed publishes
## Best Practices Checklist
**Event Creation**:
- [ ] Correct serialization for ID
- [ ] Schnorr signatures
- [ ] Current timestamp
- [ ] Valid tag structure
- [ ] Handle replaceable events
**WebSocket**:
- [ ] Handle EOSE
- [ ] Close subscriptions
- [ ] Process OK messages
- [ ] Check WebSocket state
- [ ] Reconnection logic
**Filters**:
- [ ] Set reasonable limits
- [ ] Specific queries
- [ ] Deduplicate arrays
- [ ] Use prefix matching
**Threading**:
- [ ] Use root/reply markers
- [ ] Include all p tags
- [ ] Proper thread structure
**Relays**:
- [ ] Multiple relays
- [ ] Implement NIP-65
- [ ] Respect limitations
- [ ] Handle failures
**Security**:
- [ ] Never expose nsec
- [ ] Verify all signatures
- [ ] Use NIP-44 encryption
- [ ] Sanitize content
**UX**:
- [ ] Cache events
- [ ] Optimistic UI
- [ ] Loading states
- [ ] Pagination
**Testing**:
- [ ] Multiple relays
- [ ] Edge cases
- [ ] Monitor performance
## Resources
- **nostr-tools**: JavaScript library with best practices
- **rust-nostr**: Rust implementation with strong typing
- **NIPs Repository**: Official specifications
- **Nostr Dev**: Community resources and help

View File

@@ -0,0 +1,361 @@
# Nostr Event Kinds - Complete Reference
This document provides a comprehensive list of all standard and commonly-used Nostr event kinds.
## Standard Event Kinds
### Core Events (0-999)
#### Metadata and Profile
- **0**: `Metadata` - User profile information (name, about, picture, etc.)
- Replaceable
- Content: JSON with profile fields
#### Text Content
- **1**: `Text Note` - Short-form post (like a tweet)
- Regular event (not replaceable)
- Most common event type
#### Relay Recommendations
- **2**: `Recommend Relay` - Deprecated, use NIP-65 instead
#### Contact Lists
- **3**: `Contacts` - Following list with optional relay hints
- Replaceable
- Tags: `p` tags for each followed user
#### Encrypted Messages
- **4**: `Encrypted Direct Message` - Private message (NIP-04, deprecated)
- Regular event
- Use NIP-44 instead for better security
#### Content Management
- **5**: `Event Deletion` - Request to delete events
- Tags: `e` tags for events to delete
- Only works for own events
#### Sharing
- **6**: `Repost` - Share another event
- Tags: `e` for reposted event, `p` for original author
- May include original event in content
#### Reactions
- **7**: `Reaction` - Like, emoji reaction to event
- Content: "+" or emoji
- Tags: `e` for reacted event, `p` for author
### Channel Events (40-49)
- **40**: `Channel Creation` - Create a public chat channel
- **41**: `Channel Metadata` - Set channel name, about, picture
- **42**: `Channel Message` - Post message in channel
- **43**: `Channel Hide Message` - Hide a message in channel
- **44**: `Channel Mute User` - Mute a user in channel
### Regular Events (1000-9999)
Regular events are never deleted or replaced. All versions are kept.
- **1000**: `Example regular event`
- **1063**: `File Metadata` (NIP-94) - Metadata for shared files
- Tags: url, MIME type, hash, size, dimensions
### Replaceable Events (10000-19999)
Only the latest event of each kind is kept per pubkey.
- **10000**: `Mute List` - List of muted users/content
- **10001**: `Pin List` - Pinned events
- **10002**: `Relay List Metadata` (NIP-65) - User's preferred relays
- Critical for routing
- Tags: `r` with relay URLs and read/write markers
### Ephemeral Events (20000-29999)
Not stored by relays, only forwarded once.
- **20000**: `Example ephemeral event`
- **21000**: `Typing Indicator` - User is typing
- **22242**: `Client Authentication` (NIP-42) - Auth response to relay
### Parameterized Replaceable Events (30000-39999)
Replaced based on `d` tag value.
#### Lists (30000-30009)
- **30000**: `Categorized People List` - Custom people lists
- `d` tag: list identifier
- `p` tags: people in list
- **30001**: `Categorized Bookmark List` - Bookmark collections
- `d` tag: list identifier
- `e` or `a` tags: bookmarked items
- **30008**: `Badge Definition` (NIP-58) - Define a badge/achievement
- `d` tag: badge ID
- Tags: name, description, image
- **30009**: `Profile Badges` (NIP-58) - Badges displayed on profile
- `d` tag: badge ID
- `e` or `a` tags: badge awards
#### Long-form Content (30023)
- **30023**: `Long-form Article` (NIP-23) - Blog post, article
- `d` tag: article identifier (slug)
- Tags: title, summary, published_at, image
- Content: Markdown
#### Application Data (30078)
- **30078**: `Application-specific Data` (NIP-78)
- `d` tag: app-name:data-key
- Content: app-specific data (may be encrypted)
#### Other Parameterized Replaceables
- **31989**: `Application Handler Information` (NIP-89)
- Declares app can handle certain event kinds
- **31990**: `Handler Recommendation` (NIP-89)
- User's preferred apps for event kinds
## Special Event Kinds
### Authentication & Signing
- **22242**: `Client Authentication` - Prove key ownership to relay
- **24133**: `Nostr Connect` - Remote signer protocol (NIP-46)
### Lightning & Payments
- **9734**: `Zap Request` (NIP-57) - Request Lightning payment
- Not published to regular relays
- Sent to LNURL provider
- **9735**: `Zap Receipt` (NIP-57) - Proof of Lightning payment
- Published by LNURL provider
- Proves zap was paid
- **23194**: `Wallet Request` (NIP-47) - Request wallet operation
- **23195**: `Wallet Response` (NIP-47) - Response to wallet request
### Content & Annotations
- **1984**: `Reporting` (NIP-56) - Report content/users
- Tags: reason (spam, illegal, etc.)
- **9802**: `Highlights` (NIP-84) - Highlight text
- Content: highlighted text
- Tags: context, source event
### Badges & Reputation
- **8**: `Badge Award` (NIP-58) - Award a badge to someone
- Tags: `a` for badge definition, `p` for recipient
### Generic Events
- **16**: `Generic Repost` (NIP-18) - Repost any event kind
- More flexible than kind 6
- **27235**: `HTTP Auth` (NIP-98) - Authenticate HTTP requests
- Tags: URL, method
## Event Kind Ranges Summary
| Range | Type | Behavior | Examples |
|-------|------|----------|----------|
| 0-999 | Core | Varies | Metadata, notes, reactions |
| 1000-9999 | Regular | Immutable, all kept | File metadata |
| 10000-19999 | Replaceable | Only latest kept | Mute list, relay list |
| 20000-29999 | Ephemeral | Not stored | Typing, presence |
| 30000-39999 | Parameterized Replaceable | Replaced by `d` tag | Articles, lists, badges |
## Event Lifecycle
### Regular Events (1000-9999)
```
Event A published → Stored
Event A' published → Both A and A' stored
```
### Replaceable Events (10000-19999)
```
Event A published → Stored
Event A' published (same kind, same pubkey) → A deleted, A' stored
```
### Parameterized Replaceable Events (30000-39999)
```
Event A (d="foo") published → Stored
Event B (d="bar") published → Both stored (different d)
Event A' (d="foo") published → A deleted, A' stored (same d)
```
### Ephemeral Events (20000-29999)
```
Event A published → Forwarded to subscribers, NOT stored
```
## Common Patterns
### Metadata (Kind 0)
```json
{
"kind": 0,
"content": "{\"name\":\"Alice\",\"about\":\"Nostr user\",\"picture\":\"https://...\",\"nip05\":\"alice@example.com\"}",
"tags": []
}
```
### Text Note (Kind 1)
```json
{
"kind": 1,
"content": "Hello Nostr!",
"tags": [
["t", "nostr"],
["t", "hello"]
]
}
```
### Reply (Kind 1 with thread tags)
```json
{
"kind": 1,
"content": "Great post!",
"tags": [
["e", "<root-event-id>", "<relay>", "root"],
["e", "<parent-event-id>", "<relay>", "reply"],
["p", "<author-pubkey>"]
]
}
```
### Reaction (Kind 7)
```json
{
"kind": 7,
"content": "+",
"tags": [
["e", "<reacted-event-id>"],
["p", "<event-author-pubkey>"],
["k", "1"]
]
}
```
### Long-form Article (Kind 30023)
```json
{
"kind": 30023,
"content": "# My Article\n\nContent here...",
"tags": [
["d", "my-article-slug"],
["title", "My Article"],
["summary", "This is about..."],
["published_at", "1234567890"],
["t", "nostr"],
["image", "https://..."]
]
}
```
### Relay List (Kind 10002)
```json
{
"kind": 10002,
"content": "",
"tags": [
["r", "wss://relay1.com"],
["r", "wss://relay2.com", "write"],
["r", "wss://relay3.com", "read"]
]
}
```
### Zap Request (Kind 9734)
```json
{
"kind": 9734,
"content": "",
"tags": [
["relays", "wss://relay1.com", "wss://relay2.com"],
["amount", "21000"],
["lnurl", "lnurl..."],
["p", "<recipient-pubkey>"],
["e", "<event-id>"]
]
}
```
### File Metadata (Kind 1063)
```json
{
"kind": 1063,
"content": "My photo from the trip",
"tags": [
["url", "https://cdn.example.com/image.jpg"],
["m", "image/jpeg"],
["x", "abc123..."],
["size", "524288"],
["dim", "1920x1080"],
["blurhash", "LEHV6n..."]
]
}
```
### Report (Kind 1984)
```json
{
"kind": 1984,
"content": "This is spam",
"tags": [
["e", "<reported-event-id>", "<relay>"],
["p", "<reported-pubkey>"],
["report", "spam"]
]
}
```
## Future Event Kinds
The event kind space is open-ended. New NIPs may define new event kinds.
**Guidelines for new event kinds**:
1. Use appropriate range for desired behavior
2. Document in a NIP
3. Implement in at least 2 clients and 1 relay
4. Ensure backwards compatibility
5. Don't overlap with existing kinds
**Custom event kinds**:
- Applications can use undefined event kinds
- Document behavior for interoperability
- Consider proposing as a NIP if useful broadly
## Event Kind Selection Guide
**Choose based on lifecycle needs**:
- **Regular (1000-9999)**: When you need history
- User posts, comments, reactions
- Payment records, receipts
- Immutable records
- **Replaceable (10000-19999)**: When you need latest state
- User settings, preferences
- Mute/block lists
- Current status
- **Ephemeral (20000-29999)**: When you need real-time only
- Typing indicators
- Online presence
- Temporary notifications
- **Parameterized Replaceable (30000-39999)**: When you need multiple latest states
- Articles (one per slug)
- Product listings (one per product ID)
- Configuration sets (one per setting name)
## References
- NIPs Repository: https://github.com/nostr-protocol/nips
- NIP-16: Event Treatment
- NIP-01: Event structure
- Various feature NIPs for specific kinds

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
# React 19 Skill
A comprehensive Claude skill for working with React 19, including hooks, components, server components, and modern React architecture.
## Contents
### Main Skill File
- **SKILL.md** - Main skill document with React 19 fundamentals, hooks, components, and best practices
### References
- **hooks-quick-reference.md** - Quick reference for all React hooks with examples
- **server-components.md** - Complete guide to React Server Components and Server Functions
- **performance.md** - Performance optimization strategies and techniques
### Examples
- **practical-patterns.tsx** - Real-world React patterns and solutions
## What This Skill Covers
### Core Topics
- React 19 features and improvements
- All built-in hooks (useState, useEffect, useTransition, useOptimistic, etc.)
- Component patterns and composition
- Server Components and Server Functions
- React Compiler and automatic optimization
- Performance optimization techniques
- Form handling and validation
- Error boundaries and error handling
- Context and global state management
- Code splitting and lazy loading
### Best Practices
- Component design principles
- State management strategies
- Performance optimization
- Error handling patterns
- TypeScript integration
- Testing considerations
- Accessibility guidelines
## When to Use This Skill
Use this skill when:
- Building React 19 applications
- Working with React hooks
- Implementing server components
- Optimizing React performance
- Troubleshooting React-specific issues
- Understanding concurrent features
- Working with forms and user input
- Implementing complex UI patterns
## Quick Start Examples
### Basic Component
```typescript
interface ButtonProps {
label: string
onClick: () => void
}
const Button = ({ label, onClick }: ButtonProps) => {
return <button onClick={onClick}>{label}</button>
}
```
### Using Hooks
```typescript
const Counter = () => {
const [count, setCount] = useState(0)
useEffect(() => {
console.log(`Count is: ${count}`)
}, [count])
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
)
}
```
### Server Component
```typescript
const Page = async () => {
const data = await fetchData()
return <div>{data}</div>
}
```
### Server Function
```typescript
'use server'
export async function createUser(formData: FormData) {
const name = formData.get('name')
return await db.user.create({ data: { name } })
}
```
## Related Skills
- **typescript** - TypeScript patterns for React
- **ndk** - Nostr integration with React
- **skill-creator** - Creating reusable component libraries
## Resources
- [React Documentation](https://react.dev)
- [React API Reference](https://react.dev/reference/react)
- [React Hooks Reference](https://react.dev/reference/react/hooks)
- [React Server Components](https://react.dev/reference/rsc)
- [React Compiler](https://react.dev/reference/react-compiler)
## Version
This skill is based on React 19.2 and includes the latest features and APIs.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,878 @@
# React Practical Examples
This file contains real-world examples of React patterns and solutions.
## Example 1: Custom Hook for Data Fetching
```typescript
import { useState, useEffect } from 'react'
interface FetchState<T> {
data: T | null
loading: boolean
error: Error | null
}
const useFetch = <T,>(url: string) => {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null
})
useEffect(() => {
let cancelled = false
const controller = new AbortController()
const fetchData = async () => {
try {
setState(prev => ({ ...prev, loading: true, error: null }))
const response = await fetch(url, {
signal: controller.signal
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
if (!cancelled) {
setState({ data, loading: false, error: null })
}
} catch (error) {
if (!cancelled && error.name !== 'AbortError') {
setState({
data: null,
loading: false,
error: error as Error
})
}
}
}
fetchData()
return () => {
cancelled = true
controller.abort()
}
}, [url])
return state
}
// Usage
const UserProfile = ({ userId }: { userId: string }) => {
const { data, loading, error } = useFetch<User>(`/api/users/${userId}`)
if (loading) return <Spinner />
if (error) return <ErrorMessage error={error} />
if (!data) return null
return <UserCard user={data} />
}
```
## Example 2: Form with Validation
```typescript
import { useState, useCallback } from 'react'
import { z } from 'zod'
const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be 18 or older')
})
type UserForm = z.infer<typeof userSchema>
type FormErrors = Partial<Record<keyof UserForm, string>>
const UserForm = () => {
const [formData, setFormData] = useState<UserForm>({
name: '',
email: '',
age: 0
})
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleChange = useCallback((
field: keyof UserForm,
value: string | number
) => {
setFormData(prev => ({ ...prev, [field]: value }))
// Clear error when user starts typing
setErrors(prev => ({ ...prev, [field]: undefined }))
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validate
const result = userSchema.safeParse(formData)
if (!result.success) {
const fieldErrors: FormErrors = {}
result.error.errors.forEach(err => {
const field = err.path[0] as keyof UserForm
fieldErrors[field] = err.message
})
setErrors(fieldErrors)
return
}
// Submit
setIsSubmitting(true)
try {
await submitUser(result.data)
// Success handling
} catch (error) {
console.error(error)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
value={formData.name}
onChange={e => handleChange('name', e.target.value)}
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={formData.email}
onChange={e => handleChange('email', e.target.value)}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label htmlFor="age">Age</label>
<input
id="age"
type="number"
value={formData.age || ''}
onChange={e => handleChange('age', Number(e.target.value))}
/>
{errors.age && <span className="error">{errors.age}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
```
## Example 3: Modal with Portal
```typescript
import { createPortal } from 'react-dom'
import { useEffect, useRef, useState } from 'react'
interface ModalProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
title?: string
}
const Modal = ({ isOpen, onClose, children, title }: ModalProps) => {
const modalRef = useRef<HTMLDivElement>(null)
// Close on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
// Prevent body scroll
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
// Close on backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === modalRef.current) {
onClose()
}
}
if (!isOpen) return null
return createPortal(
<div
ref={modalRef}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex justify-between items-center mb-4">
{title && <h2 className="text-xl font-bold">{title}</h2>}
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
aria-label="Close modal"
>
</button>
</div>
{children}
</div>
</div>,
document.body
)
}
// Usage
const App = () => {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="My Modal">
<p>Modal content goes here</p>
<button onClick={() => setIsOpen(false)}>Close</button>
</Modal>
</>
)
}
```
## Example 4: Infinite Scroll
```typescript
import { useState, useEffect, useRef, useCallback } from 'react'
interface InfiniteScrollProps<T> {
fetchData: (page: number) => Promise<T[]>
renderItem: (item: T, index: number) => React.ReactNode
loader?: React.ReactNode
endMessage?: React.ReactNode
}
const InfiniteScroll = <T extends { id: string | number },>({
fetchData,
renderItem,
loader = <div>Loading...</div>,
endMessage = <div>No more items</div>
}: InfiniteScrollProps<T>) => {
const [items, setItems] = useState<T[]>([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const observerRef = useRef<IntersectionObserver | null>(null)
const loadMoreRef = useRef<HTMLDivElement>(null)
const loadMore = useCallback(async () => {
if (loading || !hasMore) return
setLoading(true)
try {
const newItems = await fetchData(page)
if (newItems.length === 0) {
setHasMore(false)
} else {
setItems(prev => [...prev, ...newItems])
setPage(prev => prev + 1)
}
} catch (error) {
console.error('Failed to load items:', error)
} finally {
setLoading(false)
}
}, [page, loading, hasMore, fetchData])
// Set up intersection observer
useEffect(() => {
observerRef.current = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
loadMore()
}
},
{ threshold: 0.1 }
)
const currentRef = loadMoreRef.current
if (currentRef) {
observerRef.current.observe(currentRef)
}
return () => {
if (observerRef.current && currentRef) {
observerRef.current.unobserve(currentRef)
}
}
}, [loadMore])
// Initial load
useEffect(() => {
loadMore()
}, [])
return (
<div>
{items.map((item, index) => (
<div key={item.id}>
{renderItem(item, index)}
</div>
))}
<div ref={loadMoreRef}>
{loading && loader}
{!loading && !hasMore && endMessage}
</div>
</div>
)
}
// Usage
const PostsList = () => {
const fetchPosts = async (page: number) => {
const response = await fetch(`/api/posts?page=${page}`)
return response.json()
}
return (
<InfiniteScroll<Post>
fetchData={fetchPosts}
renderItem={(post) => <PostCard post={post} />}
/>
)
}
```
## Example 5: Dark Mode Toggle
```typescript
import { createContext, useContext, useState, useEffect } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | null>(null)
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<Theme>(() => {
// Check localStorage and system preference
const saved = localStorage.getItem('theme') as Theme | null
if (saved) return saved
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
})
useEffect(() => {
// Update DOM and localStorage
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
localStorage.setItem('theme', theme)
}, [theme])
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
// Usage
const ThemeToggle = () => {
const { theme, toggleTheme } = useTheme()
return (
<button onClick={toggleTheme} aria-label="Toggle theme">
{theme === 'light' ? '🌙' : '☀️'}
</button>
)
}
```
## Example 6: Debounced Search
```typescript
import { useState, useEffect, useMemo } from 'react'
const useDebounce = <T,>(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}
const SearchPage = () => {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Product[]>([])
const [loading, setLoading] = useState(false)
const debouncedQuery = useDebounce(query, 500)
useEffect(() => {
if (!debouncedQuery) {
setResults([])
return
}
const searchProducts = async () => {
setLoading(true)
try {
const response = await fetch(`/api/search?q=${debouncedQuery}`)
const data = await response.json()
setResults(data)
} catch (error) {
console.error('Search failed:', error)
} finally {
setLoading(false)
}
}
searchProducts()
}, [debouncedQuery])
return (
<div>
<input
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search products..."
/>
{loading && <Spinner />}
{!loading && results.length > 0 && (
<div>
{results.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
{!loading && query && results.length === 0 && (
<p>No results found for "{query}"</p>
)}
</div>
)
}
```
## Example 7: Tabs Component
```typescript
import { createContext, useContext, useState, useId } from 'react'
interface TabsContextType {
activeTab: string
setActiveTab: (id: string) => void
tabsId: string
}
const TabsContext = createContext<TabsContextType | null>(null)
const useTabs = () => {
const context = useContext(TabsContext)
if (!context) throw new Error('Tabs compound components must be used within Tabs')
return context
}
interface TabsProps {
children: React.ReactNode
defaultValue: string
className?: string
}
const Tabs = ({ children, defaultValue, className }: TabsProps) => {
const [activeTab, setActiveTab] = useState(defaultValue)
const tabsId = useId()
return (
<TabsContext.Provider value={{ activeTab, setActiveTab, tabsId }}>
<div className={className}>
{children}
</div>
</TabsContext.Provider>
)
}
const TabsList = ({ children, className }: {
children: React.ReactNode
className?: string
}) => (
<div role="tablist" className={className}>
{children}
</div>
)
interface TabsTriggerProps {
value: string
children: React.ReactNode
className?: string
}
const TabsTrigger = ({ value, children, className }: TabsTriggerProps) => {
const { activeTab, setActiveTab, tabsId } = useTabs()
const isActive = activeTab === value
return (
<button
role="tab"
id={`${tabsId}-tab-${value}`}
aria-controls={`${tabsId}-panel-${value}`}
aria-selected={isActive}
onClick={() => setActiveTab(value)}
className={`${className} ${isActive ? 'active' : ''}`}
>
{children}
</button>
)
}
interface TabsContentProps {
value: string
children: React.ReactNode
className?: string
}
const TabsContent = ({ value, children, className }: TabsContentProps) => {
const { activeTab, tabsId } = useTabs()
if (activeTab !== value) return null
return (
<div
role="tabpanel"
id={`${tabsId}-panel-${value}`}
aria-labelledby={`${tabsId}-tab-${value}`}
className={className}
>
{children}
</div>
)
}
// Export compound component
export { Tabs, TabsList, TabsTrigger, TabsContent }
// Usage
const App = () => (
<Tabs defaultValue="profile">
<TabsList>
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<h2>Profile Content</h2>
</TabsContent>
<TabsContent value="settings">
<h2>Settings Content</h2>
</TabsContent>
<TabsContent value="notifications">
<h2>Notifications Content</h2>
</TabsContent>
</Tabs>
)
```
## Example 8: Error Boundary
```typescript
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: (error: Error, reset: () => void) => ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
}
interface State {
hasError: boolean
error: Error | null
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
this.props.onError?.(error, errorInfo)
}
reset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.reset)
}
return (
<div className="error-boundary">
<h2>Something went wrong</h2>
<details>
<summary>Error details</summary>
<pre>{this.state.error.message}</pre>
</details>
<button onClick={this.reset}>Try again</button>
</div>
)
}
return this.props.children
}
}
// Usage
const App = () => (
<ErrorBoundary
fallback={(error, reset) => (
<div>
<h1>Oops! Something went wrong</h1>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
)}
onError={(error, errorInfo) => {
// Send to error tracking service
console.error('Error logged:', error, errorInfo)
}}
>
<YourApp />
</ErrorBoundary>
)
```
## Example 9: Custom Hook for Local Storage
```typescript
import { useState, useEffect, useCallback } from 'react'
const useLocalStorage = <T,>(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void, () => void] => {
// Get initial value from localStorage
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(`Error loading ${key} from localStorage:`, error)
return initialValue
}
})
// Update localStorage when value changes
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
// Dispatch storage event for other tabs
window.dispatchEvent(new Event('storage'))
} catch (error) {
console.error(`Error saving ${key} to localStorage:`, error)
}
}, [key, storedValue])
// Remove from localStorage
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key)
setStoredValue(initialValue)
} catch (error) {
console.error(`Error removing ${key} from localStorage:`, error)
}
}, [key, initialValue])
// Listen for changes in other tabs
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue))
}
}
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [key])
return [storedValue, setValue, removeValue]
}
// Usage
const UserPreferences = () => {
const [preferences, setPreferences, clearPreferences] = useLocalStorage('user-prefs', {
theme: 'light',
language: 'en',
notifications: true
})
return (
<div>
<label>
<input
type="checkbox"
checked={preferences.notifications}
onChange={e => setPreferences({
...preferences,
notifications: e.target.checked
})}
/>
Enable notifications
</label>
<button onClick={clearPreferences}>
Reset to defaults
</button>
</div>
)
}
```
## Example 10: Optimistic Updates with useOptimistic
```typescript
'use client'
import { useOptimistic } from 'react'
import { likePost, unlikePost } from './actions'
interface Post {
id: string
content: string
likes: number
isLiked: boolean
}
const PostCard = ({ post }: { post: Post }) => {
const [optimisticPost, addOptimistic] = useOptimistic(
post,
(currentPost, update: Partial<Post>) => ({
...currentPost,
...update
})
)
const handleLike = async () => {
// Optimistically update UI
addOptimistic({
likes: optimisticPost.likes + 1,
isLiked: true
})
try {
// Send server request
await likePost(post.id)
} catch (error) {
// Server will send correct state via revalidation
console.error('Failed to like post:', error)
}
}
const handleUnlike = async () => {
addOptimistic({
likes: optimisticPost.likes - 1,
isLiked: false
})
try {
await unlikePost(post.id)
} catch (error) {
console.error('Failed to unlike post:', error)
}
}
return (
<div className="post-card">
<p>{optimisticPost.content}</p>
<button
onClick={optimisticPost.isLiked ? handleUnlike : handleLike}
className={optimisticPost.isLiked ? 'liked' : ''}
>
❤️ {optimisticPost.likes}
</button>
</div>
)
}
```
## References
These examples demonstrate:
- Custom hooks for reusable logic
- Form handling with validation
- Portal usage for modals
- Infinite scroll with Intersection Observer
- Context for global state
- Debouncing for performance
- Compound components pattern
- Error boundaries
- LocalStorage integration
- Optimistic updates (React 19)

View File

@@ -0,0 +1,291 @@
# React Hooks Quick Reference
## State Hooks
### useState
```typescript
const [state, setState] = useState<Type>(initialValue)
const [count, setCount] = useState(0)
// Functional update
setCount(prev => prev + 1)
// Lazy initialization
const [state, setState] = useState(() => expensiveComputation())
```
### useReducer
```typescript
type State = { count: number }
type Action = { type: 'increment' } | { type: 'decrement' }
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'increment': return { count: state.count + 1 }
case 'decrement': return { count: state.count - 1 }
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 })
dispatch({ type: 'increment' })
```
### useActionState (React 19)
```typescript
const [state, formAction, isPending] = useActionState(
async (previousState, formData: FormData) => {
// Server action
return await processForm(formData)
},
initialState
)
<form action={formAction}>
<button disabled={isPending}>Submit</button>
</form>
```
## Effect Hooks
### useEffect
```typescript
useEffect(() => {
// Side effect
const subscription = api.subscribe()
// Cleanup
return () => subscription.unsubscribe()
}, [dependencies])
```
**Timing**: After render & paint
**Use for**: Data fetching, subscriptions, DOM mutations
### useLayoutEffect
```typescript
useLayoutEffect(() => {
// Runs before paint
const height = ref.current.offsetHeight
setHeight(height)
}, [])
```
**Timing**: After render, before paint
**Use for**: DOM measurements, preventing flicker
### useInsertionEffect
```typescript
useInsertionEffect(() => {
// Insert styles before any DOM reads
const style = document.createElement('style')
style.textContent = css
document.head.appendChild(style)
return () => document.head.removeChild(style)
}, [css])
```
**Timing**: Before any DOM mutations
**Use for**: CSS-in-JS libraries
## Performance Hooks
### useMemo
```typescript
const memoizedValue = useMemo(() => {
return expensiveComputation(a, b)
}, [a, b])
```
**Use for**: Expensive calculations, stable object references
### useCallback
```typescript
const memoizedCallback = useCallback(() => {
doSomething(a, b)
}, [a, b])
```
**Use for**: Passing callbacks to optimized components
## Ref Hooks
### useRef
```typescript
// DOM reference
const ref = useRef<HTMLDivElement>(null)
ref.current?.focus()
// Mutable value (doesn't trigger re-render)
const countRef = useRef(0)
countRef.current += 1
```
### useImperativeHandle
```typescript
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => inputRef.current && (inputRef.current.value = '')
}), [])
```
## Context Hook
### useContext
```typescript
const value = useContext(MyContext)
```
Must be used within a Provider.
## Transition Hooks
### useTransition
```typescript
const [isPending, startTransition] = useTransition()
startTransition(() => {
setState(newValue) // Non-urgent update
})
```
### useDeferredValue
```typescript
const [input, setInput] = useState('')
const deferredInput = useDeferredValue(input)
// Use deferredInput for expensive operations
const results = useMemo(() => search(deferredInput), [deferredInput])
```
## Optimistic Updates (React 19)
### useOptimistic
```typescript
const [optimisticState, addOptimistic] = useOptimistic(
actualState,
(currentState, optimisticValue) => {
return [...currentState, optimisticValue]
}
)
```
## Other Hooks
### useId
```typescript
const id = useId()
<label htmlFor={id}>Name</label>
<input id={id} />
```
### useSyncExternalStore
```typescript
const state = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
)
```
### useDebugValue
```typescript
useDebugValue(isOnline ? 'Online' : 'Offline')
```
### use (React 19)
```typescript
// Read context or promise
const value = use(MyContext)
const data = use(fetchPromise) // Must be in Suspense
```
## Form Hooks (React DOM)
### useFormStatus
```typescript
import { useFormStatus } from 'react-dom'
const { pending, data, method, action } = useFormStatus()
```
## Hook Rules
1. **Only call at top level** - Not in loops, conditions, or nested functions
2. **Only call from React functions** - Components or custom hooks
3. **Custom hooks start with "use"** - Naming convention
4. **Same hooks in same order** - Every render must call same hooks
## Dependencies Best Practices
1. **Include all used values** - Variables, props, state from component scope
2. **Use ESLint plugin** - `eslint-plugin-react-hooks` enforces rules
3. **Functions as dependencies** - Wrap with useCallback or define outside component
4. **Object/array dependencies** - Use useMemo for stable references
## Common Patterns
### Fetching Data
```typescript
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
return () => controller.abort()
}, [])
```
### Debouncing
```typescript
const [value, setValue] = useState('')
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, 500)
return () => clearTimeout(timer)
}, [value])
```
### Previous Value
```typescript
const usePrevious = <T,>(value: T): T | undefined => {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
})
return ref.current
}
```
### Interval
```typescript
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])
```
### Event Listeners
```typescript
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
```

View File

@@ -0,0 +1,658 @@
# React Performance Optimization Guide
## Overview
This guide covers performance optimization strategies for React 19 applications.
## Measurement & Profiling
### React DevTools Profiler
Record performance data:
1. Open React DevTools
2. Go to Profiler tab
3. Click record button
4. Interact with app
5. Stop recording
6. Analyze flame graph and ranked chart
### Profiler Component
```typescript
import { Profiler } from 'react'
const App = () => {
const onRender = (
id: string,
phase: 'mount' | 'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) => {
console.log({
component: id,
phase,
actualDuration, // Time spent rendering this update
baseDuration // Estimated time without memoization
})
}
return (
<Profiler id="App" onRender={onRender}>
<YourApp />
</Profiler>
)
}
```
### Performance Metrics
```typescript
// Custom performance tracking
const startTime = performance.now()
// ... do work
const endTime = performance.now()
console.log(`Operation took ${endTime - startTime}ms`)
// React rendering metrics
import { unstable_trace as trace } from 'react'
trace('expensive-operation', async () => {
await performExpensiveOperation()
})
```
## Memoization Strategies
### React.memo
Prevent unnecessary re-renders:
```typescript
// Basic memoization
const ExpensiveComponent = memo(({ data }: Props) => {
return <div>{processData(data)}</div>
})
// Custom comparison
const MemoizedComponent = memo(
({ user }: Props) => <UserCard user={user} />,
(prevProps, nextProps) => {
// Return true if props are equal (skip render)
return prevProps.user.id === nextProps.user.id
}
)
```
**When to use:**
- Component renders often with same props
- Rendering is expensive
- Component receives complex prop objects
**When NOT to use:**
- Props change frequently
- Component is already fast
- Premature optimization
### useMemo
Memoize computed values:
```typescript
const SortedList = ({ items, filter }: Props) => {
// Without memoization - runs every render
const filteredItems = items.filter(item => item.type === filter)
const sortedItems = filteredItems.sort((a, b) => a.name.localeCompare(b.name))
// With memoization - only runs when dependencies change
const sortedFilteredItems = useMemo(() => {
const filtered = items.filter(item => item.type === filter)
return filtered.sort((a, b) => a.name.localeCompare(b.name))
}, [items, filter])
return (
<ul>
{sortedFilteredItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
}
```
**When to use:**
- Expensive calculations (sorting, filtering large arrays)
- Creating stable object references
- Computed values used as dependencies
### useCallback
Memoize callback functions:
```typescript
const Parent = () => {
const [count, setCount] = useState(0)
// Without useCallback - new function every render
const handleClick = () => {
setCount(c => c + 1)
}
// With useCallback - stable function reference
const handleClickMemo = useCallback(() => {
setCount(c => c + 1)
}, [])
return <MemoizedChild onClick={handleClickMemo} />
}
const MemoizedChild = memo(({ onClick }: Props) => {
return <button onClick={onClick}>Click</button>
})
```
**When to use:**
- Passing callbacks to memoized components
- Callback is used in dependency array
- Callback is expensive to create
## React Compiler (Automatic Optimization)
### Enable React Compiler
React 19 can automatically optimize without manual memoization:
```javascript
// babel.config.js
module.exports = {
plugins: [
['react-compiler', {
compilationMode: 'all', // Optimize all components
}]
]
}
```
### Compilation Modes
```javascript
{
compilationMode: 'annotation', // Only components with "use memo"
compilationMode: 'all', // All components (recommended)
compilationMode: 'infer' // Based on component complexity
}
```
### Directives
```typescript
// Force memoization
'use memo'
const Component = ({ data }: Props) => {
return <div>{data}</div>
}
// Prevent memoization
'use no memo'
const SimpleComponent = ({ text }: Props) => {
return <span>{text}</span>
}
```
## State Management Optimization
### State Colocation
Keep state as close as possible to where it's used:
```typescript
// Bad - state too high
const App = () => {
const [showModal, setShowModal] = useState(false)
return (
<>
<Header />
<Content />
<Modal show={showModal} onClose={() => setShowModal(false)} />
</>
)
}
// Good - state colocated
const App = () => {
return (
<>
<Header />
<Content />
<ModalContainer />
</>
)
}
const ModalContainer = () => {
const [showModal, setShowModal] = useState(false)
return <Modal show={showModal} onClose={() => setShowModal(false)} />
}
```
### Split Context
Avoid unnecessary re-renders by splitting context:
```typescript
// Bad - single context causes all consumers to re-render
const AppContext = createContext({ user, theme, settings })
// Good - split into separate contexts
const UserContext = createContext(user)
const ThemeContext = createContext(theme)
const SettingsContext = createContext(settings)
```
### Context with useMemo
```typescript
const ThemeProvider = ({ children }: Props) => {
const [theme, setTheme] = useState('light')
// Memoize context value to prevent unnecessary re-renders
const value = useMemo(() => ({
theme,
setTheme
}), [theme])
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
```
## Code Splitting & Lazy Loading
### React.lazy
Split components into separate bundles:
```typescript
import { lazy, Suspense } from 'react'
// Lazy load components
const Dashboard = lazy(() => import('./Dashboard'))
const Settings = lazy(() => import('./Settings'))
const Profile = lazy(() => import('./Profile'))
const App = () => {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
)
}
```
### Route-based Splitting
```typescript
// App.tsx
const routes = [
{ path: '/', component: lazy(() => import('./pages/Home')) },
{ path: '/about', component: lazy(() => import('./pages/About')) },
{ path: '/products', component: lazy(() => import('./pages/Products')) },
]
const App = () => (
<Suspense fallback={<PageLoader />}>
<Routes>
{routes.map(({ path, component: Component }) => (
<Route key={path} path={path} element={<Component />} />
))}
</Routes>
</Suspense>
)
```
### Component-based Splitting
```typescript
// Split expensive components
const HeavyChart = lazy(() => import('./HeavyChart'))
const Dashboard = () => {
const [showChart, setShowChart] = useState(false)
return (
<>
<button onClick={() => setShowChart(true)}>
Load Chart
</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)}
</>
)
}
```
## List Rendering Optimization
### Keys
Always use stable, unique keys:
```typescript
// Bad - index as key (causes issues on reorder/insert)
{items.map((item, index) => (
<Item key={index} data={item} />
))}
// Good - unique ID as key
{items.map(item => (
<Item key={item.id} data={item} />
))}
// For static lists without IDs
{items.map(item => (
<Item key={`${item.name}-${item.category}`} data={item} />
))}
```
### Virtualization
For long lists, render only visible items:
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
const VirtualList = ({ items }: { items: Item[] }) => {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated item height
overscan: 5 // Render 5 extra items above/below viewport
})
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`
}}
>
<Item data={items[virtualItem.index]} />
</div>
))}
</div>
</div>
)
}
```
### Pagination
```typescript
const PaginatedList = ({ items }: Props) => {
const [page, setPage] = useState(1)
const itemsPerPage = 20
const paginatedItems = useMemo(() => {
const start = (page - 1) * itemsPerPage
const end = start + itemsPerPage
return items.slice(start, end)
}, [items, page, itemsPerPage])
return (
<>
{paginatedItems.map(item => (
<Item key={item.id} data={item} />
))}
<Pagination
page={page}
total={Math.ceil(items.length / itemsPerPage)}
onChange={setPage}
/>
</>
)
}
```
## Transitions & Concurrent Features
### useTransition
Keep UI responsive during expensive updates:
```typescript
const SearchPage = () => {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
const handleSearch = (value: string) => {
setQuery(value) // Urgent - update input immediately
// Non-urgent - can be interrupted
startTransition(() => {
const filtered = expensiveFilter(items, value)
setResults(filtered)
})
}
return (
<>
<input value={query} onChange={e => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
)
}
```
### useDeferredValue
Defer non-urgent renders:
```typescript
const SearchPage = () => {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
// Input updates immediately
// Results update with deferred value (can be interrupted)
const results = useMemo(() => {
return expensiveFilter(items, deferredQuery)
}, [deferredQuery])
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultsList results={results} />
</>
)
}
```
## Image & Asset Optimization
### Lazy Load Images
```typescript
const LazyImage = ({ src, alt }: Props) => {
const [isLoaded, setIsLoaded] = useState(false)
return (
<div className="relative">
{!isLoaded && <ImageSkeleton />}
<img
src={src}
alt={alt}
loading="lazy" // Native lazy loading
onLoad={() => setIsLoaded(true)}
className={isLoaded ? 'opacity-100' : 'opacity-0'}
/>
</div>
)
}
```
### Next.js Image Component
```typescript
import Image from 'next/image'
const OptimizedImage = () => (
<Image
src="/hero.jpg"
alt="Hero"
width={800}
height={600}
priority // Load immediately for above-fold images
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
)
```
## Bundle Size Optimization
### Tree Shaking
Import only what you need:
```typescript
// Bad - imports entire library
import _ from 'lodash'
// Good - import only needed functions
import debounce from 'lodash/debounce'
import throttle from 'lodash/throttle'
// Even better - use native methods when possible
const debounce = (fn, delay) => {
let timeoutId
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), delay)
}
}
```
### Analyze Bundle
```bash
# Next.js
ANALYZE=true npm run build
# Create React App
npm install --save-dev webpack-bundle-analyzer
```
### Dynamic Imports
```typescript
// Load library only when needed
const handleExport = async () => {
const { jsPDF } = await import('jspdf')
const doc = new jsPDF()
doc.save('report.pdf')
}
```
## Common Performance Pitfalls
### 1. Inline Object Creation
```typescript
// Bad - new object every render
<Component style={{ margin: 10 }} />
// Good - stable reference
const style = { margin: 10 }
<Component style={style} />
// Or use useMemo
const style = useMemo(() => ({ margin: 10 }), [])
```
### 2. Inline Functions
```typescript
// Bad - new function every render (if child is memoized)
<MemoizedChild onClick={() => handleClick(id)} />
// Good
const handleClickMemo = useCallback(() => handleClick(id), [id])
<MemoizedChild onClick={handleClickMemo} />
```
### 3. Spreading Props
```typescript
// Bad - causes re-renders even when props unchanged
<Component {...props} />
// Good - pass only needed props
<Component value={props.value} onChange={props.onChange} />
```
### 4. Large Context
```typescript
// Bad - everything re-renders on any state change
const AppContext = createContext({ user, theme, cart, settings, ... })
// Good - split into focused contexts
const UserContext = createContext(user)
const ThemeContext = createContext(theme)
const CartContext = createContext(cart)
```
## Performance Checklist
- [ ] Measure before optimizing (use Profiler)
- [ ] Use React DevTools to identify slow components
- [ ] Implement code splitting for large routes
- [ ] Lazy load below-the-fold content
- [ ] Virtualize long lists
- [ ] Memoize expensive calculations
- [ ] Split large contexts
- [ ] Colocate state close to usage
- [ ] Use transitions for non-urgent updates
- [ ] Optimize images and assets
- [ ] Analyze and minimize bundle size
- [ ] Remove console.logs in production
- [ ] Use production build for testing
- [ ] Monitor real-world performance metrics
## References
- React Performance: https://react.dev/learn/render-and-commit
- React Profiler: https://react.dev/reference/react/Profiler
- React Compiler: https://react.dev/reference/react-compiler
- Web Vitals: https://web.dev/vitals/

View File

@@ -0,0 +1,656 @@
# React Server Components & Server Functions
## Overview
React Server Components (RSC) allow components to render on the server, improving performance and enabling direct data access. Server Functions allow client components to call server-side functions.
## Server Components
### What are Server Components?
Components that run **only on the server**:
- Can access databases directly
- Zero bundle size (code stays on server)
- Better performance (less JavaScript to client)
- Automatic code splitting
### Creating Server Components
```typescript
// app/products/page.tsx
// Server Component by default in App Router
import { db } from '@/lib/db'
const ProductsPage = async () => {
// Direct database access
const products = await db.product.findMany({
where: { active: true },
include: { category: true }
})
return (
<div>
<h1>Products</h1>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
export default ProductsPage
```
### Server Component Rules
**Can do:**
- Access databases and APIs directly
- Use server-only modules (fs, path, etc.)
- Keep secrets secure (API keys, tokens)
- Reduce client bundle size
- Use async/await at top level
**Cannot do:**
- Use hooks (useState, useEffect, etc.)
- Use browser APIs (window, document)
- Attach event handlers (onClick, etc.)
- Use Context
### Mixing Server and Client Components
```typescript
// Server Component (default)
const Page = async () => {
const data = await fetchData()
return (
<div>
<ServerComponent data={data} />
{/* Client component for interactivity */}
<ClientComponent initialData={data} />
</div>
)
}
// Client Component
'use client'
import { useState } from 'react'
const ClientComponent = ({ initialData }) => {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
)
}
```
### Server Component Patterns
#### Data Fetching
```typescript
// app/user/[id]/page.tsx
interface PageProps {
params: { id: string }
}
const UserPage = async ({ params }: PageProps) => {
const user = await db.user.findUnique({
where: { id: params.id }
})
if (!user) {
notFound() // Next.js 404
}
return <UserProfile user={user} />
}
```
#### Parallel Data Fetching
```typescript
const DashboardPage = async () => {
// Fetch in parallel
const [user, orders, stats] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchStats()
])
return (
<>
<UserHeader user={user} />
<OrdersList orders={orders} />
<StatsWidget stats={stats} />
</>
)
}
```
#### Streaming with Suspense
```typescript
const Page = () => {
return (
<>
<Header />
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</>
)
}
const Products = async () => {
const products = await fetchProducts() // Slow query
return <ProductsList products={products} />
}
```
## Server Functions (Server Actions)
### What are Server Functions?
Functions that run on the server but can be called from client components:
- Marked with `'use server'` directive
- Can mutate data
- Integrated with forms
- Type-safe with TypeScript
### Creating Server Functions
#### File-level directive
```typescript
// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
const price = Number(formData.get('price'))
const product = await db.product.create({
data: { name, price }
})
revalidatePath('/products')
return product
}
export async function deleteProduct(id: string) {
await db.product.delete({ where: { id } })
revalidatePath('/products')
}
```
#### Function-level directive
```typescript
// Inside a Server Component
const MyComponent = async () => {
async function handleSubmit(formData: FormData) {
'use server'
const email = formData.get('email') as string
await saveEmail(email)
}
return <form action={handleSubmit}>...</form>
}
```
### Using Server Functions
#### With Forms
```typescript
'use client'
import { createProduct } from './actions'
const ProductForm = () => {
return (
<form action={createProduct}>
<input name="name" required />
<input name="price" type="number" required />
<button type="submit">Create</button>
</form>
)
}
```
#### With useActionState
```typescript
'use client'
import { useActionState } from 'react'
import { createProduct } from './actions'
type FormState = {
message: string
success: boolean
} | null
const ProductForm = () => {
const [state, formAction, isPending] = useActionState<FormState>(
async (previousState, formData: FormData) => {
try {
await createProduct(formData)
return { message: 'Product created!', success: true }
} catch (error) {
return { message: 'Failed to create product', success: false }
}
},
null
)
return (
<form action={formAction}>
<input name="name" required />
<input name="price" type="number" required />
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
{state?.message && (
<p className={state.success ? 'text-green-600' : 'text-red-600'}>
{state.message}
</p>
)}
</form>
)
}
```
#### Programmatic Invocation
```typescript
'use client'
import { deleteProduct } from './actions'
const DeleteButton = ({ productId }: { productId: string }) => {
const [isPending, setIsPending] = useState(false)
const handleDelete = async () => {
setIsPending(true)
try {
await deleteProduct(productId)
} catch (error) {
console.error(error)
} finally {
setIsPending(false)
}
}
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
)
}
```
### Server Function Patterns
#### Validation with Zod
```typescript
'use server'
import { z } from 'zod'
const ProductSchema = z.object({
name: z.string().min(3),
price: z.number().positive(),
description: z.string().optional()
})
export async function createProduct(formData: FormData) {
const rawData = {
name: formData.get('name'),
price: Number(formData.get('price')),
description: formData.get('description')
}
// Validate
const result = ProductSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
}
}
// Create product
const product = await db.product.create({
data: result.data
})
revalidatePath('/products')
return { success: true, product }
}
```
#### Authentication Check
```typescript
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function createOrder(formData: FormData) {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const order = await db.order.create({
data: {
userId: session.user.id,
// ... other fields
}
})
return order
}
```
#### Error Handling
```typescript
'use server'
export async function updateProfile(formData: FormData) {
try {
const userId = await getCurrentUserId()
const profile = await db.user.update({
where: { id: userId },
data: {
name: formData.get('name') as string,
bio: formData.get('bio') as string
}
})
revalidatePath('/profile')
return { success: true, profile }
} catch (error) {
console.error('Failed to update profile:', error)
return {
success: false,
error: 'Failed to update profile. Please try again.'
}
}
}
```
#### Optimistic Updates
```typescript
'use client'
import { useOptimistic } from 'react'
import { likePost } from './actions'
const Post = ({ post }: { post: Post }) => {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
post.likes,
(currentLikes) => currentLikes + 1
)
const handleLike = async () => {
addOptimisticLike(null)
await likePost(post.id)
}
return (
<div>
<p>{post.content}</p>
<button onClick={handleLike}>
{optimisticLikes}
</button>
</div>
)
}
```
## Data Mutations & Revalidation
### revalidatePath
Invalidate cached data for a path:
```typescript
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
await db.post.create({ data: {...} })
// Revalidate the posts page
revalidatePath('/posts')
// Revalidate with layout
revalidatePath('/posts', 'layout')
}
```
### revalidateTag
Invalidate cached data by tag:
```typescript
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(id: string, data: ProductData) {
await db.product.update({ where: { id }, data })
// Revalidate all queries tagged with 'products'
revalidateTag('products')
}
```
### redirect
Redirect after mutation:
```typescript
'use server'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: {...} })
// Redirect to the new post
redirect(`/posts/${post.id}`)
}
```
## Caching with Server Components
### cache Function
Deduplicate requests within a render:
```typescript
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return await db.user.findUnique({ where: { id } })
})
// Called multiple times but only fetches once per render
const Page = async () => {
const user1 = await getUser('123')
const user2 = await getUser('123') // Uses cached result
return <div>...</div>
}
```
### Next.js fetch Caching
```typescript
// Cached by default
const data = await fetch('https://api.example.com/data')
// Revalidate every 60 seconds
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
// Never cache
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
})
// Tag for revalidation
const data = await fetch('https://api.example.com/data', {
next: { tags: ['products'] }
})
```
## Best Practices
### 1. Component Placement
- Keep interactive components client-side
- Use server components for data fetching
- Place 'use client' as deep as possible in tree
### 2. Data Fetching
- Fetch in parallel when possible
- Use Suspense for streaming
- Cache expensive operations
### 3. Server Functions
- Validate all inputs
- Check authentication/authorization
- Handle errors gracefully
- Return serializable data only
### 4. Performance
- Minimize client JavaScript
- Use streaming for slow queries
- Implement proper caching
- Optimize database queries
### 5. Security
- Never expose secrets to client
- Validate server function inputs
- Use environment variables
- Implement rate limiting
## Common Patterns
### Layout with Dynamic Data
```typescript
// app/layout.tsx
const RootLayout = async ({ children }: { children: React.ReactNode }) => {
const user = await getCurrentUser()
return (
<html>
<body>
<Header user={user} />
{children}
<Footer />
</body>
</html>
)
}
```
### Loading States
```typescript
// app/products/loading.tsx
export default function Loading() {
return <ProductsSkeleton />
}
// app/products/page.tsx
const ProductsPage = async () => {
const products = await fetchProducts()
return <ProductsList products={products} />
}
```
### Error Boundaries
```typescript
// app/products/error.tsx
'use client'
export default function Error({
error,
reset
}: {
error: Error
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
```
### Search with Server Functions
```typescript
'use client'
import { searchProducts } from './actions'
import { useDeferredValue, useState, useEffect } from 'react'
const SearchPage = () => {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const deferredQuery = useDeferredValue(query)
useEffect(() => {
if (deferredQuery) {
searchProducts(deferredQuery).then(setResults)
}
}, [deferredQuery])
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<ResultsList results={results} />
</>
)
}
```
## Troubleshooting
### Common Issues
1. **"Cannot use hooks in Server Component"**
- Add 'use client' directive
- Move state logic to client component
2. **"Functions cannot be passed to Client Components"**
- Use Server Functions instead
- Pass data, not functions
3. **Hydration mismatches**
- Ensure server and client render same HTML
- Use useEffect for browser-only code
4. **Slow initial load**
- Implement Suspense boundaries
- Use streaming rendering
- Optimize database queries
## References
- React Server Components: https://react.dev/reference/rsc/server-components
- Server Functions: https://react.dev/reference/rsc/server-functions
- Next.js App Router: https://nextjs.org/docs/app

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,209 @@
---
name: skill-creator
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.
license: Complete terms in LICENSE.txt
---
# Skill Creator
This skill provides guidance for creating effective skills.
## About Skills
Skills are modular, self-contained packages that extend Claude's capabilities by providing
specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific
domains or tasks—they transform Claude from a general-purpose agent into a specialized agent
equipped with procedural knowledge that no model can fully possess.
### What Skills Provide
1. Specialized workflows - Multi-step procedures for specific domains
2. Tool integrations - Instructions for working with specific file formats or APIs
3. Domain expertise - Company-specific knowledge, schemas, business logic
4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks
### Anatomy of a Skill
Every skill consists of a required SKILL.md file and optional bundled resources:
```
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter metadata (required)
│ │ ├── name: (required)
│ │ └── description: (required)
│ └── Markdown instructions (required)
└── Bundled Resources (optional)
├── scripts/ - Executable code (Python/Bash/etc.)
├── references/ - Documentation intended to be loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts, etc.)
```
#### SKILL.md (required)
**Metadata Quality:** The `name` and `description` in YAML frontmatter determine when Claude will use the skill. Be specific about what the skill does and when to use it. Use the third-person (e.g. "This skill should be used when..." instead of "Use this skill when...").
#### Bundled Resources (optional)
##### Scripts (`scripts/`)
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments
##### References (`references/`)
Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking.
- **When to include**: For documentation that Claude should reference while working
- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications
- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed
- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md
- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
##### Assets (`assets/`)
Files not intended to be loaded into context, but rather used within the output Claude produces.
- **When to include**: When the skill needs files that will be used in the final output
- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography
- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context
### Progressive Disclosure Design Principle
Skills use a three-level loading system to manage context efficiently:
1. **Metadata (name + description)** - Always in context (~100 words)
2. **SKILL.md body** - When skill triggers (<5k words)
3. **Bundled resources** - As needed by Claude (Unlimited*)
*Unlimited because scripts can be executed without reading into context window.
## Skill Creation Process
To create a skill, follow the "Skill Creation Process" in order, skipping steps only if there is a clear reason why they are not applicable.
### Step 1: Understanding the Skill with Concrete Examples
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
For example, when building an image-editor skill, relevant questions include:
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
- "Can you give some examples of how this skill would be used?"
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
- "What would a user say that should trigger this skill?"
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
Conclude this step when there is a clear sense of the functionality the skill should support.
### Step 2: Planning the Reusable Skill Contents
To turn concrete examples into an effective skill, analyze each example by:
1. Considering how to execute on the example from scratch
2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
1. Rotating a PDF requires re-writing the same code each time
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
1. Writing a frontend webapp requires the same boilerplate HTML/React each time
2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill
Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows:
1. Querying BigQuery requires re-discovering the table schemas and relationships each time
2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
### Step 3: Initializing the Skill
At this point, it is time to actually create the skill.
Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
Usage:
```bash
scripts/init_skill.py <skill-name> --path <output-directory>
```
The script:
- Creates the skill directory at the specified path
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
- Creates example resource directories: `scripts/`, `references/`, and `assets/`
- Adds example files in each directory that can be customized or deleted
After initialization, customize or remove the generated SKILL.md and example files as needed.
### Step 4: Edit the Skill
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Focus on including information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively.
#### Start with Reusable Skill Contents
To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.
Also, delete any example files and directories not needed for the skill. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them.
#### Update SKILL.md
**Writing Style:** Write the entire skill using **imperative/infinitive form** (verb-first instructions), not second person. Use objective, instructional language (e.g., "To accomplish X, do Y" rather than "You should do X" or "If you need to do X"). This maintains consistency and clarity for AI consumption.
To complete SKILL.md, answer the following questions:
1. What is the purpose of the skill, in a few sentences?
2. When should the skill be used?
3. In practice, how should Claude use the skill? All reusable skill contents developed above should be referenced so that Claude knows how to use them.
### Step 5: Packaging a Skill
Once the skill is ready, it should be packaged into a distributable zip file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:
```bash
scripts/package_skill.py <path/to/skill-folder>
```
Optional output directory specification:
```bash
scripts/package_skill.py <path/to/skill-folder> ./dist
```
The packaging script will:
1. **Validate** the skill automatically, checking:
- YAML frontmatter format and required fields
- Skill naming conventions and directory structure
- Description completeness and quality
- File organization and resource references
2. **Package** the skill if validation passes, creating a zip file named after the skill (e.g., `my-skill.zip`) that includes all files and maintains the proper directory structure for distribution.
If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.
### Step 6: Iterate
After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.
**Iteration workflow:**
1. Use the skill on real tasks
2. Notice struggles or inefficiencies
3. Identify how SKILL.md or bundled resources should be updated
4. Implement changes and test again

View File

@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""
Skill Initializer - Creates a new skill from template
Usage:
init_skill.py <skill-name> --path <path>
Examples:
init_skill.py my-new-skill --path skills/public
init_skill.py my-api-helper --path skills/private
init_skill.py custom-skill --path /custom/location
"""
import sys
from pathlib import Path
SKILL_TEMPLATE = """---
name: {skill_name}
description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
---
# {skill_title}
## Overview
[TODO: 1-2 sentences explaining what this skill enables]
## Structuring This Skill
[TODO: Choose the structure that best fits this skill's purpose. Common patterns:
**1. Workflow-Based** (best for sequential processes)
- Works well when there are clear step-by-step procedures
- Example: DOCX skill with "Workflow Decision Tree""Reading""Creating""Editing"
- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2...
**2. Task-Based** (best for tool collections)
- Works well when the skill offers different operations/capabilities
- Example: PDF skill with "Quick Start""Merge PDFs""Split PDFs""Extract Text"
- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2...
**3. Reference/Guidelines** (best for standards or specifications)
- Works well for brand guidelines, coding standards, or requirements
- Example: Brand styling with "Brand Guidelines""Colors""Typography""Features"
- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage...
**4. Capabilities-Based** (best for integrated systems)
- Works well when the skill provides multiple interrelated features
- Example: Product Management with "Core Capabilities" → numbered capability list
- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature...
Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations).
Delete this entire "Structuring This Skill" section when done - it's just guidance.]
## [TODO: Replace with the first main section based on chosen structure]
[TODO: Add content here. See examples in existing skills:
- Code samples for technical skills
- Decision trees for complex workflows
- Concrete examples with realistic user requests
- References to scripts/templates/references as needed]
## Resources
This skill includes example resource directories that demonstrate how to organize different types of bundled resources:
### scripts/
Executable code (Python/Bash/etc.) that can be run directly to perform specific operations.
**Examples from other skills:**
- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation
- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing
**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations.
**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments.
### references/
Documentation and reference material intended to be loaded into context to inform Claude's process and thinking.
**Examples from other skills:**
- Product management: `communication.md`, `context_building.md` - detailed workflow guides
- BigQuery: API reference documentation and query examples
- Finance: Schema documentation, company policies
**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working.
### assets/
Files not intended to be loaded into context, but rather used within the output Claude produces.
**Examples from other skills:**
- Brand styling: PowerPoint template files (.pptx), logo files
- Frontend builder: HTML/React boilerplate project directories
- Typography: Font files (.ttf, .woff2)
**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
---
**Any unneeded directories can be deleted.** Not every skill requires all three types of resources.
"""
EXAMPLE_SCRIPT = '''#!/usr/bin/env python3
"""
Example helper script for {skill_name}
This is a placeholder script that can be executed directly.
Replace with actual implementation or delete if not needed.
Example real scripts from other skills:
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
"""
def main():
print("This is an example script for {skill_name}")
# TODO: Add actual script logic here
# This could be data processing, file conversion, API calls, etc.
if __name__ == "__main__":
main()
'''
EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title}
This is a placeholder for detailed reference documentation.
Replace with actual reference content or delete if not needed.
Example real reference docs from other skills:
- product-management/references/communication.md - Comprehensive guide for status updates
- product-management/references/context_building.md - Deep-dive on gathering context
- bigquery/references/ - API references and query examples
## When Reference Docs Are Useful
Reference docs are ideal for:
- Comprehensive API documentation
- Detailed workflow guides
- Complex multi-step processes
- Information too lengthy for main SKILL.md
- Content that's only needed for specific use cases
## Structure Suggestions
### API Reference Example
- Overview
- Authentication
- Endpoints with examples
- Error codes
- Rate limits
### Workflow Guide Example
- Prerequisites
- Step-by-step instructions
- Common patterns
- Troubleshooting
- Best practices
"""
EXAMPLE_ASSET = """# Example Asset File
This placeholder represents where asset files would be stored.
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
Asset files are NOT intended to be loaded into context, but rather used within
the output Claude produces.
Example asset files from other skills:
- Brand guidelines: logo.png, slides_template.pptx
- Frontend builder: hello-world/ directory with HTML/React boilerplate
- Typography: custom-font.ttf, font-family.woff2
- Data: sample_data.csv, test_dataset.json
## Common Asset Types
- Templates: .pptx, .docx, boilerplate directories
- Images: .png, .jpg, .svg, .gif
- Fonts: .ttf, .otf, .woff, .woff2
- Boilerplate code: Project directories, starter files
- Icons: .ico, .svg
- Data files: .csv, .json, .xml, .yaml
Note: This is a text placeholder. Actual assets can be any file type.
"""
def title_case_skill_name(skill_name):
"""Convert hyphenated skill name to Title Case for display."""
return ' '.join(word.capitalize() for word in skill_name.split('-'))
def init_skill(skill_name, path):
"""
Initialize a new skill directory with template SKILL.md.
Args:
skill_name: Name of the skill
path: Path where the skill directory should be created
Returns:
Path to created skill directory, or None if error
"""
# Determine skill directory path
skill_dir = Path(path).resolve() / skill_name
# Check if directory already exists
if skill_dir.exists():
print(f"❌ Error: Skill directory already exists: {skill_dir}")
return None
# Create skill directory
try:
skill_dir.mkdir(parents=True, exist_ok=False)
print(f"✅ Created skill directory: {skill_dir}")
except Exception as e:
print(f"❌ Error creating directory: {e}")
return None
# Create SKILL.md from template
skill_title = title_case_skill_name(skill_name)
skill_content = SKILL_TEMPLATE.format(
skill_name=skill_name,
skill_title=skill_title
)
skill_md_path = skill_dir / 'SKILL.md'
try:
skill_md_path.write_text(skill_content)
print("✅ Created SKILL.md")
except Exception as e:
print(f"❌ Error creating SKILL.md: {e}")
return None
# Create resource directories with example files
try:
# Create scripts/ directory with example script
scripts_dir = skill_dir / 'scripts'
scripts_dir.mkdir(exist_ok=True)
example_script = scripts_dir / 'example.py'
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
example_script.chmod(0o755)
print("✅ Created scripts/example.py")
# Create references/ directory with example reference doc
references_dir = skill_dir / 'references'
references_dir.mkdir(exist_ok=True)
example_reference = references_dir / 'api_reference.md'
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
print("✅ Created references/api_reference.md")
# Create assets/ directory with example asset placeholder
assets_dir = skill_dir / 'assets'
assets_dir.mkdir(exist_ok=True)
example_asset = assets_dir / 'example_asset.txt'
example_asset.write_text(EXAMPLE_ASSET)
print("✅ Created assets/example_asset.txt")
except Exception as e:
print(f"❌ Error creating resource directories: {e}")
return None
# Print next steps
print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}")
print("\nNext steps:")
print("1. Edit SKILL.md to complete the TODO items and update the description")
print("2. Customize or delete the example files in scripts/, references/, and assets/")
print("3. Run the validator when ready to check the skill structure")
return skill_dir
def main():
if len(sys.argv) < 4 or sys.argv[2] != '--path':
print("Usage: init_skill.py <skill-name> --path <path>")
print("\nSkill name requirements:")
print(" - Hyphen-case identifier (e.g., 'data-analyzer')")
print(" - Lowercase letters, digits, and hyphens only")
print(" - Max 40 characters")
print(" - Must match directory name exactly")
print("\nExamples:")
print(" init_skill.py my-new-skill --path skills/public")
print(" init_skill.py my-api-helper --path skills/private")
print(" init_skill.py custom-skill --path /custom/location")
sys.exit(1)
skill_name = sys.argv[1]
path = sys.argv[3]
print(f"🚀 Initializing skill: {skill_name}")
print(f" Location: {path}")
print()
result = init_skill(skill_name, path)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Skill Packager - Creates a distributable zip file of a skill folder
Usage:
python utils/package_skill.py <path/to/skill-folder> [output-directory]
Example:
python utils/package_skill.py skills/public/my-skill
python utils/package_skill.py skills/public/my-skill ./dist
"""
import sys
import zipfile
from pathlib import Path
from quick_validate import validate_skill
def package_skill(skill_path, output_dir=None):
"""
Package a skill folder into a zip file.
Args:
skill_path: Path to the skill folder
output_dir: Optional output directory for the zip file (defaults to current directory)
Returns:
Path to the created zip file, or None if error
"""
skill_path = Path(skill_path).resolve()
# Validate skill folder exists
if not skill_path.exists():
print(f"❌ Error: Skill folder not found: {skill_path}")
return None
if not skill_path.is_dir():
print(f"❌ Error: Path is not a directory: {skill_path}")
return None
# Validate SKILL.md exists
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
print(f"❌ Error: SKILL.md not found in {skill_path}")
return None
# Run validation before packaging
print("🔍 Validating skill...")
valid, message = validate_skill(skill_path)
if not valid:
print(f"❌ Validation failed: {message}")
print(" Please fix the validation errors before packaging.")
return None
print(f"{message}\n")
# Determine output location
skill_name = skill_path.name
if output_dir:
output_path = Path(output_dir).resolve()
output_path.mkdir(parents=True, exist_ok=True)
else:
output_path = Path.cwd()
zip_filename = output_path / f"{skill_name}.zip"
# Create the zip file
try:
with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Walk through the skill directory
for file_path in skill_path.rglob('*'):
if file_path.is_file():
# Calculate the relative path within the zip
arcname = file_path.relative_to(skill_path.parent)
zipf.write(file_path, arcname)
print(f" Added: {arcname}")
print(f"\n✅ Successfully packaged skill to: {zip_filename}")
return zip_filename
except Exception as e:
print(f"❌ Error creating zip file: {e}")
return None
def main():
if len(sys.argv) < 2:
print("Usage: python utils/package_skill.py <path/to/skill-folder> [output-directory]")
print("\nExample:")
print(" python utils/package_skill.py skills/public/my-skill")
print(" python utils/package_skill.py skills/public/my-skill ./dist")
sys.exit(1)
skill_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
print(f"📦 Packaging skill: {skill_path}")
if output_dir:
print(f" Output directory: {output_dir}")
print()
result = package_skill(skill_path, output_dir)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
Quick validation script for skills - minimal version
"""
import sys
import os
import re
from pathlib import Path
def validate_skill(skill_path):
"""Basic validation of a skill"""
skill_path = Path(skill_path)
# Check SKILL.md exists
skill_md = skill_path / 'SKILL.md'
if not skill_md.exists():
return False, "SKILL.md not found"
# Read and validate frontmatter
content = skill_md.read_text()
if not content.startswith('---'):
return False, "No YAML frontmatter found"
# Extract frontmatter
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not match:
return False, "Invalid frontmatter format"
frontmatter = match.group(1)
# Check required fields
if 'name:' not in frontmatter:
return False, "Missing 'name' in frontmatter"
if 'description:' not in frontmatter:
return False, "Missing 'description' in frontmatter"
# Extract name for validation
name_match = re.search(r'name:\s*(.+)', frontmatter)
if name_match:
name = name_match.group(1).strip()
# Check naming convention (hyphen-case: lowercase with hyphens)
if not re.match(r'^[a-z0-9-]+$', name):
return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)"
if name.startswith('-') or name.endswith('-') or '--' in name:
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens"
# Extract and validate description
desc_match = re.search(r'description:\s*(.+)', frontmatter)
if desc_match:
description = desc_match.group(1).strip()
# Check for angle brackets
if '<' in description or '>' in description:
return False, "Description cannot contain angle brackets (< or >)"
return True, "Skill is valid!"
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python quick_validate.py <skill_directory>")
sys.exit(1)
valid, message = validate_skill(sys.argv[1])
print(message)
sys.exit(0 if valid else 1)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
# TypeScript Claude Skill
Comprehensive TypeScript skill for type-safe development with modern JavaScript/TypeScript applications.
## Overview
This skill provides in-depth knowledge about TypeScript's type system, patterns, best practices, and integration with popular frameworks like React. It covers everything from basic types to advanced type manipulation techniques.
## Files
### Core Documentation
- **SKILL.md** - Main skill file with workflows and when to use this skill
- **quick-reference.md** - Quick lookup guide for common TypeScript syntax and patterns
### Reference Materials
- **references/type-system.md** - Comprehensive guide to TypeScript's type system
- **references/utility-types.md** - Complete reference for built-in and custom utility types
- **references/common-patterns.md** - Real-world TypeScript patterns and idioms
### Examples
- **examples/type-system-basics.ts** - Fundamental TypeScript concepts
- **examples/advanced-types.ts** - Generics, conditional types, mapped types
- **examples/react-patterns.ts** - Type-safe React components and hooks
- **examples/README.md** - Guide to using the examples
## Usage
### When to Use This Skill
Reference this skill when:
- Writing or refactoring TypeScript code
- Designing type-safe APIs and interfaces
- Working with advanced type system features
- Configuring TypeScript projects
- Troubleshooting type errors
- Implementing type-safe patterns with libraries
- Converting JavaScript to TypeScript
### Quick Start
For quick lookups, start with `quick-reference.md` which provides concise syntax and patterns.
For learning or deep dives:
1. **Fundamentals**: Start with `references/type-system.md`
2. **Utilities**: Learn about transformations in `references/utility-types.md`
3. **Patterns**: Study real-world patterns in `references/common-patterns.md`
4. **Practice**: Explore code examples in `examples/`
## Key Topics Covered
### Type System
- Primitive types and special types
- Object types (interfaces, type aliases)
- Union and intersection types
- Literal types and template literal types
- Type inference and narrowing
- Generic types with constraints
- Conditional types and mapped types
- Recursive types
### Advanced Features
- Type guards and type predicates
- Assertion functions
- Branded types for nominal typing
- Key remapping and filtering
- Distributive conditional types
- Type-level programming
### Utility Types
- Built-in utilities (Partial, Pick, Omit, etc.)
- Custom utility type patterns
- Deep transformations
- Type composition
### React Integration
- Component props typing
- Generic components
- Hooks with TypeScript
- Context with type safety
- Event handlers
- Ref typing
### Best Practices
- Type safety patterns
- Error handling
- Code organization
- Integration with Zod for runtime validation
- Named return variables (Go-style)
- Discriminated unions for state management
## Integration with Project Stack
This skill is designed to work seamlessly with:
- **React 19**: Type-safe component development
- **TanStack Ecosystem**: Typed queries, routing, forms, and stores
- **Zod**: Runtime validation with type inference
- **Radix UI**: Component prop typing
- **Tailwind CSS**: Type-safe className composition
## Examples
All examples are self-contained and demonstrate practical patterns:
- Based on real-world usage
- Follow project best practices
- Include comprehensive comments
- Can be run with `ts-node`
- Ready to adapt to your needs
## Configuration
The skill includes guidance on TypeScript configuration with recommended settings for:
- Strict type checking
- Module resolution
- JSX support
- Path aliases
- Declaration files
## Contributing
When adding new patterns or examples:
1. Follow existing file structure
2. Include comprehensive comments
3. Demonstrate real-world usage
4. Add to appropriate reference file
5. Update this README if needed
## Resources
- [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/)
- [TypeScript Deep Dive](https://basarat.gitbook.io/typescript/)
- [Type Challenges](https://github.com/type-challenges/type-challenges)
- [TSConfig Reference](https://www.typescriptlang.org/tsconfig)

View File

@@ -0,0 +1,359 @@
---
name: typescript
description: This skill should be used when working with TypeScript code, including type definitions, type inference, generics, utility types, and TypeScript configuration. Provides comprehensive knowledge of TypeScript patterns, best practices, and advanced type system features.
---
# TypeScript Skill
This skill provides comprehensive knowledge and patterns for working with TypeScript effectively in modern applications.
## When to Use This Skill
Use this skill when:
- Writing or refactoring TypeScript code
- Designing type-safe APIs and interfaces
- Working with advanced type system features (generics, conditional types, mapped types)
- Configuring TypeScript projects (tsconfig.json)
- Troubleshooting type errors
- Implementing type-safe patterns with libraries (React, TanStack, etc.)
- Converting JavaScript code to TypeScript
## Core Concepts
### Type System Fundamentals
TypeScript provides static typing for JavaScript with a powerful type system that includes:
- Primitive types (string, number, boolean, null, undefined, symbol, bigint)
- Object types (interfaces, type aliases, classes)
- Array and tuple types
- Union and intersection types
- Literal types and template literal types
- Type inference and type narrowing
- Generic types with constraints
- Conditional types and mapped types
### Type Inference
Leverage TypeScript's type inference to write less verbose code:
- Let TypeScript infer return types when obvious
- Use type inference for variable declarations
- Rely on generic type inference in function calls
- Use `as const` for immutable literal types
### Type Safety Patterns
Implement type-safe patterns:
- Use discriminated unions for state management
- Implement type guards for runtime type checking
- Use branded types for nominal typing
- Leverage conditional types for API design
- Use template literal types for string manipulation
## Key Workflows
### 1. Designing Type-Safe APIs
When designing APIs, follow these patterns:
**Interface vs Type Alias:**
- Use `interface` for object shapes that may be extended
- Use `type` for unions, intersections, and complex type operations
- Use `type` with mapped types and conditional types
**Generic Constraints:**
```typescript
// Use extends for generic constraints
function getValue<T extends { id: string }>(item: T): string {
return item.id
}
```
**Discriminated Unions:**
```typescript
// Use for type-safe state machines
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Data }
| { status: 'error'; error: Error }
```
### 2. Working with Utility Types
Use built-in utility types for common transformations:
- `Partial<T>` - Make all properties optional
- `Required<T>` - Make all properties required
- `Readonly<T>` - Make all properties readonly
- `Pick<T, K>` - Select specific properties
- `Omit<T, K>` - Exclude specific properties
- `Record<K, T>` - Create object type with specific keys
- `Exclude<T, U>` - Exclude types from union
- `Extract<T, U>` - Extract types from union
- `NonNullable<T>` - Remove null/undefined
- `ReturnType<T>` - Get function return type
- `Parameters<T>` - Get function parameter types
- `Awaited<T>` - Unwrap Promise type
### 3. Advanced Type Patterns
**Mapped Types:**
```typescript
// Transform object types
type Nullable<T> = {
[K in keyof T]: T[K] | null
}
type ReadonlyDeep<T> = {
readonly [K in keyof T]: T[K] extends object
? ReadonlyDeep<T[K]>
: T[K]
}
```
**Conditional Types:**
```typescript
// Type-level logic
type IsArray<T> = T extends Array<any> ? true : false
type Flatten<T> = T extends Array<infer U> ? U : T
```
**Template Literal Types:**
```typescript
// String manipulation at type level
type EventName<T extends string> = `on${Capitalize<T>}`
type Route = `/api/${'users' | 'posts'}/${string}`
```
### 4. Type Narrowing
Use type guards and narrowing techniques:
**typeof guards:**
```typescript
if (typeof value === 'string') {
// value is string here
}
```
**instanceof guards:**
```typescript
if (error instanceof Error) {
// error is Error here
}
```
**Custom type guards:**
```typescript
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value
}
```
**Discriminated unions:**
```typescript
function handle(state: State) {
switch (state.status) {
case 'idle':
// state is { status: 'idle' }
break
case 'success':
// state is { status: 'success'; data: Data }
console.log(state.data)
break
}
}
```
### 5. Working with External Libraries
**Typing Third-Party Libraries:**
- Install type definitions: `npm install --save-dev @types/package-name`
- Create custom declarations in `.d.ts` files when types unavailable
- Use module augmentation to extend existing type definitions
**Declaration Files:**
```typescript
// globals.d.ts
declare global {
interface Window {
myCustomProperty: string
}
}
export {}
```
### 6. TypeScript Configuration
Configure `tsconfig.json` for strict type checking:
**Essential Strict Options:**
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
}
}
```
## Best Practices
### 1. Prefer Type Inference Over Explicit Types
Let TypeScript infer types when they're obvious from context.
### 2. Use Strict Mode
Enable strict type checking to catch more errors at compile time.
### 3. Avoid `any` Type
Use `unknown` for truly unknown types, then narrow with type guards.
### 4. Use Const Assertions
Use `as const` for immutable values and narrow literal types.
### 5. Leverage Discriminated Unions
Use for state machines and variant types for better type safety.
### 6. Create Reusable Generic Types
Extract common type patterns into reusable generics.
### 7. Use Branded Types for Nominal Typing
Create distinct types for values with same structure but different meaning.
### 8. Document Complex Types
Add JSDoc comments to explain non-obvious type decisions.
### 9. Use Type-Only Imports
Use `import type` for type-only imports to aid tree-shaking.
### 10. Handle Errors with Type Guards
Use type guards to safely work with error objects.
## Common Patterns
### React Component Props
```typescript
// Use interface for component props
interface ButtonProps {
variant?: 'primary' | 'secondary'
size?: 'sm' | 'md' | 'lg'
onClick?: () => void
children: React.ReactNode
}
export function Button({ variant = 'primary', size = 'md', onClick, children }: ButtonProps) {
// implementation
}
```
### API Response Types
```typescript
// Use discriminated unions for API responses
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string }
// Helper for safe API calls
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
try {
const response = await fetch(url)
const data = await response.json()
return { success: true, data }
} catch (error) {
return { success: false, error: String(error) }
}
}
```
### Store/State Types
```typescript
// Use interfaces for state objects
interface AppState {
user: User | null
isAuthenticated: boolean
theme: 'light' | 'dark'
}
// Use type for actions (discriminated union)
type AppAction =
| { type: 'LOGIN'; payload: User }
| { type: 'LOGOUT' }
| { type: 'SET_THEME'; payload: 'light' | 'dark' }
```
## References
For detailed information on specific topics, refer to:
- `references/type-system.md` - Deep dive into TypeScript's type system
- `references/utility-types.md` - Complete guide to built-in utility types
- `references/advanced-types.md` - Advanced type patterns and techniques
- `references/tsconfig-reference.md` - Comprehensive tsconfig.json reference
- `references/common-patterns.md` - Common TypeScript patterns and idioms
- `examples/` - Practical code examples
## Troubleshooting
### Common Type Errors
**Type 'X' is not assignable to type 'Y':**
- Check if types are compatible
- Use type assertions when you know better than the compiler
- Consider using union types or widening the target type
**Object is possibly 'null' or 'undefined':**
- Use optional chaining: `object?.property`
- Use nullish coalescing: `value ?? defaultValue`
- Add type guards or null checks
**Type 'any' implicitly has...**
- Enable strict mode and fix type definitions
- Add explicit type annotations
- Use `unknown` instead of `any` when appropriate
**Cannot find module or its type declarations:**
- Install type definitions: `@types/package-name`
- Create custom `.d.ts` declaration file
- Add to `types` array in tsconfig.json
## Integration with Project Stack
### React 19
Use TypeScript with React 19 features:
- Type component props with interfaces
- Use generic types for hooks
- Type context providers properly
- Use `React.FC` sparingly (prefer explicit typing)
### TanStack Ecosystem
Type TanStack libraries properly:
- TanStack Query: Type query keys and data
- TanStack Router: Use typed route definitions
- TanStack Form: Type form values and validation
- TanStack Store: Type state and actions
### Zod Integration
Combine Zod with TypeScript:
- Use `z.infer<typeof schema>` to extract types from schemas
- Let Zod handle runtime validation
- Use TypeScript for compile-time type checking
## Resources
The TypeScript documentation provides comprehensive information:
- Handbook: https://www.typescriptlang.org/docs/handbook/
- Type manipulation: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html
- Utility types: https://www.typescriptlang.org/docs/handbook/utility-types.html
- TSConfig reference: https://www.typescriptlang.org/tsconfig

View File

@@ -0,0 +1,45 @@
# TypeScript Examples
This directory contains practical TypeScript examples demonstrating various patterns and features.
## Examples
1. **type-system-basics.ts** - Fundamental TypeScript types and features
2. **advanced-types.ts** - Generics, conditional types, and mapped types
3. **react-patterns.ts** - Type-safe React components and hooks
4. **api-patterns.ts** - API response handling with type safety
5. **validation.ts** - Runtime validation with Zod and TypeScript
## How to Use
Each example file is self-contained and demonstrates specific TypeScript concepts. They're based on real-world patterns used in the Plebeian Market application and follow best practices for:
- Type safety
- Error handling
- Code organization
- Reusability
- Maintainability
## Running Examples
These examples are TypeScript files that can be:
- Copied into your project
- Used as reference for patterns
- Modified for your specific needs
- Run with `ts-node` for testing
```bash
# Run an example
npx ts-node examples/type-system-basics.ts
```
## Learning Path
1. Start with `type-system-basics.ts` to understand fundamentals
2. Move to `advanced-types.ts` for complex type patterns
3. Explore `react-patterns.ts` for component typing
4. Study `api-patterns.ts` for type-safe API handling
5. Review `validation.ts` for runtime safety
Each example builds on previous concepts, so following this order is recommended for learners.

View File

@@ -0,0 +1,478 @@
/**
* Advanced TypeScript Types
*
* This file demonstrates advanced TypeScript features including:
* - Generics with constraints
* - Conditional types
* - Mapped types
* - Template literal types
* - Recursive types
* - Utility type implementations
*/
// ============================================================================
// Generics Basics
// ============================================================================
// Generic function
function identity<T>(value: T): T {
return value
}
const stringValue = identity('hello') // Type: string
const numberValue = identity(42) // Type: number
// Generic interface
interface Box<T> {
value: T
}
const stringBox: Box<string> = { value: 'hello' }
const numberBox: Box<number> = { value: 42 }
// Generic class
class Stack<T> {
private items: T[] = []
push(item: T): void {
this.items.push(item)
}
pop(): T | undefined {
return this.items.pop()
}
peek(): T | undefined {
return this.items[this.items.length - 1]
}
isEmpty(): boolean {
return this.items.length === 0
}
}
const numberStack = new Stack<number>()
numberStack.push(1)
numberStack.push(2)
numberStack.pop() // Type: number | undefined
// ============================================================================
// Generic Constraints
// ============================================================================
// Constrain to specific type
interface HasLength {
length: number
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length)
}
logLength('string') // OK
logLength([1, 2, 3]) // OK
logLength({ length: 10 }) // OK
// logLength(42) // Error: number doesn't have length
// Constrain to object keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
interface User {
id: string
name: string
age: number
}
const user: User = { id: '1', name: 'Alice', age: 30 }
const userName = getProperty(user, 'name') // Type: string
// const invalid = getProperty(user, 'invalid') // Error
// Multiple type parameters with constraints
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 }
}
const merged = merge({ a: 1 }, { b: 2 }) // Type: { a: number } & { b: number }
// ============================================================================
// Conditional Types
// ============================================================================
// Basic conditional type
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// Nested conditional types
type TypeName<T> = T extends string
? 'string'
: T extends number
? 'number'
: T extends boolean
? 'boolean'
: T extends undefined
? 'undefined'
: T extends Function
? 'function'
: 'object'
type T1 = TypeName<string> // "string"
type T2 = TypeName<number> // "number"
type T3 = TypeName<() => void> // "function"
// Distributive conditional types
type ToArray<T> = T extends any ? T[] : never
type StrArrOrNumArr = ToArray<string | number> // string[] | number[]
// infer keyword
type Flatten<T> = T extends Array<infer U> ? U : T
type Str = Flatten<string[]> // string
type Num = Flatten<number> // number
// Return type extraction
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
function exampleFn(): string {
return 'hello'
}
type ExampleReturn = MyReturnType<typeof exampleFn> // string
// Parameters extraction
type MyParameters<T> = T extends (...args: infer P) => any ? P : never
function createUser(name: string, age: number): User {
return { id: '1', name, age }
}
type CreateUserParams = MyParameters<typeof createUser> // [string, number]
// ============================================================================
// Mapped Types
// ============================================================================
// Make all properties optional
type MyPartial<T> = {
[K in keyof T]?: T[K]
}
interface Person {
name: string
age: number
email: string
}
type PartialPerson = MyPartial<Person>
// {
// name?: string
// age?: number
// email?: string
// }
// Make all properties required
type MyRequired<T> = {
[K in keyof T]-?: T[K]
}
// Make all properties readonly
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]
}
// Pick specific properties
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
type UserProfile = MyPick<User, 'id' | 'name'>
// { id: string; name: string }
// Omit specific properties
type MyOmit<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P]
}
type UserWithoutAge = MyOmit<User, 'age'>
// { id: string; name: string }
// Transform property types
type Nullable<T> = {
[K in keyof T]: T[K] | null
}
type NullablePerson = Nullable<Person>
// {
// name: string | null
// age: number | null
// email: string | null
// }
// ============================================================================
// Key Remapping
// ============================================================================
// Add prefix to keys
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type PersonGetters = Getters<Person>
// {
// getName: () => string
// getAge: () => number
// getEmail: () => string
// }
// Filter keys by type
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
interface Model {
id: number
name: string
description: string
price: number
}
type StringFields = PickByType<Model, string>
// { name: string; description: string }
// Remove specific key
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, 'kind'>]: T[K]
}
// ============================================================================
// Template Literal Types
// ============================================================================
// Event name generation
type EventName<T extends string> = `on${Capitalize<T>}`
type ClickEvent = EventName<'click'> // "onClick"
type SubmitEvent = EventName<'submit'> // "onSubmit"
// Combining literals
type Color = 'red' | 'green' | 'blue'
type Shade = 'light' | 'dark'
type ColorShade = `${Shade}-${Color}`
// "light-red" | "light-green" | "light-blue" | "dark-red" | "dark-green" | "dark-blue"
// CSS properties
type CSSProperty = 'margin' | 'padding'
type Side = 'top' | 'right' | 'bottom' | 'left'
type CSSPropertyWithSide = `${CSSProperty}-${Side}`
// "margin-top" | "margin-right" | ... | "padding-left"
// Route generation
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Endpoint = '/users' | '/products' | '/orders'
type ApiRoute = `${HttpMethod} ${Endpoint}`
// "GET /users" | "POST /users" | ... | "DELETE /orders"
// ============================================================================
// Recursive Types
// ============================================================================
// JSON value type
type JSONValue = string | number | boolean | null | JSONObject | JSONArray
interface JSONObject {
[key: string]: JSONValue
}
interface JSONArray extends Array<JSONValue> {}
// Tree structure
interface TreeNode<T> {
value: T
children?: TreeNode<T>[]
}
const tree: TreeNode<number> = {
value: 1,
children: [
{ value: 2, children: [{ value: 4 }, { value: 5 }] },
{ value: 3, children: [{ value: 6 }] },
],
}
// Deep readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
interface NestedConfig {
api: {
url: string
timeout: number
}
features: {
darkMode: boolean
}
}
type ImmutableConfig = DeepReadonly<NestedConfig>
// All properties at all levels are readonly
// Deep partial
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
// ============================================================================
// Advanced Utility Types
// ============================================================================
// Exclude types from union
type MyExclude<T, U> = T extends U ? never : T
type T4 = MyExclude<'a' | 'b' | 'c', 'a'> // "b" | "c"
// Extract types from union
type MyExtract<T, U> = T extends U ? T : never
type T5 = MyExtract<'a' | 'b' | 'c', 'a' | 'f'> // "a"
// NonNullable
type MyNonNullable<T> = T extends null | undefined ? never : T
type T6 = MyNonNullable<string | null | undefined> // string
// Record
type MyRecord<K extends keyof any, T> = {
[P in K]: T
}
type PageInfo = MyRecord<string, number>
// Awaited
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T
type T7 = MyAwaited<Promise<string>> // string
type T8 = MyAwaited<Promise<Promise<number>>> // number
// ============================================================================
// Branded Types
// ============================================================================
type Brand<K, T> = K & { __brand: T }
type USD = Brand<number, 'USD'>
type EUR = Brand<number, 'EUR'>
type UserId = Brand<string, 'UserId'>
type ProductId = Brand<string, 'ProductId'>
function makeUSD(amount: number): USD {
return amount as USD
}
function makeUserId(id: string): UserId {
return id as UserId
}
const usd = makeUSD(100)
const userId = makeUserId('user-123')
// Type-safe operations
function addMoney(a: USD, b: USD): USD {
return (a + b) as USD
}
// Prevents mixing different branded types
// const total = addMoney(usd, eur) // Error
// ============================================================================
// Union to Intersection
// ============================================================================
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never
type Union = { a: string } | { b: number }
type Intersection = UnionToIntersection<Union>
// { a: string } & { b: number }
// ============================================================================
// Advanced Generic Patterns
// ============================================================================
// Constraining multiple related types
function merge<
T extends Record<string, any>,
U extends Record<string, any>,
K extends keyof T & keyof U,
>(obj1: T, obj2: U, conflictKeys: K[]): T & U {
const result = { ...obj1, ...obj2 }
conflictKeys.forEach((key) => {
// Handle conflicts
})
return result as T & U
}
// Builder pattern with fluent API
class QueryBuilder<T, Selected extends keyof T = never> {
private selectFields: Set<keyof T> = new Set()
select<K extends keyof T>(
...fields: K[]
): QueryBuilder<T, Selected | K> {
fields.forEach((field) => this.selectFields.add(field))
return this as any
}
execute(): Pick<T, Selected> {
// Execute query
return {} as Pick<T, Selected>
}
}
// Usage
interface Product {
id: string
name: string
price: number
description: string
}
const result = new QueryBuilder<Product>()
.select('id', 'name')
.select('price')
.execute()
// Type: { id: string; name: string; price: number }
// ============================================================================
// Exports
// ============================================================================
export type {
Box,
HasLength,
IsString,
Flatten,
MyPartial,
MyRequired,
MyReadonly,
Nullable,
DeepReadonly,
DeepPartial,
Brand,
USD,
EUR,
UserId,
ProductId,
JSONValue,
TreeNode,
}
export { Stack, identity, getProperty, merge, makeUSD, makeUserId }

View File

@@ -0,0 +1,555 @@
/**
* TypeScript React Patterns
*
* This file demonstrates type-safe React patterns including:
* - Component props typing
* - Hooks with TypeScript
* - Context with type safety
* - Generic components
* - Event handlers
* - Ref types
*/
import { createContext, useContext, useEffect, useReducer, useRef, useState } from 'react'
import type { ReactNode, InputHTMLAttributes, FormEvent, ChangeEvent } from 'react'
// ============================================================================
// Component Props Patterns
// ============================================================================
// Basic component with props
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'tertiary'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
onClick?: () => void
children: ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
onClick,
children,
}: ButtonProps) {
return (
<button
className={`btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}
// Props extending HTML attributes
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
export function Input({ label, error, helperText, ...inputProps }: InputProps) {
return (
<div className="input-wrapper">
{label && <label>{label}</label>}
<input className={error ? 'input-error' : ''} {...inputProps} />
{error && <span className="error">{error}</span>}
{helperText && <span className="helper">{helperText}</span>}
</div>
)
}
// Generic component
interface ListProps<T> {
items: T[]
renderItem: (item: T, index: number) => ReactNode
keyExtractor: (item: T, index: number) => string
emptyMessage?: string
}
export function List<T>({
items,
renderItem,
keyExtractor,
emptyMessage = 'No items',
}: ListProps<T>) {
if (items.length === 0) {
return <div>{emptyMessage}</div>
}
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item, index)}>{renderItem(item, index)}</li>
))}
</ul>
)
}
// Component with children render prop
interface ContainerProps {
isLoading: boolean
error: Error | null
children: (props: { retry: () => void }) => ReactNode
}
export function Container({ isLoading, error, children }: ContainerProps) {
const retry = () => {
// Retry logic
}
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return <>{children({ retry })}</>
}
// ============================================================================
// Hooks Patterns
// ============================================================================
// useState with explicit type
function useCounter(initialValue: number = 0) {
const [count, setCount] = useState<number>(initialValue)
const increment = () => setCount((c) => c + 1)
const decrement = () => setCount((c) => c - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}
// useState with union type
type LoadingState = 'idle' | 'loading' | 'success' | 'error'
function useLoadingState() {
const [state, setState] = useState<LoadingState>('idle')
const startLoading = () => setState('loading')
const setSuccess = () => setState('success')
const setError = () => setState('error')
const reset = () => setState('idle')
return { state, startLoading, setSuccess, setError, reset }
}
// Custom hook with options
interface UseFetchOptions<T> {
initialData?: T
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
interface UseFetchReturn<T> {
data: T | undefined
loading: boolean
error: Error | null
refetch: () => Promise<void>
}
function useFetch<T>(url: string, options?: UseFetchOptions<T>): UseFetchReturn<T> {
const [data, setData] = useState<T | undefined>(options?.initialData)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const json = await response.json()
setData(json)
options?.onSuccess?.(json)
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
setError(error)
options?.onError?.(error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [url])
return { data, loading, error, refetch: fetchData }
}
// useReducer with discriminated unions
interface User {
id: string
name: string
email: string
}
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; error: Error }
| { type: 'RESET' }
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' }
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload }
case 'FETCH_ERROR':
return { status: 'error', error: action.error }
case 'RESET':
return { status: 'idle' }
}
}
function useFetchWithReducer<T>(url: string) {
const [state, dispatch] = useReducer(fetchReducer<T>, { status: 'idle' })
useEffect(() => {
let isCancelled = false
const fetchData = async () => {
dispatch({ type: 'FETCH_START' })
try {
const response = await fetch(url)
const data = await response.json()
if (!isCancelled) {
dispatch({ type: 'FETCH_SUCCESS', payload: data })
}
} catch (error) {
if (!isCancelled) {
dispatch({
type: 'FETCH_ERROR',
error: error instanceof Error ? error : new Error(String(error)),
})
}
}
}
fetchData()
return () => {
isCancelled = true
}
}, [url])
return state
}
// ============================================================================
// Context Patterns
// ============================================================================
// Type-safe context
interface AuthContextType {
user: User | null
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const login = async (email: string, password: string) => {
// Login logic
const userData = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}).then((r) => r.json())
setUser(userData)
}
const logout = () => {
setUser(null)
}
const value: AuthContextType = {
user,
isAuthenticated: user !== null,
login,
logout,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
// Custom hook with error handling
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
// ============================================================================
// Event Handler Patterns
// ============================================================================
interface FormData {
name: string
email: string
message: string
}
function ContactForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
message: '',
})
// Type-safe change handler
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData((prev) => ({
...prev,
[name]: value,
}))
}
// Type-safe submit handler
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
console.log('Submitting:', formData)
}
// Specific field handler
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Name"
/>
<input
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Message"
/>
<button type="submit">Submit</button>
</form>
)
}
// ============================================================================
// Ref Patterns
// ============================================================================
function FocusInput() {
// useRef with DOM element
const inputRef = useRef<HTMLInputElement>(null)
const focusInput = () => {
inputRef.current?.focus()
}
return (
<div>
<input ref={inputRef} />
<button onClick={focusInput}>Focus Input</button>
</div>
)
}
function Timer() {
// useRef for mutable value
const countRef = useRef<number>(0)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const startTimer = () => {
intervalRef.current = setInterval(() => {
countRef.current += 1
console.log(countRef.current)
}, 1000)
}
const stopTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
return (
<div>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
)
}
// ============================================================================
// Generic Component Patterns
// ============================================================================
// Select component with generic options
interface SelectProps<T> {
options: T[]
value: T
onChange: (value: T) => void
getLabel: (option: T) => string
getValue: (option: T) => string
}
export function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
}: SelectProps<T>) {
return (
<select
value={getValue(value)}
onChange={(e) => {
const selectedValue = e.target.value
const option = options.find((opt) => getValue(opt) === selectedValue)
if (option) {
onChange(option)
}
}}
>
{options.map((option) => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
)
}
// Data table component
interface Column<T> {
key: keyof T
header: string
render?: (value: T[keyof T], row: T) => ReactNode
}
interface TableProps<T> {
data: T[]
columns: Column<T>[]
keyExtractor: (row: T) => string
}
export function Table<T>({ data, columns, keyExtractor }: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={keyExtractor(row)}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render ? col.render(row[col.key], row) : String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
// ============================================================================
// Higher-Order Component Pattern
// ============================================================================
interface WithLoadingProps {
isLoading: boolean
}
function withLoading<P extends object>(
Component: React.ComponentType<P>,
): React.FC<P & WithLoadingProps> {
return ({ isLoading, ...props }: WithLoadingProps & P) => {
if (isLoading) {
return <div>Loading...</div>
}
return <Component {...(props as P)} />
}
}
// Usage
interface UserListProps {
users: User[]
}
const UserList: React.FC<UserListProps> = ({ users }) => (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
const UserListWithLoading = withLoading(UserList)
// ============================================================================
// Exports
// ============================================================================
export {
useCounter,
useLoadingState,
useFetch,
useFetchWithReducer,
ContactForm,
FocusInput,
Timer,
}
export type {
ButtonProps,
InputProps,
ListProps,
UseFetchOptions,
UseFetchReturn,
FetchState,
FetchAction,
AuthContextType,
SelectProps,
Column,
TableProps,
}

View File

@@ -0,0 +1,361 @@
/**
* TypeScript Type System Basics
*
* This file demonstrates fundamental TypeScript concepts including:
* - Primitive types
* - Object types (interfaces, type aliases)
* - Union and intersection types
* - Type inference and narrowing
* - Function types
*/
// ============================================================================
// Primitive Types
// ============================================================================
const message: string = 'Hello, TypeScript!'
const count: number = 42
const isActive: boolean = true
const nothing: null = null
const notDefined: undefined = undefined
// ============================================================================
// Object Types
// ============================================================================
// Interface definition
interface User {
id: string
name: string
email: string
age?: number // Optional property
readonly createdAt: Date // Readonly property
}
// Type alias definition
type Product = {
id: string
name: string
price: number
category: string
}
// Creating objects
const user: User = {
id: '1',
name: 'Alice',
email: 'alice@example.com',
createdAt: new Date(),
}
const product: Product = {
id: 'p1',
name: 'Laptop',
price: 999,
category: 'electronics',
}
// ============================================================================
// Union Types
// ============================================================================
type Status = 'idle' | 'loading' | 'success' | 'error'
type ID = string | number
function formatId(id: ID): string {
if (typeof id === 'string') {
return id.toUpperCase()
}
return id.toString()
}
// Discriminated unions
type ApiResponse =
| { success: true; data: User }
| { success: false; error: string }
function handleResponse(response: ApiResponse) {
if (response.success) {
// TypeScript knows response.data exists here
console.log(response.data.name)
} else {
// TypeScript knows response.error exists here
console.error(response.error)
}
}
// ============================================================================
// Intersection Types
// ============================================================================
type Timestamped = {
createdAt: Date
updatedAt: Date
}
type TimestampedUser = User & Timestamped
const timestampedUser: TimestampedUser = {
id: '1',
name: 'Bob',
email: 'bob@example.com',
createdAt: new Date(),
updatedAt: new Date(),
}
// ============================================================================
// Array Types
// ============================================================================
const numbers: number[] = [1, 2, 3, 4, 5]
const strings: Array<string> = ['a', 'b', 'c']
const users: User[] = [user, timestampedUser]
// Readonly arrays
const immutableNumbers: readonly number[] = [1, 2, 3]
// immutableNumbers.push(4) // Error: push does not exist on readonly array
// ============================================================================
// Tuple Types
// ============================================================================
type Point = [number, number]
type NamedPoint = [x: number, y: number, z?: number]
const point: Point = [10, 20]
const namedPoint: NamedPoint = [10, 20, 30]
// ============================================================================
// Function Types
// ============================================================================
// Function declaration
function add(a: number, b: number): number {
return a + b
}
// Arrow function
const subtract = (a: number, b: number): number => a - b
// Function type alias
type MathOperation = (a: number, b: number) => number
const multiply: MathOperation = (a, b) => a * b
// Optional parameters
function greet(name: string, greeting?: string): string {
return `${greeting ?? 'Hello'}, ${name}!`
}
// Default parameters
function createUser(name: string, role: string = 'user'): User {
return {
id: Math.random().toString(),
name,
email: `${name.toLowerCase()}@example.com`,
createdAt: new Date(),
}
}
// Rest parameters
function sum(...numbers: number[]): number {
return numbers.reduce((acc, n) => acc + n, 0)
}
// ============================================================================
// Type Inference
// ============================================================================
// Type is inferred as string
let inferredString = 'hello'
// Type is inferred as number
let inferredNumber = 42
// Type is inferred as { name: string; age: number }
let inferredObject = {
name: 'Alice',
age: 30,
}
// Return type is inferred as number
function inferredReturn(a: number, b: number) {
return a + b
}
// ============================================================================
// Type Narrowing
// ============================================================================
// typeof guard
function processValue(value: string | number) {
if (typeof value === 'string') {
// value is string here
return value.toUpperCase()
}
// value is number here
return value.toFixed(2)
}
// Truthiness narrowing
function printName(name: string | null | undefined) {
if (name) {
// name is string here
console.log(name.toUpperCase())
}
}
// Equality narrowing
function example(x: string | number, y: string | boolean) {
if (x === y) {
// x and y are both string here
console.log(x.toUpperCase(), y.toLowerCase())
}
}
// in operator narrowing
type Fish = { swim: () => void }
type Bird = { fly: () => void }
function move(animal: Fish | Bird) {
if ('swim' in animal) {
// animal is Fish here
animal.swim()
} else {
// animal is Bird here
animal.fly()
}
}
// instanceof narrowing
function processError(error: Error | string) {
if (error instanceof Error) {
// error is Error here
console.error(error.message)
} else {
// error is string here
console.error(error)
}
}
// ============================================================================
// Type Predicates (Custom Type Guards)
// ============================================================================
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
)
}
function processData(data: unknown) {
if (isUser(data)) {
// data is User here
console.log(data.name)
}
}
// ============================================================================
// Const Assertions
// ============================================================================
// Without const assertion
const mutableConfig = {
host: 'localhost',
port: 8080,
}
// mutableConfig.host = 'example.com' // OK
// With const assertion
const immutableConfig = {
host: 'localhost',
port: 8080,
} as const
// immutableConfig.host = 'example.com' // Error: cannot assign to readonly property
// Array with const assertion
const directions = ['north', 'south', 'east', 'west'] as const
// Type: readonly ["north", "south", "east", "west"]
// ============================================================================
// Literal Types
// ============================================================================
type Direction = 'north' | 'south' | 'east' | 'west'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6
function move(direction: Direction, steps: number) {
console.log(`Moving ${direction} by ${steps} steps`)
}
move('north', 10) // OK
// move('up', 10) // Error: "up" is not assignable to Direction
// ============================================================================
// Index Signatures
// ============================================================================
interface StringMap {
[key: string]: string
}
const translations: StringMap = {
hello: 'Hola',
goodbye: 'Adiós',
thanks: 'Gracias',
}
// ============================================================================
// Utility Functions
// ============================================================================
// Type-safe object keys
function getObjectKeys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>
}
// Type-safe property access
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const userName = getProperty(user, 'name') // Type: string
const userAge = getProperty(user, 'age') // Type: number | undefined
// ============================================================================
// Named Return Values (Go-style)
// ============================================================================
function parseJSON(json: string): { data: unknown | null; err: Error | null } {
let data: unknown | null = null
let err: Error | null = null
try {
data = JSON.parse(json)
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { data, err }
}
// Usage
const { data, err } = parseJSON('{"name": "Alice"}')
if (err) {
console.error('Failed to parse JSON:', err.message)
} else {
console.log('Parsed data:', data)
}
// ============================================================================
// Exports
// ============================================================================
export type { User, Product, Status, ID, ApiResponse, TimestampedUser }
export { formatId, handleResponse, processValue, isUser, getProperty, parseJSON }

View File

@@ -0,0 +1,395 @@
# TypeScript Quick Reference
Quick lookup guide for common TypeScript patterns and syntax.
## Basic Types
```typescript
// Primitives
string, number, boolean, null, undefined, symbol, bigint
// Special types
any // Avoid - disables type checking
unknown // Type-safe alternative to any
void // No return value
never // Never returns
// Arrays
number[]
Array<string>
readonly number[]
// Tuples
[string, number]
[x: number, y: number]
// Objects
{ name: string; age: number }
Record<string, number>
```
## Type Declarations
```typescript
// Interface
interface User {
id: string
name: string
age?: number // Optional
readonly createdAt: Date // Readonly
}
// Type alias
type Status = 'idle' | 'loading' | 'success' | 'error'
type ID = string | number
type Point = { x: number; y: number }
// Function type
type Callback = (data: string) => void
type MathOp = (a: number, b: number) => number
```
## Union & Intersection
```typescript
// Union (OR)
string | number
type Result = Success | Error
// Intersection (AND)
A & B
type Combined = User & Timestamped
// Discriminated union
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Data }
| { status: 'error'; error: Error }
```
## Generics
```typescript
// Generic function
function identity<T>(value: T): T
// Generic interface
interface Box<T> { value: T }
// Generic with constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K]
// Multiple type parameters
function merge<T, U>(a: T, b: U): T & U
// Default type parameter
interface Response<T = unknown> { data: T }
```
## Utility Types
```typescript
Partial<T> // Make all optional
Required<T> // Make all required
Readonly<T> // Make all readonly
Pick<T, K> // Select properties
Omit<T, K> // Exclude properties
Record<K, T> // Object with specific keys
Exclude<T, U> // Remove from union
Extract<T, U> // Extract from union
NonNullable<T> // Remove null/undefined
ReturnType<T> // Get function return type
Parameters<T> // Get function parameters
Awaited<T> // Unwrap Promise
```
## Type Guards
```typescript
// typeof
if (typeof value === 'string') { }
// instanceof
if (error instanceof Error) { }
// in operator
if ('property' in object) { }
// Custom type guard
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value
}
// Assertion function
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw new Error()
}
```
## Advanced Types
```typescript
// Conditional types
type IsString<T> = T extends string ? true : false
// Mapped types
type Nullable<T> = { [K in keyof T]: T[K] | null }
// Template literal types
type EventName<T extends string> = `on${Capitalize<T>}`
// Key remapping
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
// infer keyword
type Flatten<T> = T extends Array<infer U> ? U : T
```
## Functions
```typescript
// Function declaration
function add(a: number, b: number): number { return a + b }
// Arrow function
const subtract = (a: number, b: number): number => a - b
// Optional parameters
function greet(name: string, greeting?: string): string { }
// Default parameters
function create(name: string, role = 'user'): User { }
// Rest parameters
function sum(...numbers: number[]): number { }
// Overloads
function format(value: string): string
function format(value: number): string
function format(value: string | number): string { }
```
## Classes
```typescript
class User {
// Properties
private id: string
public name: string
protected age: number
readonly createdAt: Date
// Constructor
constructor(name: string) {
this.name = name
this.createdAt = new Date()
}
// Methods
greet(): string {
return `Hello, ${this.name}`
}
// Static
static create(name: string): User {
return new User(name)
}
// Getters/Setters
get displayName(): string {
return this.name.toUpperCase()
}
}
// Inheritance
class Admin extends User {
constructor(name: string, public permissions: string[]) {
super(name)
}
}
// Abstract class
abstract class Animal {
abstract makeSound(): void
}
```
## React Patterns
```typescript
// Component props
interface ButtonProps {
variant?: 'primary' | 'secondary'
onClick?: () => void
children: React.ReactNode
}
export function Button({ variant = 'primary', onClick, children }: ButtonProps) { }
// Generic component
interface ListProps<T> {
items: T[]
renderItem: (item: T) => React.ReactNode
}
export function List<T>({ items, renderItem }: ListProps<T>) { }
// Hooks
const [state, setState] = useState<string>('')
const [data, setData] = useState<User | null>(null)
// Context
interface AuthContextType {
user: User | null
login: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used within AuthProvider')
return context
}
```
## Common Patterns
### Result Type
```typescript
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E }
```
### Option Type
```typescript
type Option<T> = Some<T> | None
interface Some<T> { _tag: 'Some'; value: T }
interface None { _tag: 'None' }
```
### Branded Types
```typescript
type Brand<K, T> = K & { __brand: T }
type UserId = Brand<string, 'UserId'>
```
### Named Returns (Go-style)
```typescript
function parseJSON(json: string): { data: unknown | null; err: Error | null } {
let data: unknown | null = null
let err: Error | null = null
try {
data = JSON.parse(json)
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { data, err }
}
```
## Type Assertions
```typescript
// as syntax (preferred)
const value = input as string
// Angle bracket syntax (not in JSX)
const value = <string>input
// as const
const config = { host: 'localhost' } as const
// Non-null assertion (use sparingly)
const element = document.getElementById('app')!
```
## Type Narrowing
```typescript
// Control flow
if (value !== null) {
// value is non-null here
}
// Switch with discriminated unions
switch (state.status) {
case 'success':
console.log(state.data) // TypeScript knows data exists
break
case 'error':
console.log(state.error) // TypeScript knows error exists
break
}
// Optional chaining
user?.profile?.name
// Nullish coalescing
const name = user?.name ?? 'Anonymous'
```
## Module Syntax
```typescript
// Named exports
export function helper() { }
export const CONFIG = { }
// Default export
export default class App { }
// Type-only imports/exports
import type { User } from './types'
export type { User }
// Namespace imports
import * as utils from './utils'
```
## TSConfig Essentials
```json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
}
}
```
## Common Errors & Fixes
| Error | Fix |
|-------|-----|
| Type 'X' is not assignable to type 'Y' | Check type compatibility, use type assertion if needed |
| Object is possibly 'null' | Use optional chaining `?.` or null check |
| Cannot find module | Install `@types/package-name` |
| Implicit any | Add type annotation or enable strict mode |
| Property does not exist | Check object shape, use type guard |
## Best Practices
1. Enable `strict` mode in tsconfig.json
2. Avoid `any`, use `unknown` instead
3. Use discriminated unions for state
4. Leverage type inference
5. Use `const` assertions for immutable data
6. Create custom type guards for runtime safety
7. Use utility types instead of recreating
8. Document complex types with JSDoc
9. Prefer interfaces for objects, types for unions
10. Use branded types for domain-specific primitives

View File

@@ -0,0 +1,756 @@
# TypeScript Common Patterns Reference
This document contains commonly used TypeScript patterns and idioms from real-world applications.
## React Patterns
### Component Props
```typescript
// Basic props with children
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'tertiary'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
onClick?: () => void
children: React.ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
onClick,
children,
}: ButtonProps) {
return (
<button className={`btn-${variant} btn-${size}`} disabled={disabled} onClick={onClick}>
{children}
</button>
)
}
// Props extending HTML attributes
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
}
export function Input({ label, error, ...inputProps }: InputProps) {
return (
<div>
{label && <label>{label}</label>}
<input {...inputProps} />
{error && <span>{error}</span>}
</div>
)
}
// Generic component props
interface ListProps<T> {
items: T[]
renderItem: (item: T) => React.ReactNode
keyExtractor: (item: T) => string
}
export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
)
}
```
### Hooks
```typescript
// Custom hook with return type
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
return initialValue
}
})
const setValue = (value: T) => {
setStoredValue(value)
window.localStorage.setItem(key, JSON.stringify(value))
}
return [storedValue, setValue]
}
// Hook with options object
interface UseFetchOptions<T> {
initialData?: T
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
function useFetch<T>(url: string, options?: UseFetchOptions<T>) {
const [data, setData] = useState<T | undefined>(options?.initialData)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let isCancelled = false
const fetchData = async () => {
setLoading(true)
try {
const response = await fetch(url)
const json = await response.json()
if (!isCancelled) {
setData(json)
options?.onSuccess?.(json)
}
} catch (err) {
if (!isCancelled) {
const error = err instanceof Error ? err : new Error(String(err))
setError(error)
options?.onError?.(error)
}
} finally {
if (!isCancelled) {
setLoading(false)
}
}
}
fetchData()
return () => {
isCancelled = true
}
}, [url])
return { data, loading, error }
}
```
### Context
```typescript
// Type-safe context
interface AuthContextType {
user: User | null
login: (email: string, password: string) => Promise<void>
logout: () => void
isAuthenticated: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const login = async (email: string, password: string) => {
// Login logic
const user = await api.login(email, password)
setUser(user)
}
const logout = () => {
setUser(null)
}
const value: AuthContextType = {
user,
login,
logout,
isAuthenticated: user !== null,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
// Custom hook with proper error handling
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
```
## API Response Patterns
### Result Type Pattern
```typescript
// Discriminated union for API responses
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E }
// Helper functions
function success<T>(data: T): Result<T> {
return { success: true, data }
}
function failure<E = Error>(error: E): Result<never, E> {
return { success: false, error }
}
// Usage
async function fetchUser(id: string): Promise<Result<User>> {
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
return failure(new Error(`HTTP ${response.status}`))
}
const data = await response.json()
return success(data)
} catch (error) {
return failure(error instanceof Error ? error : new Error(String(error)))
}
}
// Consuming the result
const result = await fetchUser('123')
if (result.success) {
console.log(result.data.name) // Type-safe access
} else {
console.error(result.error.message) // Type-safe error handling
}
```
### Option Type Pattern
```typescript
// Option/Maybe type for nullable values
type Option<T> = Some<T> | None
interface Some<T> {
readonly _tag: 'Some'
readonly value: T
}
interface None {
readonly _tag: 'None'
}
// Constructors
function some<T>(value: T): Option<T> {
return { _tag: 'Some', value }
}
function none(): Option<never> {
return { _tag: 'None' }
}
// Helper functions
function isSome<T>(option: Option<T>): option is Some<T> {
return option._tag === 'Some'
}
function isNone<T>(option: Option<T>): option is None {
return option._tag === 'None'
}
function map<T, U>(option: Option<T>, fn: (value: T) => U): Option<U> {
return isSome(option) ? some(fn(option.value)) : none()
}
function getOrElse<T>(option: Option<T>, defaultValue: T): T {
return isSome(option) ? option.value : defaultValue
}
// Usage
function findUser(id: string): Option<User> {
const user = users.find((u) => u.id === id)
return user ? some(user) : none()
}
const user = findUser('123')
const userName = getOrElse(map(user, (u) => u.name), 'Unknown')
```
## State Management Patterns
### Discriminated Union for State
```typescript
// State machine using discriminated unions
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
// Reducer pattern
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; error: Error }
| { type: 'RESET' }
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' }
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload }
case 'FETCH_ERROR':
return { status: 'error', error: action.error }
case 'RESET':
return { status: 'idle' }
}
}
// Usage in component
function UserProfile({ userId }: { userId: string }) {
const [state, dispatch] = useReducer(fetchReducer<User>, { status: 'idle' })
useEffect(() => {
dispatch({ type: 'FETCH_START' })
fetchUser(userId)
.then((user) => dispatch({ type: 'FETCH_SUCCESS', payload: user }))
.catch((error) => dispatch({ type: 'FETCH_ERROR', error }))
}, [userId])
switch (state.status) {
case 'idle':
return <div>Ready to load</div>
case 'loading':
return <div>Loading...</div>
case 'success':
return <div>{state.data.name}</div>
case 'error':
return <div>Error: {state.error.message}</div>
}
}
```
### Store Pattern
```typescript
// Type-safe store implementation
interface Store<T> {
getState: () => T
setState: (partial: Partial<T>) => void
subscribe: (listener: (state: T) => void) => () => void
}
function createStore<T>(initialState: T): Store<T> {
let state = initialState
const listeners = new Set<(state: T) => void>()
return {
getState: () => state,
setState: (partial) => {
state = { ...state, ...partial }
listeners.forEach((listener) => listener(state))
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
// Usage
interface AppState {
user: User | null
theme: 'light' | 'dark'
}
const store = createStore<AppState>({
user: null,
theme: 'light',
})
// React hook integration
function useStore<T, U>(store: Store<T>, selector: (state: T) => U): U {
const [value, setValue] = useState(() => selector(store.getState()))
useEffect(() => {
const unsubscribe = store.subscribe((state) => {
setValue(selector(state))
})
return unsubscribe
}, [store, selector])
return value
}
// Usage in component
function ThemeToggle() {
const theme = useStore(store, (state) => state.theme)
return (
<button
onClick={() => store.setState({ theme: theme === 'light' ? 'dark' : 'light' })}
>
Toggle Theme
</button>
)
}
```
## Form Patterns
### Form State Management
```typescript
// Generic form state
interface FormState<T> {
values: T
errors: Partial<Record<keyof T, string>>
touched: Partial<Record<keyof T, boolean>>
isSubmitting: boolean
}
// Form hook
function useForm<T extends Record<string, any>>(
initialValues: T,
validate: (values: T) => Partial<Record<keyof T, string>>,
) {
const [state, setState] = useState<FormState<T>>({
values: initialValues,
errors: {},
touched: {},
isSubmitting: false,
})
const handleChange = <K extends keyof T>(field: K, value: T[K]) => {
setState((prev) => ({
...prev,
values: { ...prev.values, [field]: value },
errors: { ...prev.errors, [field]: undefined },
}))
}
const handleBlur = <K extends keyof T>(field: K) => {
setState((prev) => ({
...prev,
touched: { ...prev.touched, [field]: true },
}))
}
const handleSubmit = async (onSubmit: (values: T) => Promise<void>) => {
const errors = validate(state.values)
if (Object.keys(errors).length > 0) {
setState((prev) => ({
...prev,
errors,
touched: Object.keys(state.values).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{},
),
}))
return
}
setState((prev) => ({ ...prev, isSubmitting: true }))
try {
await onSubmit(state.values)
} finally {
setState((prev) => ({ ...prev, isSubmitting: false }))
}
}
return {
values: state.values,
errors: state.errors,
touched: state.touched,
isSubmitting: state.isSubmitting,
handleChange,
handleBlur,
handleSubmit,
}
}
// Usage
interface LoginFormValues {
email: string
password: string
}
function LoginForm() {
const form = useForm<LoginFormValues>(
{ email: '', password: '' },
(values) => {
const errors: Partial<Record<keyof LoginFormValues, string>> = {}
if (!values.email) {
errors.email = 'Email is required'
}
if (!values.password) {
errors.password = 'Password is required'
}
return errors
},
)
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit(async (values) => {
await login(values.email, values.password)
})
}}
>
<input
value={form.values.email}
onChange={(e) => form.handleChange('email', e.target.value)}
onBlur={() => form.handleBlur('email')}
/>
{form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
<input
type="password"
value={form.values.password}
onChange={(e) => form.handleChange('password', e.target.value)}
onBlur={() => form.handleBlur('password')}
/>
{form.touched.password && form.errors.password && (
<span>{form.errors.password}</span>
)}
<button type="submit" disabled={form.isSubmitting}>
Login
</button>
</form>
)
}
```
## Validation Patterns
### Zod Integration
```typescript
import { z } from 'zod'
// Schema definition
const userSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(120),
role: z.enum(['admin', 'user', 'guest']),
})
// Extract type from schema
type User = z.infer<typeof userSchema>
// Validation function
function validateUser(data: unknown): Result<User> {
const result = userSchema.safeParse(data)
if (result.success) {
return { success: true, data: result.data }
}
return {
success: false,
error: new Error(result.error.errors.map((e) => e.message).join(', ')),
}
}
// API integration
async function createUser(data: unknown): Promise<Result<User>> {
const validation = validateUser(data)
if (!validation.success) {
return validation
}
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validation.data),
})
if (!response.ok) {
return failure(new Error(`HTTP ${response.status}`))
}
const user = await response.json()
return success(user)
} catch (error) {
return failure(error instanceof Error ? error : new Error(String(error)))
}
}
```
## Builder Pattern
```typescript
// Fluent builder pattern
class QueryBuilder<T> {
private filters: Array<(item: T) => boolean> = []
private sortFn?: (a: T, b: T) => number
private limitValue?: number
where(predicate: (item: T) => boolean): this {
this.filters.push(predicate)
return this
}
sortBy(compareFn: (a: T, b: T) => number): this {
this.sortFn = compareFn
return this
}
limit(count: number): this {
this.limitValue = count
return this
}
execute(data: T[]): T[] {
let result = data
// Apply filters
this.filters.forEach((filter) => {
result = result.filter(filter)
})
// Apply sorting
if (this.sortFn) {
result = result.sort(this.sortFn)
}
// Apply limit
if (this.limitValue !== undefined) {
result = result.slice(0, this.limitValue)
}
return result
}
}
// Usage
interface Product {
id: string
name: string
price: number
category: string
}
const products: Product[] = [
/* ... */
]
const query = new QueryBuilder<Product>()
.where((p) => p.category === 'electronics')
.where((p) => p.price < 1000)
.sortBy((a, b) => a.price - b.price)
.limit(10)
.execute(products)
```
## Factory Pattern
```typescript
// Abstract factory pattern with TypeScript
interface Button {
render: () => string
onClick: () => void
}
interface ButtonFactory {
createButton: (label: string, onClick: () => void) => Button
}
class PrimaryButton implements Button {
constructor(private label: string, private clickHandler: () => void) {}
render() {
return `<button class="primary">${this.label}</button>`
}
onClick() {
this.clickHandler()
}
}
class SecondaryButton implements Button {
constructor(private label: string, private clickHandler: () => void) {}
render() {
return `<button class="secondary">${this.label}</button>`
}
onClick() {
this.clickHandler()
}
}
class PrimaryButtonFactory implements ButtonFactory {
createButton(label: string, onClick: () => void): Button {
return new PrimaryButton(label, onClick)
}
}
class SecondaryButtonFactory implements ButtonFactory {
createButton(label: string, onClick: () => void): Button {
return new SecondaryButton(label, onClick)
}
}
// Usage
function createUI(factory: ButtonFactory) {
const button = factory.createButton('Click me', () => console.log('Clicked!'))
return button.render()
}
```
## Named Return Variables Pattern
```typescript
// Following Go-style named returns
function parseUser(data: unknown): { user: User | null; err: Error | null } {
let user: User | null = null
let err: Error | null = null
try {
user = userSchema.parse(data)
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { user, err }
}
// With explicit naming
function fetchData(url: string): {
data: unknown | null
status: number
err: Error | null
} {
let data: unknown | null = null
let status = 0
let err: Error | null = null
try {
const response = fetch(url)
// Process response
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { data, status, err }
}
```
## Best Practices
1. **Use discriminated unions** for type-safe state management
2. **Leverage generic types** for reusable components and hooks
3. **Extract types from Zod schemas** for runtime + compile-time safety
4. **Use Result/Option types** for explicit error handling
5. **Create builder patterns** for complex object construction
6. **Use factory patterns** for flexible object creation
7. **Type context properly** to catch usage errors at compile time
8. **Prefer const assertions** for immutable configurations
9. **Use branded types** for domain-specific primitives
10. **Document patterns** with JSDoc for team knowledge sharing

View File

@@ -0,0 +1,804 @@
# TypeScript Type System Reference
## Overview
TypeScript's type system is structural (duck-typed) rather than nominal. Two types are compatible if their structure matches, regardless of their names.
## Primitive Types
### Basic Primitives
```typescript
let str: string = 'hello'
let num: number = 42
let bool: boolean = true
let nul: null = null
let undef: undefined = undefined
let sym: symbol = Symbol('key')
let big: bigint = 100n
```
### Special Types
**any** - Disables type checking (avoid when possible):
```typescript
let anything: any = 'string'
anything = 42 // OK
anything.nonExistent() // OK at compile time, error at runtime
```
**unknown** - Type-safe alternative to any (requires type checking):
```typescript
let value: unknown = 'string'
// value.toUpperCase() // Error: must narrow type first
if (typeof value === 'string') {
value.toUpperCase() // OK after narrowing
}
```
**void** - Absence of a value (function return type):
```typescript
function log(message: string): void {
console.log(message)
}
```
**never** - Value that never occurs (exhaustive checks, infinite loops):
```typescript
function throwError(message: string): never {
throw new Error(message)
}
function exhaustiveCheck(value: never): never {
throw new Error(`Unhandled case: ${value}`)
}
```
## Object Types
### Interfaces
```typescript
// Basic interface
interface User {
id: string
name: string
email: string
}
// Optional properties
interface Product {
id: string
name: string
description?: string // Optional
}
// Readonly properties
interface Config {
readonly apiUrl: string
readonly timeout: number
}
// Index signatures
interface Dictionary {
[key: string]: string
}
// Method signatures
interface Calculator {
add(a: number, b: number): number
subtract(a: number, b: number): number
}
// Extending interfaces
interface Employee extends User {
role: string
department: string
}
// Multiple inheritance
interface Admin extends User, Employee {
permissions: string[]
}
```
### Type Aliases
```typescript
// Basic type alias
type ID = string | number
// Object type
type Point = {
x: number
y: number
}
// Union type
type Status = 'idle' | 'loading' | 'success' | 'error'
// Intersection type
type Timestamped = {
createdAt: Date
updatedAt: Date
}
type TimestampedUser = User & Timestamped
// Function type
type Callback = (data: string) => void
// Generic type alias
type Result<T> = { success: true; data: T } | { success: false; error: string }
```
### Interface vs Type Alias
**Use interface when:**
- Defining object shapes
- Need declaration merging
- Building public API types that others might extend
**Use type when:**
- Creating unions or intersections
- Working with mapped types
- Need conditional types
- Defining primitive aliases
## Array and Tuple Types
### Arrays
```typescript
// Array syntax
let numbers: number[] = [1, 2, 3]
let strings: Array<string> = ['a', 'b', 'c']
// Readonly arrays
let immutable: readonly number[] = [1, 2, 3]
let alsoImmutable: ReadonlyArray<string> = ['a', 'b']
```
### Tuples
```typescript
// Fixed-length, mixed-type arrays
type Point = [number, number]
type NamedPoint = [x: number, y: number]
// Optional elements
type OptionalTuple = [string, number?]
// Rest elements
type StringNumberBooleans = [string, number, ...boolean[]]
// Readonly tuples
type ReadonlyPair = readonly [string, number]
```
## Union and Intersection Types
### Union Types
```typescript
// Value can be one of several types
type StringOrNumber = string | number
function format(value: StringOrNumber): string {
if (typeof value === 'string') {
return value
}
return value.toString()
}
// Discriminated unions
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number }
| { kind: 'rectangle'; width: number; height: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'square':
return shape.size ** 2
case 'rectangle':
return shape.width * shape.height
}
}
```
### Intersection Types
```typescript
// Combine multiple types
type Draggable = {
drag: () => void
}
type Resizable = {
resize: () => void
}
type UIWidget = Draggable & Resizable
const widget: UIWidget = {
drag: () => console.log('dragging'),
resize: () => console.log('resizing'),
}
```
## Literal Types
### String Literal Types
```typescript
type Direction = 'north' | 'south' | 'east' | 'west'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
function move(direction: Direction) {
// direction can only be one of the four values
}
```
### Number Literal Types
```typescript
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6
type PowerOfTwo = 1 | 2 | 4 | 8 | 16 | 32
```
### Boolean Literal Types
```typescript
type Yes = true
type No = false
```
### Template Literal Types
```typescript
// String manipulation at type level
type EventName<T extends string> = `on${Capitalize<T>}`
type ClickEvent = EventName<'click'> // "onClick"
// Combining literals
type Color = 'red' | 'blue' | 'green'
type Shade = 'light' | 'dark'
type ColorShade = `${Shade}-${Color}` // "light-red" | "light-blue" | ...
// Extract patterns
type EmailLocaleIDs = 'welcome_email' | 'email_heading'
type FooterLocaleIDs = 'footer_title' | 'footer_sendoff'
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`
```
## Type Inference
### Automatic Inference
```typescript
// Type inferred as string
let message = 'hello'
// Type inferred as number[]
let numbers = [1, 2, 3]
// Type inferred as { name: string; age: number }
let person = {
name: 'Alice',
age: 30,
}
// Return type inferred
function add(a: number, b: number) {
return a + b // Returns number
}
```
### Const Assertions
```typescript
// Without const assertion
let colors1 = ['red', 'green', 'blue'] // Type: string[]
// With const assertion
let colors2 = ['red', 'green', 'blue'] as const // Type: readonly ["red", "green", "blue"]
// Object with const assertion
const config = {
host: 'localhost',
port: 8080,
} as const // All properties become readonly with literal types
```
### Type Inference in Generics
```typescript
// Generic type inference from usage
function identity<T>(value: T): T {
return value
}
let str = identity('hello') // T inferred as string
let num = identity(42) // T inferred as number
// Multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second]
}
let p = pair('hello', 42) // [string, number]
```
## Type Narrowing
### typeof Guards
```typescript
function padLeft(value: string, padding: string | number) {
if (typeof padding === 'number') {
// padding is number here
return ' '.repeat(padding) + value
}
// padding is string here
return padding + value
}
```
### instanceof Guards
```typescript
class Dog {
bark() {
console.log('Woof!')
}
}
class Cat {
meow() {
console.log('Meow!')
}
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark()
} else {
animal.meow()
}
}
```
### in Operator
```typescript
type Fish = { swim: () => void }
type Bird = { fly: () => void }
function move(animal: Fish | Bird) {
if ('swim' in animal) {
animal.swim()
} else {
animal.fly()
}
}
```
### Equality Narrowing
```typescript
function example(x: string | number, y: string | boolean) {
if (x === y) {
// x and y are both string here
x.toUpperCase()
y.toLowerCase()
}
}
```
### Control Flow Analysis
```typescript
function example(value: string | null) {
if (value === null) {
return
}
// value is string here (null eliminated)
console.log(value.toUpperCase())
}
```
### Type Predicates (Custom Type Guards)
```typescript
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function example(value: unknown) {
if (isString(value)) {
// value is string here
console.log(value.toUpperCase())
}
}
// More complex example
interface User {
id: string
name: string
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
typeof (value as User).id === 'string' &&
typeof (value as User).name === 'string'
)
}
```
### Assertion Functions
```typescript
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message || 'Assertion failed')
}
}
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error('Value must be a string')
}
}
function example(value: unknown) {
assertIsString(value)
// value is string here
console.log(value.toUpperCase())
}
```
## Generic Types
### Basic Generics
```typescript
// Generic function
function first<T>(items: T[]): T | undefined {
return items[0]
}
// Generic interface
interface Box<T> {
value: T
}
// Generic type alias
type Result<T> = { success: true; data: T } | { success: false; error: string }
// Generic class
class Stack<T> {
private items: T[] = []
push(item: T) {
this.items.push(item)
}
pop(): T | undefined {
return this.items.pop()
}
}
```
### Generic Constraints
```typescript
// Constrain to specific type
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
// Constrain to interface
interface HasLength {
length: number
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length)
}
logLength('string') // OK
logLength([1, 2, 3]) // OK
logLength({ length: 10 }) // OK
// logLength(42) // Error: number doesn't have length
```
### Default Generic Parameters
```typescript
interface Response<T = unknown> {
data: T
status: number
}
// Uses default
let response1: Response = { data: 'anything', status: 200 }
// Explicitly typed
let response2: Response<User> = { data: user, status: 200 }
```
### Generic Utility Functions
```typescript
// Pick specific properties
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>
keys.forEach((key) => {
result[key] = obj[key]
})
return result
}
// Map array
function map<T, U>(items: T[], fn: (item: T) => U): U[] {
return items.map(fn)
}
```
## Advanced Type Features
### Conditional Types
```typescript
// Basic conditional type
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// Distributive conditional types
type ToArray<T> = T extends any ? T[] : never
type StrArrOrNumArr = ToArray<string | number> // string[] | number[]
// Infer keyword
type Flatten<T> = T extends Array<infer U> ? U : T
type Str = Flatten<string[]> // string
type Num = Flatten<number> // number
// ReturnType implementation
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
```
### Mapped Types
```typescript
// Make all properties optional
type Partial<T> = {
[K in keyof T]?: T[K]
}
// Make all properties required
type Required<T> = {
[K in keyof T]-?: T[K]
}
// Make all properties readonly
type Readonly<T> = {
readonly [K in keyof T]: T[K]
}
// Transform keys
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
interface Person {
name: string
age: number
}
type PersonGetters = Getters<Person>
// {
// getName: () => string
// getAge: () => number
// }
```
### Key Remapping
```typescript
// Filter keys
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, 'kind'>]: T[K]
}
// Conditional key inclusion
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
interface Model {
id: number
name: string
age: number
email: string
}
type StringFields = PickByType<Model, string> // { name: string, email: string }
```
### Recursive Types
```typescript
// JSON value type
type JSONValue = string | number | boolean | null | JSONObject | JSONArray
interface JSONObject {
[key: string]: JSONValue
}
interface JSONArray extends Array<JSONValue> {}
// Tree structure
interface TreeNode<T> {
value: T
children?: TreeNode<T>[]
}
// Deep readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
```
## Type Compatibility
### Structural Typing
```typescript
interface Point {
x: number
y: number
}
interface Named {
name: string
}
// Compatible if structure matches
let point: Point = { x: 0, y: 0 }
let namedPoint = { x: 0, y: 0, name: 'origin' }
point = namedPoint // OK: namedPoint has x and y
```
### Variance
**Covariance** (return types):
```typescript
interface Animal {
name: string
}
interface Dog extends Animal {
breed: string
}
let getDog: () => Dog
let getAnimal: () => Animal
getAnimal = getDog // OK: Dog is assignable to Animal
```
**Contravariance** (parameter types):
```typescript
let handleAnimal: (animal: Animal) => void
let handleDog: (dog: Dog) => void
handleDog = handleAnimal // OK: can pass Dog to function expecting Animal
```
## Index Types
### Index Signatures
```typescript
// String index
interface StringMap {
[key: string]: string
}
// Number index
interface NumberArray {
[index: number]: number
}
// Combine with named properties
interface MixedInterface {
length: number
[index: number]: string
}
```
### keyof Operator
```typescript
interface Person {
name: string
age: number
}
type PersonKeys = keyof Person // "name" | "age"
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
```
### Indexed Access Types
```typescript
interface Person {
name: string
age: number
address: {
street: string
city: string
}
}
type Name = Person['name'] // string
type Age = Person['age'] // number
type Address = Person['address'] // { street: string; city: string }
type AddressCity = Person['address']['city'] // string
// Access multiple keys
type NameOrAge = Person['name' | 'age'] // string | number
```
## Branded Types
```typescript
// Create nominal types from structural types
type Brand<K, T> = K & { __brand: T }
type USD = Brand<number, 'USD'>
type EUR = Brand<number, 'EUR'>
function makeUSD(amount: number): USD {
return amount as USD
}
function makeEUR(amount: number): EUR {
return amount as EUR
}
let usd = makeUSD(100)
let eur = makeEUR(100)
// usd = eur // Error: different brands
```
## Best Practices
1. **Prefer type inference** - Let TypeScript infer types when obvious
2. **Use strict null checks** - Enable strictNullChecks for better safety
3. **Avoid `any`** - Use `unknown` and narrow with type guards
4. **Use discriminated unions** - Better than loose unions for state
5. **Leverage const assertions** - Get narrow literal types
6. **Use branded types** - When structural typing isn't enough
7. **Document complex types** - Add JSDoc comments
8. **Extract reusable types** - DRY principle applies to types too
9. **Use utility types** - Leverage built-in transformation types
10. **Test your types** - Use type assertions to verify type correctness

View File

@@ -0,0 +1,666 @@
# TypeScript Utility Types Reference
TypeScript provides several built-in utility types that help transform and manipulate types. These are implemented using advanced type features like mapped types and conditional types.
## Property Modifiers
### Partial\<T\>
Makes all properties in `T` optional.
```typescript
interface User {
id: string
name: string
email: string
age: number
}
type PartialUser = Partial<User>
// {
// id?: string
// name?: string
// email?: string
// age?: number
// }
// Useful for update operations
function updateUser(id: string, updates: Partial<User>) {
// Only update provided fields
}
updateUser('123', { name: 'Alice' }) // OK
updateUser('123', { name: 'Alice', age: 30 }) // OK
```
### Required\<T\>
Makes all properties in `T` required (removes optionality).
```typescript
interface Config {
host?: string
port?: number
timeout?: number
}
type RequiredConfig = Required<Config>
// {
// host: string
// port: number
// timeout: number
// }
function initServer(config: RequiredConfig) {
// All properties are guaranteed to exist
console.log(config.host, config.port, config.timeout)
}
```
### Readonly\<T\>
Makes all properties in `T` readonly.
```typescript
interface MutablePoint {
x: number
y: number
}
type ImmutablePoint = Readonly<MutablePoint>
// {
// readonly x: number
// readonly y: number
// }
const point: ImmutablePoint = { x: 0, y: 0 }
// point.x = 10 // Error: Cannot assign to 'x' because it is a read-only property
```
### Mutable\<T\> (Custom)
Removes readonly modifiers (not built-in, but useful pattern).
```typescript
type Mutable<T> = {
-readonly [K in keyof T]: T[K]
}
interface ReadonlyPerson {
readonly name: string
readonly age: number
}
type MutablePerson = Mutable<ReadonlyPerson>
// {
// name: string
// age: number
// }
```
## Property Selection
### Pick\<T, K\>
Creates a type by picking specific properties from `T`.
```typescript
interface User {
id: string
name: string
email: string
password: string
createdAt: Date
}
type UserProfile = Pick<User, 'id' | 'name' | 'email'>
// {
// id: string
// name: string
// email: string
// }
// Useful for API responses
function getUserProfile(id: string): UserProfile {
// Return only safe properties
}
```
### Omit\<T, K\>
Creates a type by omitting specific properties from `T`.
```typescript
interface User {
id: string
name: string
email: string
password: string
}
type UserWithoutPassword = Omit<User, 'password'>
// {
// id: string
// name: string
// email: string
// }
// Useful for public user data
function publishUser(user: User): UserWithoutPassword {
const { password, ...publicData } = user
return publicData
}
```
## Union Type Utilities
### Exclude\<T, U\>
Excludes types from `T` that are assignable to `U`.
```typescript
type T1 = Exclude<'a' | 'b' | 'c', 'a'> // "b" | "c"
type T2 = Exclude<string | number | boolean, boolean> // string | number
type EventType = 'click' | 'scroll' | 'mousemove' | 'keypress'
type UIEvent = Exclude<EventType, 'scroll'> // "click" | "mousemove" | "keypress"
```
### Extract\<T, U\>
Extracts types from `T` that are assignable to `U`.
```typescript
type T1 = Extract<'a' | 'b' | 'c', 'a' | 'f'> // "a"
type T2 = Extract<string | number | boolean, boolean> // boolean
type Shape = 'circle' | 'square' | 'triangle' | 'rectangle'
type RoundedShape = Extract<Shape, 'circle'> // "circle"
```
### NonNullable\<T\>
Excludes `null` and `undefined` from `T`.
```typescript
type T1 = NonNullable<string | null | undefined> // string
type T2 = NonNullable<string | number | null> // string | number
function processValue(value: string | null | undefined) {
if (value !== null && value !== undefined) {
const nonNull: NonNullable<typeof value> = value
// nonNull is guaranteed to be string
}
}
```
## Object Construction
### Record\<K, T\>
Constructs an object type with keys of type `K` and values of type `T`.
```typescript
type PageInfo = Record<string, number>
// { [key: string]: number }
const pages: PageInfo = {
home: 1,
about: 2,
contact: 3,
}
// Useful for mapped objects
type UserRole = 'admin' | 'user' | 'guest'
type RolePermissions = Record<UserRole, string[]>
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read'],
}
// With specific keys
type ThemeColors = Record<'primary' | 'secondary' | 'accent', string>
const colors: ThemeColors = {
primary: '#007bff',
secondary: '#6c757d',
accent: '#28a745',
}
```
## Function Utilities
### Parameters\<T\>
Extracts the parameter types of a function type as a tuple.
```typescript
function createUser(name: string, age: number, email: string) {
// ...
}
type CreateUserParams = Parameters<typeof createUser>
// [name: string, age: number, email: string]
// Useful for higher-order functions
function withLogging<T extends (...args: any[]) => any>(
fn: T,
...args: Parameters<T>
): ReturnType<T> {
console.log('Calling with:', args)
return fn(...args)
}
```
### ConstructorParameters\<T\>
Extracts the parameter types of a constructor function type.
```typescript
class User {
constructor(public name: string, public age: number) {}
}
type UserConstructorParams = ConstructorParameters<typeof User>
// [name: string, age: number]
function createUser(...args: UserConstructorParams): User {
return new User(...args)
}
```
### ReturnType\<T\>
Extracts the return type of a function type.
```typescript
function createUser() {
return {
id: '123',
name: 'Alice',
email: 'alice@example.com',
}
}
type User = ReturnType<typeof createUser>
// {
// id: string
// name: string
// email: string
// }
// Useful with async functions
async function fetchData() {
return { success: true, data: [1, 2, 3] }
}
type FetchResult = ReturnType<typeof fetchData>
// Promise<{ success: boolean; data: number[] }>
type UnwrappedResult = Awaited<FetchResult>
// { success: boolean; data: number[] }
```
### InstanceType\<T\>
Extracts the instance type of a constructor function type.
```typescript
class User {
name: string
constructor(name: string) {
this.name = name
}
}
type UserInstance = InstanceType<typeof User>
// User
function processUser(user: UserInstance) {
console.log(user.name)
}
```
### ThisParameterType\<T\>
Extracts the type of the `this` parameter for a function type.
```typescript
function toHex(this: Number) {
return this.toString(16)
}
type ThisType = ThisParameterType<typeof toHex> // Number
```
### OmitThisParameter\<T\>
Removes the `this` parameter from a function type.
```typescript
function toHex(this: Number) {
return this.toString(16)
}
type PlainFunction = OmitThisParameter<typeof toHex>
// () => string
```
## String Manipulation
### Uppercase\<S\>
Converts string literal type to uppercase.
```typescript
type Greeting = 'hello'
type LoudGreeting = Uppercase<Greeting> // "HELLO"
// Useful for constants
type HttpMethod = 'get' | 'post' | 'put' | 'delete'
type HttpMethodUppercase = Uppercase<HttpMethod>
// "GET" | "POST" | "PUT" | "DELETE"
```
### Lowercase\<S\>
Converts string literal type to lowercase.
```typescript
type Greeting = 'HELLO'
type QuietGreeting = Lowercase<Greeting> // "hello"
```
### Capitalize\<S\>
Capitalizes the first letter of a string literal type.
```typescript
type Event = 'click' | 'scroll' | 'mousemove'
type EventHandler = `on${Capitalize<Event>}`
// "onClick" | "onScroll" | "onMousemove"
```
### Uncapitalize\<S\>
Uncapitalizes the first letter of a string literal type.
```typescript
type Greeting = 'Hello'
type LowerGreeting = Uncapitalize<Greeting> // "hello"
```
## Async Utilities
### Awaited\<T\>
Unwraps the type of a Promise (recursively).
```typescript
type T1 = Awaited<Promise<string>> // string
type T2 = Awaited<Promise<Promise<number>>> // number
type T3 = Awaited<boolean | Promise<string>> // boolean | string
// Useful with async functions
async function fetchUser() {
return { id: '123', name: 'Alice' }
}
type User = Awaited<ReturnType<typeof fetchUser>>
// { id: string; name: string }
```
## Custom Utility Types
### DeepPartial\<T\>
Makes all properties and nested properties optional.
```typescript
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
interface User {
id: string
profile: {
name: string
address: {
street: string
city: string
}
}
}
type PartialUser = DeepPartial<User>
// All properties at all levels are optional
```
### DeepReadonly\<T\>
Makes all properties and nested properties readonly.
```typescript
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
interface User {
id: string
profile: {
name: string
address: {
street: string
city: string
}
}
}
type ImmutableUser = DeepReadonly<User>
// All properties at all levels are readonly
```
### PartialBy\<T, K\>
Makes specific properties optional.
```typescript
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
interface User {
id: string
name: string
email: string
age: number
}
type UserWithOptionalEmail = PartialBy<User, 'email' | 'age'>
// {
// id: string
// name: string
// email?: string
// age?: number
// }
```
### RequiredBy\<T, K\>
Makes specific properties required.
```typescript
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
interface User {
id?: string
name?: string
email?: string
}
type UserWithRequiredId = RequiredBy<User, 'id'>
// {
// id: string
// name?: string
// email?: string
// }
```
### PickByType\<T, U\>
Picks properties by their value type.
```typescript
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
interface User {
id: string
name: string
age: number
active: boolean
}
type StringProperties = PickByType<User, string>
// { id: string; name: string }
type NumberProperties = PickByType<User, number>
// { age: number }
```
### OmitByType\<T, U\>
Omits properties by their value type.
```typescript
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K]
}
interface User {
id: string
name: string
age: number
active: boolean
}
type NonStringProperties = OmitByType<User, string>
// { age: number; active: boolean }
```
### Prettify\<T\>
Flattens intersections for better IDE tooltips.
```typescript
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
type A = { a: string }
type B = { b: number }
type C = A & B
type PrettyC = Prettify<C>
// Displays as: { a: string; b: number }
// Instead of: A & B
```
### ValueOf\<T\>
Gets the union of all value types.
```typescript
type ValueOf<T> = T[keyof T]
interface Colors {
red: '#ff0000'
green: '#00ff00'
blue: '#0000ff'
}
type ColorValue = ValueOf<Colors>
// "#ff0000" | "#00ff00" | "#0000ff"
```
### Nullable\<T\>
Makes type nullable.
```typescript
type Nullable<T> = T | null
type NullableString = Nullable<string> // string | null
```
### Maybe\<T\>
Makes type nullable or undefined.
```typescript
type Maybe<T> = T | null | undefined
type MaybeString = Maybe<string> // string | null | undefined
```
### UnionToIntersection\<U\>
Converts union to intersection (advanced).
```typescript
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never
type Union = { a: string } | { b: number }
type Intersection = UnionToIntersection<Union>
// { a: string } & { b: number }
```
## Combining Utility Types
Utility types can be composed for powerful transformations:
```typescript
// Make specific properties optional and readonly
type PartialReadonly<T, K extends keyof T> = Readonly<Pick<T, K>> &
Partial<Omit<T, K>>
interface User {
id: string
name: string
email: string
password: string
}
type SafeUser = PartialReadonly<User, 'id' | 'name'>
// {
// readonly id: string
// readonly name: string
// email?: string
// password?: string
// }
// Pick and make readonly
type ReadonlyPick<T, K extends keyof T> = Readonly<Pick<T, K>>
// Omit and make required
type RequiredOmit<T, K extends keyof T> = Required<Omit<T, K>>
```
## Best Practices
1. **Use built-in utilities first** - They're well-tested and optimized
2. **Compose utilities** - Combine utilities for complex transformations
3. **Create custom utilities** - For patterns you use frequently
4. **Name utilities clearly** - Make intent obvious from the name
5. **Document complex utilities** - Add JSDoc for non-obvious transformations
6. **Test utility types** - Use type assertions to verify behavior
7. **Avoid over-engineering** - Don't create utilities for one-off uses
8. **Consider readability** - Sometimes explicit types are clearer
9. **Use Prettify** - For better IDE tooltips with intersections
10. **Leverage keyof** - For type-safe property selection

111
CLAUDE.md Normal file
View File

@@ -0,0 +1,111 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Plebeian Signer is a browser extension for managing multiple Nostr identities and signing events without exposing private keys to web applications. It implements NIP-07 (window.nostr interface) with support for NIP-04 and NIP-44 encryption.
## Build Commands
```bash
npm ci # Install dependencies
npm run build:chrome # Build Chrome extension (outputs to dist/chrome)
npm run build:firefox # Build Firefox extension (outputs to dist/firefox)
npm run watch:chrome # Development build with watch mode for Chrome
npm run watch:firefox # Development build with watch mode for Firefox
npm test # Run unit tests with Karma
npm run lint # Run ESLint
```
**Important:** After making any code changes, rebuild both extensions before testing:
```bash
npm run build:chrome && npm run build:firefox
```
## Architecture
### Monorepo Structure
This is an Angular 19 CLI monorepo with three projects:
- **projects/chrome**: Chrome extension (MV3)
- **projects/firefox**: Firefox extension
- **projects/common**: Shared Angular library used by both extensions
### Extension Architecture
The extension follows a three-layer communication model:
1. **Content Script** (`plebian-signer-content-script.ts`): Injected into web pages, bridges messages between page scripts and the background service worker
2. **Injected Script** (`plebian-signer-extension.ts`): Injected into page context, exposes `window.nostr` API to web applications
3. **Background Service Worker** (`background.ts`): Handles NIP-07 requests, manages permissions, performs cryptographic operations
Message flow: Web App → `window.nostr` → Content Script → Background → Content Script → Web App
### Storage Layers
- **BrowserSyncHandler**: Encrypted vault data synced across browser instances (or local-only based on user preference)
- **BrowserSessionHandler**: Session-scoped decrypted data (unlocked vault state)
- **SignerMetaHandler**: Extension metadata (sync flow preference, reckless mode, whitelisted hosts)
Each browser (Chrome/Firefox) has its own handler implementations in `projects/{browser}/src/app/common/data/`.
### Vault Encryption (v2)
The vault uses Argon2id + AES-256-GCM for password-based encryption:
- **Key derivation**: Argon2id with 256MB memory, 4 threads, 8 iterations (~3 second derivation)
- **Encryption**: AES-256-GCM with random 12-byte IV per encryption
- **Salt**: Random 32-byte salt per vault (stored in `BrowserSyncData.salt`)
- The derived key is cached in session storage (`BrowserSessionData.vaultKey`) to avoid re-derivation on each operation
Note: Argon2id runs on main thread via WebAssembly (hash-wasm) because Web Workers cannot load external scripts in browser extensions due to CSP restrictions. A deriving modal provides user feedback during the ~3 second operation.
### Custom Webpack Build
Both extensions use `@angular-builders/custom-webpack` to bundle additional entry points beyond the main Angular app:
- `background.ts` - Service worker
- `plebian-signer-extension.ts` - Page-injected script
- `plebian-signer-content-script.ts` - Content script
- `prompt.ts` - Permission prompt popup
- `options.ts` - Extension options page
### Common Library
The `@common` import alias resolves to `projects/common/src/public-api.ts`. Key exports:
- `StorageService`: Central data management with encryption/decryption
- `CryptoHelper`, `NostrHelper`: Cryptographic utilities (nostr-tools based)
- `Argon2Crypto`: Vault encryption with Argon2id key derivation
- Shared Angular components and pipes
### Permission System
Permissions are stored per identity+host+method combination. The background script checks permissions before executing NIP-07 methods:
- `allow`/`deny` policies can be stored for each method
- Kind-specific permissions supported for `signEvent`
- **Reckless mode**: Auto-approves all actions without prompting (global setting)
- **Whitelisted hosts**: Auto-approves all actions from specific hosts
## Testing Extensions Locally
**Chrome:**
1. Navigate to `chrome://extensions`
2. Enable "Developer mode"
3. Click "Load unpacked"
4. Select `dist/chrome`
**Firefox:**
1. Navigate to `about:debugging`
2. Click "This Firefox"
3. Click "Load Temporary Add-on..."
4. Select a file in `dist/firefox`
## NIP-07 Methods Implemented
- `getPublicKey()` - Return public key
- `signEvent(event)` - Sign Nostr event
- `getRelays()` - Get configured relays
- `nip04.encrypt/decrypt` - NIP-04 encryption
- `nip44.encrypt/decrypt` - NIP-44 encryption

690
DDD_ANALYSIS.md Normal file
View File

@@ -0,0 +1,690 @@
# Domain-Driven Design Analysis: Plebeian Signer
This document analyzes the Plebeian Signer codebase through the lens of Domain-Driven Design (DDD) principles, identifying bounded contexts, current patterns, anti-patterns, and providing actionable recommendations for improvement.
## Executive Summary
Plebeian Signer is a browser extension for Nostr identity management implementing NIP-07. The codebase has **good structural foundations** (monorepo with shared library, handler abstraction pattern) but suffers from several DDD anti-patterns:
- **God Service**: `StorageService` handles too many responsibilities
- **Anemic Domain Models**: Types are data containers without behavior
- **Mixed Concerns**: Encryption logic interleaved with domain operations
- **Weak Ubiquitous Language**: Generic naming (`BrowserSyncData`) obscures domain concepts
**Priority Recommendations:**
1. Extract domain aggregates with behavior (Identity, Vault, Wallet)
2. Separate encryption into an infrastructure layer
3. Introduce repository pattern for each aggregate
4. Rename types to reflect ubiquitous language
---
## Domain Overview
### Core Domain Problem
> Enable users to manage multiple Nostr identities securely, sign events without exposing private keys to web applications, and interact with Lightning/Cashu wallets.
### Subdomain Classification
| Subdomain | Type | Rationale |
|-----------|------|-----------|
| **Identity & Signing** | Core | The differentiator - secure key management and NIP-07 implementation |
| **Permission Management** | Core | Critical security layer - controls what apps can do |
| **Vault Encryption** | Supporting | Necessary security but standard cryptographic patterns |
| **Wallet Integration** | Supporting | Extends functionality but not the core value proposition |
| **Profile Caching** | Generic | Standard caching pattern, could use any solution |
| **Relay Management** | Supporting | Per-identity configuration, fairly standard |
---
## Bounded Contexts
### Identified Contexts
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ CONTEXT MAP │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ Shared Kernel ┌──────────────────┐ │
│ │ Vault Context │◄─────────(crypto)──────────►│ Identity Context │ │
│ │ │ │ │ │
│ │ - VaultState │ │ - Identity │ │
│ │ - Encryption │ │ - KeyPair │ │
│ │ - Migration │ │ - Signing │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ │ Customer/Supplier │ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Permission Ctx │ │ Wallet Context │ │
│ │ │ │ │ │
│ │ - Policy │ │ - NWC │ │
│ │ - Host Rules │ │ - Cashu │ │
│ │ - Method Auth │ │ - Lightning │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Relay Context │◄──── Conformist ────────────►│ Profile Context │ │
│ │ │ │ │ │
│ │ - Per-identity │ │ - Kind 0 cache │ │
│ │ - Read/Write │ │ - Metadata │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Legend: ◄──► Bidirectional, ──► Supplier direction │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Context Definitions
#### 1. Vault Context
**Responsibility:** Secure storage lifecycle - creation, locking, unlocking, encryption, migration.
**Current Location:** `projects/common/src/lib/services/storage/related/vault.ts`
**Key Concepts:**
- VaultState (locked/unlocked)
- EncryptionKey (Argon2id-derived)
- VaultVersion (migration support)
- Salt, IV (cryptographic parameters)
**Language:**
| Term | Definition |
|------|------------|
| Vault | The encrypted container holding all sensitive data |
| Unlock | Derive key from password and decrypt vault contents |
| Lock | Clear session data, requiring password to access again |
| Migration | Upgrade vault encryption scheme (v1→v2) |
#### 2. Identity Context
**Responsibility:** Nostr identity lifecycle and cryptographic operations.
**Current Location:** `projects/common/src/lib/services/storage/related/identity.ts`
**Key Concepts:**
- Identity (aggregates pubkey, privkey, nick)
- KeyPair (hex or nsec/npub representations)
- SelectedIdentity (current active identity)
- EventSigning (NIP-07 signEvent)
**Language:**
| Term | Definition |
|------|------------|
| Identity | A Nostr keypair with a user-defined nickname |
| Selected Identity | The currently active identity for signing |
| Sign | Create schnorr signature for a Nostr event |
| Switch | Change the active identity |
#### 3. Permission Context
**Responsibility:** Authorization decisions for NIP-07 method calls.
**Current Location:** `projects/common/src/lib/services/storage/related/permission.ts`
**Key Concepts:**
- PermissionPolicy (allow/deny)
- MethodPermission (per NIP-07 method)
- KindPermission (signEvent kind filtering)
- HostWhitelist (trusted domains)
- RecklessMode (auto-approve all)
**Language:**
| Term | Definition |
|------|------------|
| Permission | A stored allow/deny decision for identity+host+method |
| Reckless Mode | Global setting to auto-approve all requests |
| Whitelist | Hosts that auto-approve without prompting |
| Prompt | UI asking user to authorize a request |
#### 4. Wallet Context
**Responsibility:** Lightning and Cashu wallet operations.
**Current Location:**
- `projects/common/src/lib/services/nwc/`
- `projects/common/src/lib/services/cashu/`
- `projects/common/src/lib/services/storage/related/nwc.ts`
- `projects/common/src/lib/services/storage/related/cashu.ts`
**Key Concepts:**
- NwcConnection (NIP-47 wallet connect)
- CashuMint (ecash mint connection)
- CashuProof (unspent tokens)
- LightningInvoice, Keysend
#### 5. Relay Context
**Responsibility:** Per-identity relay configuration.
**Current Location:** `projects/common/src/lib/services/storage/related/relay.ts`
**Key Concepts:**
- RelayConfiguration (URL + read/write permissions)
- IdentityRelays (relays scoped to an identity)
#### 6. Profile Context
**Responsibility:** Caching Nostr profile metadata (kind 0 events).
**Current Location:** `projects/common/src/lib/services/profile-metadata/`
**Key Concepts:**
- ProfileMetadata (name, picture, nip05, etc.)
- MetadataCache (fetchedAt timestamp)
---
## Current Architecture Analysis
### What's Working Well
1. **Monorepo Structure**
- Clean separation: `projects/common`, `projects/chrome`, `projects/firefox`
- Shared library via `@common` alias
- Browser-specific implementations isolated
2. **Handler Abstraction (Adapter Pattern)**
```
StorageService
├→ BrowserSessionHandler (abstract → ChromeSessionHandler, FirefoxSessionHandler)
├→ BrowserSyncHandler (abstract → ChromeSyncYesHandler, ChromeSyncNoHandler, ...)
└→ SignerMetaHandler (abstract → ChromeMetaHandler, FirefoxMetaHandler)
```
This enables pluggable browser implementations - good DDD practice.
3. **Encrypted/Decrypted Type Pairs**
- `Identity_DECRYPTED` / `Identity_ENCRYPTED`
- Clear distinction between storage states
4. **Vault Versioning**
- Migration path from v1 (PBKDF2) to v2 (Argon2id)
- Automatic upgrade on unlock
5. **Cascade Deletes**
- Deleting an identity removes associated permissions and relays
- Maintains referential integrity
### Anti-Patterns Identified
#### 1. God Service (`StorageService`)
**Location:** `projects/common/src/lib/services/storage/storage.service.ts`
**Problem:** Single service handles:
- Vault lifecycle (create, unlock, delete, migrate)
- Identity CRUD (add, delete, switch)
- Permission management
- Relay configuration
- NWC wallet connections
- Cashu mint management
- Encryption/decryption orchestration
**Symptoms:**
- 500+ lines when including bound methods
- Methods dynamically attached via functional composition
- Implicit dependencies between operations
- Difficult to test in isolation
**DDD Violation:** Violates single responsibility; should be split into aggregate-specific repositories.
#### 2. Anemic Domain Models
**Location:** `projects/common/src/lib/services/storage/types.ts`
**Problem:** All domain types are pure data containers:
```typescript
// Current: Anemic model
interface Identity_DECRYPTED {
id: string;
nick: string;
privkey: string;
createdAt: string;
}
// All behavior lives in external functions:
// - addIdentity() in identity.ts
// - switchIdentity() in identity.ts
// - encryptIdentity() in identity.ts
```
**Should Be:**
```typescript
// Rich domain model
class Identity {
private constructor(
private readonly _id: IdentityId,
private _nick: Nickname,
private readonly _keyPair: NostrKeyPair,
private readonly _createdAt: Date
) {}
static create(nick: string, privateKey?: string): Identity { /* ... */ }
get publicKey(): string { return this._keyPair.publicKey; }
sign(event: UnsignedEvent): SignedEvent {
return this._keyPair.sign(event);
}
rename(newNick: string): void {
this._nick = Nickname.create(newNick);
}
}
```
#### 3. Mixed Encryption Concerns
**Problem:** Domain operations and encryption logic are interleaved:
```typescript
// In identity.ts
export async function addIdentity(this: StorageService, data: {...}) {
// Domain logic
const identity_decrypted: Identity_DECRYPTED = {
id: uuid(),
nick: data.nick,
privkey: data.privkeyString,
createdAt: new Date().toISOString(),
};
// Encryption concern mixed in
const identity_encrypted = await encryptIdentity.call(this, identity_decrypted);
// Storage concern
await this.#browserSyncHandler.addIdentity(identity_encrypted);
this.#browserSessionHandler.addIdentity(identity_decrypted);
}
```
**Should Be:** Encryption as infrastructure layer, repositories handle persistence:
```typescript
class IdentityRepository {
async save(identity: Identity): Promise<void> {
const encrypted = this.encryptionService.encrypt(identity.toSnapshot());
await this.syncHandler.save(encrypted);
this.sessionHandler.cache(identity);
}
}
```
#### 4. Weak Ubiquitous Language
**Problem:** Type names reflect technical storage, not domain concepts:
| Current Name | Domain Concept |
|--------------|----------------|
| `BrowserSyncData` | `EncryptedVault` |
| `BrowserSessionData` | `UnlockedVaultState` |
| `SignerMetaData` | `ExtensionSettings` |
| `Identity_DECRYPTED` | `Identity` |
| `Identity_ENCRYPTED` | `EncryptedIdentity` |
#### 5. Implicit Aggregate Boundaries
**Problem:** No clear aggregate roots. External code can manipulate any data:
```typescript
// Anyone can reach into session data
const identity = this.#browserSessionHandler.getIdentity(id);
identity.nick = "changed"; // No invariant protection!
```
**Should Have:** Aggregate roots as single entry points with invariant protection.
#### 6. TypeScript Union Type Issues
**Problem:** `LockedVaultContext` uses optional fields instead of discriminated unions:
```typescript
// Current: Confusing optional fields
type LockedVaultContext =
| { iv: string; password: string; keyBase64?: undefined }
| { iv: string; keyBase64: string; password?: undefined };
// Better: Discriminated union
type LockedVaultContext =
| { version: 1; iv: string; password: string }
| { version: 2; iv: string; keyBase64: string };
```
---
## Recommended Domain Model
### Aggregate Design
```
┌─────────────────────────────────────────────────────────────────────┐
│ AGGREGATE MAP │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Vault Aggregate (Root: Vault) │ │
│ │ │ │
│ │ Vault ──────┬──► Identity[] (child entities) │ │
│ │ ├──► Permission[] (child entities) │ │
│ │ ├──► Relay[] (child entities) │ │
│ │ ├──► NwcConnection[] (child entities) │ │
│ │ └──► CashuMint[] (child entities) │ │
│ │ │ │
│ │ Invariants: │ │
│ │ - At most one identity can be selected │ │
│ │ - Permissions must reference existing identities │ │
│ │ - Relays must reference existing identities │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ExtensionSettings Aggregate (Root: ExtensionSettings) │ │
│ │ │ │
│ │ ExtensionSettings ──┬──► SyncPreference │ │
│ │ ├──► SecurityPolicy (reckless, whitelist)│ │
│ │ ├──► Bookmark[] │ │
│ │ └──► VaultSnapshot[] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ProfileCache Aggregate (Root: ProfileCache) │ │
│ │ │ │
│ │ ProfileCache ──► ProfileMetadata[] │ │
│ │ │ │
│ │ Invariants: │ │
│ │ - Entries expire after TTL │ │
│ │ - One entry per pubkey │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Value Objects
```typescript
// Strongly-typed identity
class IdentityId {
private constructor(private readonly value: string) {}
static generate(): IdentityId { return new IdentityId(uuid()); }
static from(value: string): IdentityId { return new IdentityId(value); }
equals(other: IdentityId): boolean { return this.value === other.value; }
toString(): string { return this.value; }
}
// Self-validating nickname
class Nickname {
private constructor(private readonly value: string) {}
static create(value: string): Nickname {
if (!value || value.trim().length === 0) {
throw new InvalidNicknameError(value);
}
return new Nickname(value.trim());
}
toString(): string { return this.value; }
}
// Nostr key pair encapsulation
class NostrKeyPair {
private constructor(
private readonly privateKeyHex: string,
private readonly publicKeyHex: string
) {}
static fromPrivateKey(privkey: string): NostrKeyPair {
const hex = privkey.startsWith('nsec')
? NostrHelper.nsecToHex(privkey)
: privkey;
const pubkey = NostrHelper.pubkeyFromPrivkey(hex);
return new NostrKeyPair(hex, pubkey);
}
get publicKey(): string { return this.publicKeyHex; }
get npub(): string { return NostrHelper.pubkey2npub(this.publicKeyHex); }
sign(event: UnsignedEvent): SignedEvent {
return NostrHelper.signEvent(event, this.privateKeyHex);
}
encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string {
return version === 4
? NostrHelper.nip04Encrypt(plaintext, this.privateKeyHex, recipientPubkey)
: NostrHelper.nip44Encrypt(plaintext, this.privateKeyHex, recipientPubkey);
}
}
// Permission policy
class PermissionPolicy {
private constructor(
private readonly identityId: IdentityId,
private readonly host: string,
private readonly method: Nip07Method,
private readonly decision: 'allow' | 'deny',
private readonly kind?: number
) {}
static allow(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy {
return new PermissionPolicy(identityId, host, method, 'allow', kind);
}
static deny(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy {
return new PermissionPolicy(identityId, host, method, 'deny', kind);
}
matches(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): boolean {
return this.identityId.equals(identityId)
&& this.host === host
&& this.method === method
&& (this.kind === undefined || this.kind === kind);
}
isAllowed(): boolean { return this.decision === 'allow'; }
}
```
### Rich Domain Entities
```typescript
class Identity {
private readonly _id: IdentityId;
private _nickname: Nickname;
private readonly _keyPair: NostrKeyPair;
private readonly _createdAt: Date;
private _domainEvents: DomainEvent[] = [];
private constructor(
id: IdentityId,
nickname: Nickname,
keyPair: NostrKeyPair,
createdAt: Date
) {
this._id = id;
this._nickname = nickname;
this._keyPair = keyPair;
this._createdAt = createdAt;
}
static create(nickname: string, privateKey?: string): Identity {
const keyPair = privateKey
? NostrKeyPair.fromPrivateKey(privateKey)
: NostrKeyPair.generate();
const identity = new Identity(
IdentityId.generate(),
Nickname.create(nickname),
keyPair,
new Date()
);
identity._domainEvents.push(new IdentityCreated(identity._id, identity.publicKey));
return identity;
}
get id(): IdentityId { return this._id; }
get publicKey(): string { return this._keyPair.publicKey; }
get npub(): string { return this._keyPair.npub; }
get nickname(): string { return this._nickname.toString(); }
rename(newNickname: string): void {
const oldNickname = this._nickname.toString();
this._nickname = Nickname.create(newNickname);
this._domainEvents.push(new IdentityRenamed(this._id, oldNickname, newNickname));
}
sign(event: UnsignedEvent): SignedEvent {
return this._keyPair.sign(event);
}
encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string {
return this._keyPair.encrypt(plaintext, recipientPubkey, version);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
```
---
## Refactoring Roadmap
### Phase 1: Extract Value Objects (Low Risk)
**Goal:** Introduce type safety without changing behavior.
1. Create `IdentityId`, `Nickname`, `NostrKeyPair` value objects
2. Use them in existing interfaces initially
3. Add validation in factory methods
4. Update helpers to use value objects
**Files to Modify:**
- Create `projects/common/src/lib/domain/value-objects/`
- Update `projects/common/src/lib/helpers/nostr-helper.ts`
### Phase 2: Introduce Repository Pattern (Medium Risk)
**Goal:** Separate storage concerns from domain logic.
1. Define repository interfaces in domain layer
2. Create `IdentityRepository`, `PermissionRepository`, etc.
3. Move encryption to `EncryptionService` infrastructure
4. Refactor `StorageService` to delegate to repositories
**New Structure:**
```
projects/common/src/lib/
├── domain/
│ ├── identity/
│ │ ├── Identity.ts
│ │ ├── IdentityRepository.ts (interface)
│ │ └── events/
│ ├── permission/
│ │ ├── PermissionPolicy.ts
│ │ └── PermissionRepository.ts (interface)
│ └── vault/
│ ├── Vault.ts
│ └── VaultRepository.ts (interface)
├── infrastructure/
│ ├── encryption/
│ │ └── EncryptionService.ts
│ └── persistence/
│ ├── ChromeIdentityRepository.ts
│ └── FirefoxIdentityRepository.ts
└── application/
├── IdentityApplicationService.ts
└── VaultApplicationService.ts
```
### Phase 3: Rich Domain Model (Higher Risk)
**Goal:** Move behavior into domain entities.
1. Convert `Identity_DECRYPTED` interface to `Identity` class
2. Move signing logic into `Identity.sign()`
3. Move encryption decision logic into domain
4. Add domain events for state changes
### Phase 4: Ubiquitous Language Cleanup
**Goal:** Align code with domain language.
| Old Name | New Name |
|----------|----------|
| `BrowserSyncData` | `EncryptedVault` |
| `BrowserSessionData` | `VaultSession` |
| `SignerMetaData` | `ExtensionSettings` |
| `StorageService` | `VaultService` (or split into multiple) |
| `addIdentity()` | `Identity.create()` + `IdentityRepository.save()` |
| `switchIdentity()` | `Vault.selectIdentity()` |
---
## Implementation Priorities
### High Priority (Security/Correctness)
1. **Encapsulate KeyPair operations** - Private keys should never be accessed directly
2. **Enforce invariants** - Selected identity must exist, permissions must reference valid identities
3. **Clear transaction boundaries** - What gets saved together?
### Medium Priority (Maintainability)
1. **Split StorageService** - Into VaultService, IdentityRepository, PermissionRepository
2. **Extract EncryptionService** - Pure infrastructure concern
3. **Type-safe IDs** - Prevent mixing up identity IDs with permission IDs
### Lower Priority (Polish)
1. **Domain events** - For audit trail and extensibility
2. **Full ubiquitous language** - Rename all types
3. **Discriminated unions** - For vault context types
---
## Testing Implications
Current state makes testing difficult because:
- `StorageService` requires mocking 4 handlers
- Encryption is interleaved with logic
- No clear boundaries to test in isolation
With proposed changes:
- Domain entities testable in isolation (no storage mocks)
- Repositories testable with in-memory implementations
- Clear separation enables focused unit tests
```typescript
// Example: Testing Identity domain logic
describe('Identity', () => {
it('signs events with internal keypair', () => {
const identity = Identity.create('Test', 'nsec1...');
const event = { kind: 1, content: 'test', /* ... */ };
const signed = identity.sign(event);
expect(signed.sig).toBeDefined();
expect(signed.pubkey).toBe(identity.publicKey);
});
it('prevents duplicate private keys via repository', async () => {
const repository = new InMemoryIdentityRepository();
const existing = Identity.create('First', 'nsec1abc...');
await repository.save(existing);
const duplicate = Identity.create('Second', 'nsec1abc...');
await expect(repository.save(duplicate))
.rejects.toThrow(DuplicateIdentityError);
});
});
```
---
## Conclusion
The Plebeian Signer codebase has solid foundations but would benefit significantly from DDD tactical patterns. The recommended approach:
1. **Start with value objects** - Low risk, immediate type safety benefits
2. **Introduce repositories gradually** - Extract one at a time, starting with Identity
3. **Defer full rich domain model** - Until repositories stabilize the architecture
4. **Update language as you go** - Rename types when touching files anyway
The goal is not architectural purity but **maintainability, testability, and security**. DDD patterns are a means to those ends in a domain (cryptographic identity management) where correctness matters.

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

722
NWC-IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,722 @@
# Nostr Wallet Connect (NWC) Implementation Guide
This document provides guidance for implementing NIP-47 (Nostr Wallet Connect) support in the Plebeian Signer browser extension.
## What is Nostr Wallet Connect?
Nostr Wallet Connect (NWC), defined in [NIP-47](https://nips.nostr.com/47), is a protocol that enables Nostr applications to interact with Lightning wallets through encrypted messages over Nostr relays. It allows apps to:
- Request payments (pay invoices, keysend)
- Create invoices
- Check wallet balance
- List transactions
- Receive payment notifications
The key benefit is that users can connect their Lightning wallet once and authorize apps to make payments without requiring manual approval for each transaction (within configured limits).
## Wallets Supporting NWC
### Self-Custodial Wallets
| Wallet | Description | Link |
|--------|-------------|------|
| **Alby Hub** | Self-custodial Lightning node with seamless NWC service | [getalby.com](https://getalby.com) |
| **Phoenix** | Popular self-custodial Lightning wallet (via Phoenixd) | [phoenix.acinq.co](https://phoenix.acinq.co) |
| **Electrum** | Legendary on-chain and Lightning wallet | [electrum.org](https://electrum.org) |
| **Flash Wallet** | Self-custodial wallet built on Breez SDK | [paywithflash.com](https://paywithflash.com) |
| **Blitz** | Self-custodial wallet supporting Spark and Lightning | - |
| **LNbits** | Powerful suite of Bitcoin tools with NWC plugin | [lnbits.com](https://lnbits.com) |
| **Minibits** | Ecash wallet with focus on performance | [minibits.cash](https://minibits.cash) |
| **Cashu.me** | Ecash-based Cashu PWA wallet | [cashu.me](https://cashu.me) |
### Custodial Wallets
| Wallet | Description |
|--------|-------------|
| **Primal Wallet** | Integrated with Primal Nostr clients |
| **Coinos** | Free custodial web wallet and payment page |
| **Bitvora** | Custodial wallet and Bitcoin Lightning API platform |
| **Orange Pill App** | Social app with integrated custodial wallet |
### Node Software with NWC Support
- **Umbrel** - NWC app available in official marketplace
- **Start9** - Embassy OS with NWC support
## NIP-47 Protocol Specification
### Event Kinds
| Kind | Purpose | Encrypted |
|------|---------|-----------|
| 13194 | Wallet Info (capabilities) | No |
| 23194 | Client Request | Yes |
| 23195 | Wallet Response | Yes |
| 23197 | Notifications (NIP-44) | Yes |
| 23196 | Notifications (NIP-04 legacy) | Yes |
### Connection URI Format
```
nostr+walletconnect://<wallet_pubkey>?relay=<relay_url>&secret=<client_secret>&lud16=<optional_lightning_address>
```
**Example:**
```
nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c
```
Components:
- **wallet_pubkey**: The wallet service's public key (used for encryption)
- **relay**: Nostr relay URL for communication (URL-encoded)
- **secret**: 32-byte hex client secret key for signing requests
- **lud16**: Optional Lightning address for the wallet
### Supported Methods
| Method | Description |
|--------|-------------|
| `get_info` | Get wallet info and supported methods |
| `get_balance` | Get wallet balance in millisatoshis |
| `pay_invoice` | Pay a BOLT11 Lightning invoice |
| `multi_pay_invoice` | Pay multiple invoices in batch |
| `pay_keysend` | Send spontaneous payment to a pubkey |
| `multi_pay_keysend` | Batch keysend payments |
| `make_invoice` | Create a new Lightning invoice |
| `lookup_invoice` | Look up invoice status |
| `list_transactions` | Get transaction history |
### Error Codes
| Code | Description |
|------|-------------|
| `RATE_LIMITED` | Too many requests |
| `NOT_IMPLEMENTED` | Method not supported |
| `INSUFFICIENT_BALANCE` | Not enough funds |
| `QUOTA_EXCEEDED` | Spending limit reached |
| `RESTRICTED` | Operation not allowed for this connection |
| `UNAUTHORIZED` | No wallet connected for this pubkey |
| `INTERNAL` | Internal wallet error |
| `UNSUPPORTED_ENCRYPTION` | Encryption method not supported |
| `OTHER` | Generic error |
### Encryption
NWC supports two encryption methods:
- **NIP-44 v2** (preferred): Modern, secure encryption
- **NIP-04** (deprecated): Legacy support only
The wallet advertises supported encryption in the info event (kind 13194). Clients should prefer NIP-44 when available.
## Request/Response Formats
### Request Structure
```json
{
"method": "method_name",
"params": {
// method-specific parameters
}
}
```
### Response Structure
```json
{
"result_type": "method_name",
"error": {
"code": "ERROR_CODE",
"message": "Human readable message"
},
"result": {
// method-specific response
}
}
```
### Method Examples
#### get_info
**Request:**
```json
{
"method": "get_info",
"params": {}
}
```
**Response:**
```json
{
"result_type": "get_info",
"result": {
"alias": "My Wallet",
"color": "#ff9900",
"pubkey": "03abcdef...",
"network": "mainnet",
"block_height": 800000,
"methods": ["pay_invoice", "get_balance", "make_invoice"],
"notifications": ["payment_received", "payment_sent"]
}
}
```
#### get_balance
**Request:**
```json
{
"method": "get_balance",
"params": {}
}
```
**Response:**
```json
{
"result_type": "get_balance",
"result": {
"balance": 100000000
}
}
```
Balance is in **millisatoshis** (1 sat = 1000 msats).
#### pay_invoice
**Request:**
```json
{
"method": "pay_invoice",
"params": {
"invoice": "lnbc50n1p3...",
"amount": 5000,
"metadata": {
"comment": "Payment for coffee"
}
}
}
```
**Response:**
```json
{
"result_type": "pay_invoice",
"result": {
"preimage": "0123456789abcdef...",
"fees_paid": 10
}
}
```
#### make_invoice
**Request:**
```json
{
"method": "make_invoice",
"params": {
"amount": 21000,
"description": "Donation",
"expiry": 3600
}
}
```
**Response:**
```json
{
"result_type": "make_invoice",
"result": {
"type": "incoming",
"state": "pending",
"invoice": "lnbc210n1p3...",
"payment_hash": "abc123...",
"amount": 21000,
"created_at": 1700000000,
"expires_at": 1700003600
}
}
```
## Implementation for Browser Extension
### Architecture Overview
For Plebeian Signer, NWC support would add a new capability alongside NIP-07:
```
Web App
window.nostr.nwc.* (new NWC methods)
Content Script
Background Service Worker
Nostr Relay (WebSocket)
Lightning Wallet Service
```
### Recommended SDK
Use the **Alby JS SDK** for NWC client implementation:
```bash
npm install @getalby/sdk
```
### Code Examples
#### Basic NWC Client Setup
```typescript
import { nwc } from '@getalby/sdk';
// Parse and validate NWC connection URL
function parseNwcUrl(url: string): {
walletPubkey: string;
relayUrl: string;
secret: string;
lud16?: string;
} {
const parsed = new URL(url);
if (parsed.protocol !== 'nostr+walletconnect:') {
throw new Error('Invalid NWC URL protocol');
}
return {
walletPubkey: parsed.hostname || parsed.pathname.replace('//', ''),
relayUrl: decodeURIComponent(parsed.searchParams.get('relay') || ''),
secret: parsed.searchParams.get('secret') || '',
lud16: parsed.searchParams.get('lud16') || undefined,
};
}
// Create NWC client from connection URL
async function createNwcClient(connectionUrl: string) {
const client = new nwc.NWCClient({
nostrWalletConnectUrl: connectionUrl,
});
return client;
}
```
#### Implementing NWC Methods
```typescript
import { nwc } from '@getalby/sdk';
class NwcService {
private client: nwc.NWCClient | null = null;
async connect(connectionUrl: string): Promise<void> {
this.client = new nwc.NWCClient({
nostrWalletConnectUrl: connectionUrl,
});
}
async getInfo(): Promise<any> {
if (!this.client) throw new Error('Not connected');
return await this.client.getInfo();
}
async getBalance(): Promise<{ balance: number }> {
if (!this.client) throw new Error('Not connected');
return await this.client.getBalance();
}
async payInvoice(invoice: string, amount?: number): Promise<{
preimage: string;
fees_paid?: number;
}> {
if (!this.client) throw new Error('Not connected');
return await this.client.payInvoice({
invoice,
amount,
});
}
async makeInvoice(params: {
amount: number;
description?: string;
expiry?: number;
}): Promise<{
invoice: string;
payment_hash: string;
}> {
if (!this.client) throw new Error('Not connected');
return await this.client.makeInvoice(params);
}
async lookupInvoice(params: {
payment_hash?: string;
invoice?: string;
}): Promise<any> {
if (!this.client) throw new Error('Not connected');
return await this.client.lookupInvoice(params);
}
async listTransactions(params?: {
from?: number;
until?: number;
limit?: number;
offset?: number;
type?: 'incoming' | 'outgoing';
}): Promise<any> {
if (!this.client) throw new Error('Not connected');
return await this.client.listTransactions(params);
}
disconnect(): void {
// Clean up WebSocket connection
this.client = null;
}
}
```
#### Manual Implementation (Without SDK)
If you prefer not to use the SDK, here's how to implement NWC manually using your existing Nostr helpers:
```typescript
import { NostrHelper, CryptoHelper } from '@common';
interface NwcConnection {
walletPubkey: string;
relayUrl: string;
secret: string; // Client's private key for this connection
}
class ManualNwcClient {
private connection: NwcConnection;
private ws: WebSocket | null = null;
private pendingRequests: Map<string, {
resolve: (value: any) => void;
reject: (error: any) => void;
}> = new Map();
constructor(connectionUrl: string) {
this.connection = this.parseConnectionUrl(connectionUrl);
}
private parseConnectionUrl(url: string): NwcConnection {
const parsed = new URL(url);
return {
walletPubkey: parsed.hostname || parsed.pathname.replace('//', ''),
relayUrl: decodeURIComponent(parsed.searchParams.get('relay') || ''),
secret: parsed.searchParams.get('secret') || '',
};
}
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.connection.relayUrl);
this.ws.onopen = () => {
// Subscribe to responses from the wallet
const clientPubkey = NostrHelper.getPublicKey(this.connection.secret);
const subId = CryptoHelper.randomHex(16);
this.ws!.send(JSON.stringify([
'REQ',
subId,
{
kinds: [23195], // Response events
'#p': [clientPubkey],
since: Math.floor(Date.now() / 1000) - 60,
}
]));
resolve();
};
this.ws.onerror = (error) => reject(error);
this.ws.onmessage = (event) => {
this.handleMessage(JSON.parse(event.data));
};
});
}
private handleMessage(message: any[]): void {
if (message[0] === 'EVENT') {
const event = message[2];
this.handleResponseEvent(event);
}
}
private async handleResponseEvent(event: any): Promise<void> {
// Decrypt the response using NIP-44 (preferred) or NIP-04
const decrypted = await CryptoHelper.nip44Decrypt(
this.connection.secret,
this.connection.walletPubkey,
event.content
);
const response = JSON.parse(decrypted);
// Find the original request by event ID from 'e' tag
const requestId = event.tags.find((t: string[]) => t[0] === 'e')?.[1];
if (requestId && this.pendingRequests.has(requestId)) {
const { resolve, reject } = this.pendingRequests.get(requestId)!;
this.pendingRequests.delete(requestId);
if (response.error) {
reject(new Error(`${response.error.code}: ${response.error.message}`));
} else {
resolve(response.result);
}
}
}
async sendRequest(method: string, params: any = {}): Promise<any> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected');
}
const clientPubkey = NostrHelper.getPublicKey(this.connection.secret);
// Create the request payload
const payload = JSON.stringify({ method, params });
// Encrypt with NIP-44
const encrypted = await CryptoHelper.nip44Encrypt(
this.connection.secret,
this.connection.walletPubkey,
payload
);
// Create the request event
const event = {
kind: 23194,
pubkey: clientPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', this.connection.walletPubkey],
['encryption', 'nip44_v2'],
],
content: encrypted,
};
// Sign the event
const signedEvent = await NostrHelper.signEvent(event, this.connection.secret);
// Send to relay
this.ws.send(JSON.stringify(['EVENT', signedEvent]));
// Return promise that resolves when we get response
return new Promise((resolve, reject) => {
this.pendingRequests.set(signedEvent.id, { resolve, reject });
// Timeout after 30 seconds
setTimeout(() => {
if (this.pendingRequests.has(signedEvent.id)) {
this.pendingRequests.delete(signedEvent.id);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
// Convenience methods
async getInfo() {
return this.sendRequest('get_info');
}
async getBalance() {
return this.sendRequest('get_balance');
}
async payInvoice(invoice: string, amount?: number) {
return this.sendRequest('pay_invoice', { invoice, amount });
}
async makeInvoice(amount: number, description?: string, expiry?: number) {
return this.sendRequest('make_invoice', { amount, description, expiry });
}
disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.pendingRequests.clear();
}
}
```
### Exposing NWC via window.nostr
Extend the existing `window.nostr` interface:
```typescript
// In plebian-signer-extension.ts
interface WindowNostr {
// Existing NIP-07 methods
getPublicKey(): Promise<string>;
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
getRelays(): Promise<Record<string, RelayPolicy>>;
nip04: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
nip44: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
// New NWC methods
nwc?: {
isEnabled(): Promise<boolean>;
getInfo(): Promise<NwcInfo>;
getBalance(): Promise<{ balance: number }>;
payInvoice(invoice: string, amount?: number): Promise<{ preimage: string }>;
makeInvoice(params: MakeInvoiceParams): Promise<InvoiceResult>;
lookupInvoice(params: LookupParams): Promise<InvoiceResult>;
listTransactions(params?: ListParams): Promise<Transaction[]>;
};
}
```
### Storage Considerations
Store NWC connection details securely:
```typescript
interface NwcConnectionData {
id: string;
name: string; // User-friendly name
connectionUrl: string; // Full nostr+walletconnect:// URL
walletPubkey: string; // Extracted for quick access
relayUrl: string; // Extracted for quick access
createdAt: number;
lastUsed?: number;
// Optional spending limits (enforced client-side as additional protection)
maxSinglePayment?: number; // Max per-payment in sats
dailyLimit?: number; // Max daily spending in sats
dailySpent?: number; // Track daily spending
dailyResetAt?: number; // When to reset daily counter
}
// Store in encrypted vault alongside identities
interface VaultData {
identities: Identity[];
nwcConnections: NwcConnectionData[];
}
```
### Permission Flow
When a web app requests NWC operations:
1. **Check if NWC is configured**: Show setup prompt if not
2. **Validate the request**: Check method, params, and spending limits
3. **Prompt for approval**: Show payment details for `pay_invoice`
4. **Execute and respond**: Send to wallet and return result
```typescript
// Permission levels
enum NwcPermission {
READ_ONLY = 'read_only', // get_info, get_balance, lookup, list
RECEIVE = 'receive', // + make_invoice
SEND = 'send', // + pay_invoice, pay_keysend (requires approval)
AUTO_PAY = 'auto_pay', // + automatic payments within limits
}
interface NwcSitePermission {
origin: string;
permission: NwcPermission;
autoPayLimit?: number; // Auto-approve payments under this amount
}
```
## UI Components Needed
### Connection Setup Page
- Input field for NWC connection URL (or QR scanner)
- Parse and display connection details
- Test connection button
- Save connection
### Wallet Dashboard
- Display connected wallet info
- Show current balance
- Transaction history
- Spending statistics
### Payment Approval Prompt
- Invoice amount and description
- Recipient info if available
- Fee estimate
- Approve/Reject buttons
- "Remember for this site" option
## Security Considerations
1. **Store secrets securely**: NWC secrets should be encrypted in the vault like private keys
2. **Validate all inputs**: Sanitize invoice strings, validate amounts
3. **Implement spending limits**: Add client-side limits as defense in depth
4. **Audit trail**: Log all NWC operations for user review
5. **Clear error handling**: Never expose raw errors to web pages
6. **Connection isolation**: Each site should not see other sites' NWC activity
## Testing
### Test Wallets
1. **Alby Hub** - Full NWC support, easy setup: [getalby.com](https://getalby.com)
2. **LNbits** - Self-hosted, great for testing: [lnbits.com](https://lnbits.com)
3. **Coinos** - Custodial, quick signup: [coinos.io](https://coinos.io)
### Test Scenarios
- [ ] Parse valid NWC URLs
- [ ] Reject invalid NWC URLs
- [ ] Connect to relay successfully
- [ ] Get wallet info
- [ ] Get balance
- [ ] Create invoice
- [ ] Pay invoice (testnet/small amount)
- [ ] Handle connection errors gracefully
- [ ] Handle wallet errors gracefully
- [ ] Enforce spending limits
- [ ] Permission prompts work correctly
## Resources
### Official Documentation
- [NIP-47 Specification](https://nips.nostr.com/47)
- [NIP-47 on GitHub](https://github.com/nostr-protocol/nips/blob/master/47.md)
- [NWC Developer Portal](https://nwc.dev)
- [NWC Documentation](https://docs.nwc.dev)
### SDKs and Libraries
- [Alby JS SDK](https://github.com/getAlby/js-sdk) - Recommended for browser extensions
- [Alby SDK on npm](https://www.npmjs.com/package/@getalby/sdk)
- [Alby Developer Guide](https://guides.getalby.com/developer-guide/nostr-wallet-connect-api)
### Example Implementations
- [Alby NWC Examples](https://github.com/getAlby/js-sdk/tree/master/examples/nwc)
- [Alby Browser Extension](https://github.com/getAlby/lightning-browser-extension)
- [Awesome NWC List](https://github.com/getAlby/awesome-nwc)
### Community
- [NWC Discord](https://discord.gg/PRhQPZCmeF)
- [Nostr Protocol GitHub](https://github.com/nostr-protocol)

68
PRIVACY_POLICY.md Normal file
View File

@@ -0,0 +1,68 @@
# Privacy Policy
**Plebeian Signer** is a browser extension for managing Nostr identities and signing events. This privacy policy explains how the extension handles your data.
## Data Collection
**Plebeian Signer does not collect, store, or transmit any user data to external servers.**
All data remains on your device under your control.
## Data Storage
The extension stores the following data locally in your browser:
- **Encrypted vault**: Your Nostr private keys, encrypted with your password using Argon2id + AES-256-GCM
- **Identity metadata**: Display names, profile information you configure
- **Permissions**: Your allow/deny decisions for websites
- **Cashu wallet data**: Mint connections and ecash tokens you store
- **Preferences**: Extension settings (sync mode, reckless mode, etc.)
This data is stored using your browser's built-in storage APIs and never leaves your device unless you enable browser sync (in which case it syncs through your browser's own sync service, not ours).
## External Connections
The extension only makes external network requests in the following cases:
1. **Cashu mints**: When you explicitly add a Cashu mint and perform wallet operations (deposit, send, receive), the extension connects to that mint's URL. You choose which mints to connect to.
2. **No other external connections**: The extension does not connect to any analytics services, tracking pixels, telemetry endpoints, or any servers operated by the developers.
## Third-Party Services
Plebeian Signer does not integrate with any third-party services. The only external services involved are:
- **Cashu mints**: User-configured ecash mints for wallet functionality
- **Browser sync** (optional): Your browser's native sync service if you enable vault syncing
## Data Sharing
We do not share any data because we do not have access to any data. Your private keys and all extension data remain encrypted on your device.
## Security
- Private keys are encrypted at rest using Argon2id key derivation and AES-256-GCM encryption
- Keys are never exposed to websites — only signatures are provided
- The vault locks automatically and requires your password to unlock
## Your Rights
Since all data is stored locally on your device:
- **Access**: View your data anytime in the extension
- **Delete**: Uninstall the extension or clear browser data to remove all stored data
- **Export**: Use the extension's export features to backup your data
## Changes to This Policy
Any changes to this privacy policy will be reflected in the extension's repository and release notes.
## Contact
For questions about this privacy policy, please open an issue at the project repository.
---
**Last updated**: January 2026
**Extension**: Plebeian Signer v1.1.5

View File

@@ -1,8 +1,8 @@
# Gooti
# Plebeian Signer
## Nostr Identity Manager & Signer
Gooti is a browser extension for managing multiple [Nostr](https://github.com/nostr-protocol/nostr) identities and for signing events on web apps without having to give them your keys.
Plebeian Signer is a browser extension for managing multiple [Nostr](https://github.com/nostr-protocol/nostr) identities and for signing events on web apps without having to give them your keys.
It implements these mandatory [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md) methods:
@@ -21,19 +21,15 @@ async window.nostr.nip44.encrypt(pubkey, plaintext): string
async window.nostr.nip44.decrypt(pubkey, ciphertext): string
```
The repository is configured as monorepo to hold the extensions for Chrome and Firefox.
[Get the Firefox extension here!](https://addons.mozilla.org/en-US/firefox/addon/gooti/)
[Get the Chrome extension here!](https://chromewebstore.google.com/detail/gooti/cpcnmacmpalecmijkbcajanpdlcgjpgj)
The repository is configured as monorepo to hold the extensions for Chrome and Firefox.
## Develop Chrome Extension
To build and run the Chrome extension from this code:
```
git clone https://github.com/sam-hayes-org/gooti-extension
cd gooti-extension
git clone https://github.com/PlebeianApp/plebeian-signer.git
cd plebeian-signer
npm ci
npm run build:chrome
```
@@ -50,8 +46,8 @@ then
To build and run the Firefox extension from this code:
```
git clone https://github.com/sam-hayes-org/gooti-extension
cd gooti-extension
git clone https://github.com/PlebeianApp/plebeian-signer.git
cd plebeian-signer
npm ci
npm run build:firefox
```
@@ -65,4 +61,4 @@ then
---
LICENSE: Public Domain
LICENSE: Public Domain

View File

@@ -3,7 +3,10 @@
"version": 1,
"newProjectRoot": "projects",
"cli": {
"schematicCollections": ["angular-eslint"]
"schematicCollections": [
"angular-eslint"
],
"analytics": false
},
"projects": {
"chrome": {
@@ -48,8 +51,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "20kB",
"maximumError": "25kB"
}
],
"optimization": {
@@ -151,8 +154,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "20kB",
"maximumError": "25kB"
}
],
"optimization": {

View File

@@ -1,6 +1,7 @@
#!/bin/bash
version=$( cat package.json | jq '.custom.chrome.version' | tr -d '"')
# Extract version and strip 'v' prefix if present (manifest requires bare semver)
version=$( cat package.json | jq -r '.custom.chrome.version' | sed 's/^v//')
jq '.version = $newVersion' --arg newVersion $version ./projects/chrome/public/manifest.json > ./projects/chrome/public/tmp.manifest.json && mv ./projects/chrome/public/tmp.manifest.json ./projects/chrome/public/manifest.json

View File

@@ -0,0 +1,112 @@
# Plebeian Signer Privacy Policy
**Last Updated:** December 20, 2025
## Overview
Plebeian Signer is a browser extension for managing Nostr identities and signing cryptographic events. This privacy policy explains how we handle your data.
## Data Collection
**We do not collect any personal data.**
Plebeian Signer operates entirely locally within your browser. We do not:
- Collect analytics or telemetry
- Track your usage or behavior
- Send your data to any external servers
- Use cookies or tracking technologies
- Share any information with third parties
## Data Storage
All data is stored locally in your browser using the browser's built-in storage APIs:
### What We Store Locally
1. **Encrypted Vault Data**
- Your Nostr private keys (encrypted with Argon2id + AES-256-GCM)
- Identity nicknames and metadata
- Relay configurations
- Site permissions
2. **Session Data**
- Temporary decryption keys (cleared when browser closes or vault locks)
- Cached profile metadata
3. **Extension Settings**
- Sync preferences
- Reckless mode settings
- Whitelisted hosts
### Encryption
Your private keys are never stored in plaintext. The vault uses:
- **Argon2id** for password-based key derivation (256MB memory, 4 threads, 8 iterations)
- **AES-256-GCM** for authenticated encryption
- **Random salt and IV** generated for each vault
## Network Communications
Plebeian Signer makes the following network requests:
1. **Nostr Relay Connections**
- To fetch your profile metadata (kind 0 events)
- To fetch relay lists (kind 10002 events)
- Only connects to relays you have configured
2. **NIP-05 Verification**
- Fetches `.well-known/nostr.json` from domains in NIP-05 identifiers
- Used only to verify identity claims
**We do not operate any servers.** All relay connections are made directly to the Nostr network.
## Permissions Explained
The extension requests these browser permissions:
- **`storage`**: To save your encrypted vault and settings
- **`activeTab`**: To inject the NIP-07 interface into web pages
- **`scripting`**: To enable communication between pages and the extension
## Data Sharing
We do not share any data with third parties. The extension:
- Has no backend servers
- Does not use analytics services
- Does not include advertising
- Does not sell or monetize your data in any way
## Your Control
You have full control over your data:
- **Export**: You can export your encrypted vault at any time
- **Delete**: Use the "Reset Extension" feature to delete all local data
- **Lock**: Lock your vault to clear session data immediately
## Open Source
Plebeian Signer is open source software. You can audit the code yourself:
- Repository: https://github.com/PlebeianApp/plebeian-signer
## Children's Privacy
This extension is not intended for children under 13 years of age. We do not knowingly collect any information from children.
## Changes to This Policy
If we make changes to this privacy policy, we will update the "Last Updated" date at the top of this document. Significant changes will be noted in the extension's release notes.
## Contact
For privacy-related questions or concerns, please open an issue on our repository:
https://github.com/PlebeianApp/plebeian-signer/issues
---
## Summary
- All data stays in your browser
- Private keys are encrypted with strong cryptography
- No analytics, tracking, or data collection
- No external servers (except Nostr relays you configure)
- Fully open source and auditable

View File

@@ -0,0 +1,293 @@
# Extension Store Publishing Guide
This guide walks you through publishing Plebeian Signer to the Chrome Web Store and Firefox Add-ons.
---
## Table of Contents
1. [Assets You Need to Create](#assets-you-need-to-create)
2. [Chrome Web Store](#chrome-web-store)
3. [Firefox Add-ons](#firefox-add-ons)
4. [Ongoing Maintenance](#ongoing-maintenance)
---
## Assets You Need to Create
Before submitting to either store, prepare these assets:
### Screenshots (Required for both stores)
Create 3-5 screenshots showing the extension in action:
1. **Main popup view** - Show the identity card with profile info
2. **Permission prompt** - Show a signing request popup
3. **Identity management** - Show the identity list/switching
4. **Permissions page** - Show the permissions management
5. **Settings page** - Show vault settings and options
**Specifications:**
- Chrome: 1280x800 or 640x400 pixels (PNG or JPEG)
- Firefox: 1280x800 recommended (PNG or JPEG)
**Tips:**
- Use a clean browser profile
- Show realistic data (not "test" or placeholder text)
- Capture the full popup or relevant UI area
- Consider adding captions/annotations
### Promotional Images (Chrome only)
Chrome Web Store uses promotional tiles:
| Size | Name | Required |
|------|------|----------|
| 440x280 | Small promo tile | Optional but recommended |
| 920x680 | Large promo tile | Optional |
| 1400x560 | Marquee promo tile | Optional |
**Design tips:**
- Include the extension icon/logo
- Add a tagline like "Secure Nostr Identity Manager"
- Use brand colors
- Keep text minimal and readable
### Icon (Already exists)
You already have icons in the extension:
- `icon-48.png` - 48x48
- `icon-128.png` - 128x128
Chrome also wants a 128x128 icon for the store listing (can use the same one).
### Privacy Policy URL
You need to host the privacy policy at a public URL. Options:
1. **GitHub/Gitea Pages** - Host `PRIVACY_POLICY.md` as a webpage
2. **Simple webpage** - Create a basic HTML page
3. **Gist** - Create a public GitHub gist
Example URL format: `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md`
---
## Chrome Web Store
### Step 1: Create Developer Account
1. Go to https://chrome.google.com/webstore/devconsole
2. Sign in with a Google account
3. Pay the one-time $5 USD registration fee
4. Accept the developer agreement
### Step 2: Create New Item
1. Click **"New Item"** button
2. Upload `releases/plebeian-signer-chrome-v1.0.5.zip`
3. Wait for the upload to process
### Step 3: Fill Store Listing
**Product Details:**
- **Name:** Plebeian Signer
- **Summary:** Copy from `STORE_DESCRIPTION.md` (short description, 132 chars max)
- **Description:** Copy from `STORE_DESCRIPTION.md` (full description)
- **Category:** Productivity
- **Language:** English
**Graphic Assets:**
- Upload your screenshots (at least 1 required, up to 5)
- Upload promotional tiles if you have them
**Additional Fields:**
- **Official URL:** `https://github.com/PlebeianApp/plebeian-signer`
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
### Step 4: Privacy Tab
- **Single Purpose:** "Manage Nostr identities and sign cryptographic events for web applications"
- **Permission Justifications:**
- `storage`: "Store encrypted vault containing user's Nostr identities and extension settings"
- `activeTab`: "Inject NIP-07 interface into the active tab when user visits Nostr applications"
- `scripting`: "Enable communication between web pages and the extension for signing requests"
- **Data Usage:** Check "I do not sell or transfer user data to third parties"
- **Privacy Policy URL:** Your hosted privacy policy URL
### Step 5: Distribution
- **Visibility:** Public
- **Distribution:** All regions (or select specific ones)
### Step 6: Submit for Review
1. Review all sections show green checkmarks
2. Click **"Submit for Review"**
3. Wait 1-3 business days (can take longer for first submission)
### Chrome Review Notes
Google may ask about:
- Why you need each permission
- How you handle user data
- Your identity/organization
Be prepared to respond to reviewer questions via the dashboard.
---
## Firefox Add-ons
### Step 1: Create Developer Account
1. Go to https://addons.mozilla.org/developers/
2. Sign in with a Firefox account (create one if needed)
3. No fee required
### Step 2: Submit New Add-on
1. Click **"Submit a New Add-on"**
2. Select **"On this site"** for hosting
3. Upload `releases/plebeian-signer-firefox-v1.0.5.zip`
4. Wait for automated validation
### Step 3: Source Code Submission
Firefox may request source code because the extension uses bundled/minified JavaScript.
**If prompted:**
1. Create a source code zip (exclude `node_modules`):
```bash
cd /home/mleku/src/git.mleku.dev/mleku/plebeian-signer
zip -r plebeian-signer-source.zip . -x "node_modules/*" -x "dist/*" -x ".git/*"
```
2. Upload this zip when asked
3. Include build instructions (point to CLAUDE.md or add a note):
```
Build Instructions:
1. npm ci
2. npm run build:firefox
3. Output is in dist/firefox/
```
### Step 4: Fill Listing Details
**Basic Information:**
- **Name:** Plebeian Signer
- **Add-on URL:** `plebeian-signer` (creates addons.mozilla.org/addon/plebeian-signer)
- **Summary:** Copy short description from `STORE_DESCRIPTION.md`
- **Description:** Copy full description (supports some HTML/Markdown)
- **Categories:** Privacy & Security
**Additional Details:**
- **Homepage:** `https://github.com/PlebeianApp/plebeian-signer`
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
- **License:** Select appropriate license
- **Privacy Policy:** Paste URL to hosted privacy policy
**Media:**
- **Icon:** Already in the extension manifest
- **Screenshots:** Upload your screenshots
### Step 5: Submit for Review
1. Ensure all required fields are complete
2. Click **"Submit Version"**
3. Wait for review (usually hours to a few days)
### Firefox Review Notes
Firefox reviewers are generally faster but thorough. They may:
- Ask for source code (see Step 3)
- Question specific code patterns
- Request changes for policy compliance
---
## Ongoing Maintenance
### Updating the Extension
**For new releases:**
1. Build new version: `/release patch` (or `minor`/`major`)
2. Upload the new zip to each store
3. Add release notes describing changes
4. Submit for review
**Chrome:**
- Go to Developer Dashboard → Your extension → Package → Upload new package
**Firefox:**
- Go to Developer Hub → Your extension → Upload a New Version
### Responding to Reviews
Both stores may contact you with:
- Policy violation notices
- User reports
- Review questions
Monitor your developer email and respond promptly.
### Version Numbering
Both stores extract the version from `manifest.json`. Your current setup with `v1.0.5` in `package.json` feeds into the manifests correctly.
---
## Checklist
### Before First Submission
- [ ] Create 3-5 screenshots
- [ ] Create promotional images (Chrome, optional but recommended)
- [ ] Host privacy policy at a public URL
- [ ] Test the extension zip by loading it unpacked
- [ ] Prepare source code zip for Firefox
### Chrome Web Store
- [ ] Register developer account ($5)
- [ ] Upload extension zip
- [ ] Fill all required listing fields
- [ ] Add screenshots
- [ ] Add privacy policy URL
- [ ] Justify all permissions
- [ ] Submit for review
### Firefox Add-ons
- [ ] Register developer account (free)
- [ ] Upload extension zip
- [ ] Upload source code if requested
- [ ] Fill all required listing fields
- [ ] Add screenshots
- [ ] Add privacy policy URL
- [ ] Submit for review
---
## Helpful Links
- Chrome Developer Dashboard: https://chrome.google.com/webstore/devconsole
- Chrome Publishing Docs: https://developer.chrome.com/docs/webstore/publish/
- Firefox Developer Hub: https://addons.mozilla.org/developers/
- Firefox Extension Workshop: https://extensionworkshop.com/documentation/publish/
---
## Estimated Timeline
| Task | Time |
|------|------|
| Create screenshots | 30 min - 1 hour |
| Create promotional images | 1-2 hours (optional) |
| Host privacy policy | 15 min |
| Chrome submission | 30 min |
| Chrome review | 1-3 business days |
| Firefox submission | 30 min |
| Firefox review | Hours to 2 days |
**Total:** You can have both submissions done in an afternoon, with approvals coming within a week.

View File

@@ -0,0 +1,88 @@
# Plebeian Signer - Store Description
Use this content for Chrome Web Store and Firefox Add-ons listings.
---
## Short Description (132 characters max for Chrome)
Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility.
---
## Full Description
**Plebeian Signer** is a secure browser extension for managing your Nostr identities and signing events without exposing your private keys to web applications.
### Key Features
**Multi-Identity Management**
- Create and manage multiple Nostr identities from a single extension
- Easily switch between identities with one click
- Import existing keys or generate new ones
**Bank-Grade Security**
- Private keys never leave the extension
- Vault encrypted with Argon2id + AES-256-GCM (the same algorithms used by password managers)
- Automatic vault locking for protection
**NIP-07 Compatible**
- Works with all Nostr web applications that support NIP-07
- Supports NIP-04 and NIP-44 encryption/decryption
- Relay configuration per identity
**Permission Control**
- Fine-grained permission management per application
- Approve or deny signing requests on a per-site basis
- Optional "Reckless Mode" for trusted applications
- Whitelist trusted hosts for automatic approval
**User-Friendly Interface**
- Clean, intuitive design
- Profile metadata display with avatar and banner
- NIP-05 verification support
- Bookmark your favorite Nostr apps
### How It Works
1. Create a password-protected vault
2. Add your Nostr identities (import existing or generate new)
3. Visit any NIP-07 compatible Nostr application
4. Approve signing requests through the extension popup
### Privacy First
Plebeian Signer is open source and respects your privacy:
- No telemetry or analytics
- No external servers (except for profile metadata from Nostr relays)
- All cryptographic operations happen locally in your browser
- Your private keys are encrypted and never transmitted
### Supported NIPs
- NIP-07: Browser Extension for Nostr
- NIP-04: Encrypted Direct Messages
- NIP-44: Versioned Encryption
### Links
- Source Code: https://github.com/PlebeianApp/plebeian-signer
- Report Issues: https://github.com/PlebeianApp/plebeian-signer/issues
---
## Category Suggestions
**Chrome Web Store:**
- Primary: Productivity
- Secondary: Developer Tools
**Firefox Add-ons:**
- Primary: Privacy & Security
- Secondary: Other
---
## Tags/Keywords
nostr, nip-07, signing, identity, privacy, encryption, decentralized, keys, wallet, security

129
docs/store/publishing.md Normal file
View File

@@ -0,0 +1,129 @@
# Publishing Checklist
Developer accounts are set up. This document covers the remaining steps.
## Privacy Policy URL
```
https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md
```
## Screenshots Needed
Take 3-5 screenshots (1280x800 or 640x400 PNG/JPEG):
1. **Identity view** - Main popup showing profile card with avatar/banner
2. **Permission prompt** - A signing request popup from a Nostr app
3. **Identity list** - Multiple identities with switching UI
4. **Permissions page** - Managing site permissions
5. **Settings** - Vault/reckless mode settings
**Tips:**
- Load the extension in a clean browser profile
- Use real-looking test data, not "test123"
- Crop to show just the popup/relevant UI
---
## Chrome Web Store Submission
1. Go to https://chrome.google.com/webstore/devconsole
2. Click **"New Item"**
3. Upload: `releases/plebeian-signer-chrome-v1.0.5.zip`
### Store Listing Tab
| Field | Value |
|-------|-------|
| Name | Plebeian Signer |
| Summary | Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility. |
| Description | Copy from `docs/store/STORE_DESCRIPTION.md` (full description section) |
| Category | Productivity |
| Language | English |
Upload your screenshots.
### Privacy Tab
| Field | Value |
|-------|-------|
| Single Purpose | Manage Nostr identities and sign cryptographic events for web applications |
| Privacy Policy URL | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
**Permission Justifications:**
| Permission | Justification |
|------------|---------------|
| storage | Store encrypted vault containing user's Nostr identities and extension settings |
| activeTab | Inject NIP-07 interface into the active tab when user visits Nostr applications |
| scripting | Enable communication between web pages and the extension for signing requests |
Check: "I do not sell or transfer user data to third parties"
### Distribution Tab
- Visibility: Public
- Regions: All
Click **"Submit for Review"**
---
## Firefox Add-ons Submission
1. Go to https://addons.mozilla.org/developers/
2. Click **"Submit a New Add-on"**
3. Select **"On this site"**
4. Upload: `releases/plebeian-signer-firefox-v1.0.5.zip`
### If Asked for Source Code
Run this to create source zip:
```bash
cd /home/mleku/src/git.mleku.dev/mleku/plebeian-signer
zip -r plebeian-signer-source.zip . -x "node_modules/*" -x "dist/*" -x ".git/*" -x "releases/*"
```
Build instructions to provide:
```
1. npm ci
2. npm run build:firefox
3. Output is in dist/firefox/
```
### Listing Details
| Field | Value |
|-------|-------|
| Name | Plebeian Signer |
| Add-on URL | plebeian-signer |
| Summary | Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility. |
| Description | Copy from `docs/store/STORE_DESCRIPTION.md` |
| Categories | Privacy & Security |
| Homepage | `https://github.com/PlebeianApp/plebeian-signer` |
| Support URL | `https://github.com/PlebeianApp/plebeian-signer/issues` |
| Privacy Policy | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
Upload your screenshots.
Click **"Submit Version"**
---
## After Submission
- **Chrome:** 1-3 business days review
- **Firefox:** Hours to 2 days review
Check your email for reviewer questions. Both dashboards show review status.
---
## Updating Later
When you release a new version:
1. Run `/release patch` (or minor/major)
2. Chrome: Dashboard → Your extension → Package → Upload new package
3. Firefox: Developer Hub → Your extension → Upload a New Version
4. Add release notes, submit for review

View File

@@ -1,6 +1,7 @@
#!/bin/bash
version=$( cat package.json | jq '.custom.firefox.version' | tr -d '"')
# Extract version and strip 'v' prefix if present (manifest requires bare semver)
version=$( cat package.json | jq -r '.custom.firefox.version' | sed 's/^v//')
jq '.version = $newVersion' --arg newVersion $version ./projects/firefox/public/manifest.json > ./projects/firefox/public/tmp.manifest.json && mv ./projects/firefox/public/tmp.manifest.json ./projects/firefox/public/manifest.json

3983
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{
"name": "gooti-extension",
"version": "0.0.4",
"name": "plebeian-signer",
"version": "1.1.6",
"custom": {
"chrome": {
"version": "0.0.4"
"version": "v1.1.6"
},
"firefox": {
"version": "0.0.4"
"version": "v1.1.6"
}
},
"scripts": {
@@ -15,10 +15,11 @@
"clean:firefox": "rimraf dist/firefox",
"start:chrome": "ng serve chrome",
"start:firefox": "ng serve firefox",
"fetch-kinds": "node scripts/fetch-kinds.js",
"prepare:chrome": "./chrome_prepare_manifest.sh",
"prepare:firefox": "./firefox_prepare_manifest.sh",
"build:chrome": "npm run prepare:chrome && ng build chrome",
"build:firefox": "npm run prepare:firefox && ng build firefox",
"build:chrome": "npm run fetch-kinds && npm run prepare:chrome && ng build chrome",
"build:firefox": "npm run fetch-kinds && npm run prepare:firefox && ng build firefox",
"watch:chrome": "npm run prepare:chrome && ng build chrome --watch --configuration development",
"watch:firefox": "npm run prepare:firefox && ng build firefox --watch --configuration development",
"test": "ng test",
@@ -35,12 +36,16 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@cashu/cashu-ts": "^3.2.0",
"@nostr-dev-kit/ndk": "^2.11.0",
"@popperjs/core": "^2.11.8",
"@types/qrcode": "^1.5.6",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"buffer": "^6.0.3",
"hash-wasm": "^4.11.0",
"nostr-tools": "^2.10.4",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"webextension-polyfill": "^0.12.0",
@@ -54,6 +59,7 @@
"@types/bootstrap": "^5.2.10",
"@types/chrome": "^0.0.293",
"@types/jasmine": "~5.1.0",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.1",
"angular-eslint": "19.0.2",
"eslint": "^9.16.0",
@@ -68,5 +74,6 @@
"rimraf": "^6.0.1",
"typescript": "~5.6.2",
"typescript-eslint": "8.18.0"
}
},
"license": "Unlicense"
}

View File

@@ -6,12 +6,12 @@ module.exports = {
import: 'src/background.ts',
runtime: false,
},
'gooti-extension': {
import: 'src/gooti-extension.ts',
'plebian-signer-extension': {
import: 'src/plebian-signer-extension.ts',
runtime: false,
},
'gooti-content-script': {
import: 'src/gooti-content-script.ts',
'plebian-signer-content-script': {
import: 'src/plebian-signer-content-script.ts',
runtime: false,
},
prompt: {
@@ -22,5 +22,9 @@ module.exports = {
import: 'src/options.ts',
runtime: false,
},
unlock: {
import: 'src/unlock.ts',
runtime: false,
},
},
} as Configuration;

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Origami-Paper-Bird--Streamline-Cyber.svg" height="24" width="24"><desc>Origami Paper Bird Streamline Icon: https://streamlinehq.com</desc><path fill="#ffffff" d="M4.66378 6.62012 0.751282 10.9774H6.11588l0.5042 7.3802 11.73752 4.8911L9.55488 9.01202l-4.8911 -2.3919Z" stroke-width="1"></path><path fill="#ffffff" d="M18.8423 0.751343 9.55499 9.01194l5.32571 8.61246L18.8423 0.751343Z" stroke-width="1"></path><path fill="#bbd8ff" d="m9.555 9.01187 -1.4675 -0.7178 -1.7681 5.66933 0.3007 4.3942L9.555 9.01187Z" stroke-width="1"></path><path fill="#bbd8ff" d="m4.66378 6.62012 1.4521 4.35728H0.751282L4.66378 6.62012Z" stroke-width="1"></path><path fill="#bbd8ff" d="m15.3767 18.4282 7.872 -15.23167 -5.5814 2.5565 -2.7866 11.87137 0.496 0.8038Z" stroke-width="1"></path><path stroke="#092f63" stroke-linejoin="round" stroke-miterlimit="10" d="m9.55488 9.01202 -4.8911 -2.3919L0.751282 10.9774H6.11588l0.5042 7.3802 11.73752 4.8911L9.55488 9.01202Z" stroke-width="1"></path><path stroke="#092f63" stroke-linejoin="round" stroke-miterlimit="10" d="M9.55499 9.01194 18.8423 0.751343 14.8807 17.6244" stroke-width="1"></path><path stroke="#092f63" stroke-linejoin="round" stroke-miterlimit="10" d="m17.6673 5.75303 5.5814 -2.5565 -7.872 15.23167" stroke-width="1"></path><path stroke="#092f63" stroke-linejoin="round" stroke-miterlimit="10" d="m4.66382 6.62012 1.4521 4.35728" stroke-width="1"></path><path stroke="#092f63" stroke-linejoin="round" stroke-miterlimit="10" d="m6.62109 18.3564 2.9338 -9.34456" stroke-width="1"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Origami-Paper-Bird--Streamline-Cyber.svg" height="24" width="24"><desc>Origami Paper Bird Streamline Icon: https://streamlinehq.com</desc><path fill="#ffffff" d="M4.66378 6.62012 0.751282 10.9774H6.11588l0.5042 7.3802 11.73752 4.8911L9.55488 9.01202l-4.8911 -2.3919Z" stroke-width="1"></path><path fill="#ffffff" d="M18.8423 0.751343 9.55499 9.01194l5.32571 8.61246L18.8423 0.751343Z" stroke-width="1"></path><path fill="#ff3eb5" d="m9.555 9.01187 -1.4675 -0.7178 -1.7681 5.66933 0.3007 4.3942L9.555 9.01187Z" stroke-width="1"></path><path fill="#ff3eb5" d="m4.66378 6.62012 1.4521 4.35728H0.751282L4.66378 6.62012Z" stroke-width="1"></path><path fill="#ff3eb5" d="m15.3767 18.4282 7.872 -15.23167 -5.5814 2.5565 -2.7866 11.87137 0.496 0.8038Z" stroke-width="1"></path><path stroke="#0a0a0a" stroke-linejoin="round" stroke-miterlimit="10" d="m9.55488 9.01202 -4.8911 -2.3919L0.751282 10.9774H6.11588l0.5042 7.3802 11.73752 4.8911L9.55488 9.01202Z" stroke-width="1"></path><path stroke="#0a0a0a" stroke-linejoin="round" stroke-miterlimit="10" d="M9.55499 9.01194 18.8423 0.751343 14.8807 17.6244" stroke-width="1"></path><path stroke="#0a0a0a" stroke-linejoin="round" stroke-miterlimit="10" d="m17.6673 5.75303 5.5814 -2.5565 -7.872 15.23167" stroke-width="1"></path><path stroke="#0a0a0a" stroke-linejoin="round" stroke-miterlimit="10" d="m4.66382 6.62012 1.4521 4.35728" stroke-width="1"></path><path stroke="#0a0a0a" stroke-linejoin="round" stroke-miterlimit="10" d="m6.62109 18.3564 2.9338 -9.34456" stroke-width="1"></path></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.06 9L15 9.94L5.92 19H5V18.08L14.06 9ZM17.66 3C17.41 3 17.15 3.1 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18.17 3.09 17.92 3 17.66 3ZM14.06 6.19L3 17.25V21H6.75L17.81 9.94L14.06 6.19Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg width="43" height="43" viewBox="0 0 43 43" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.2064 12.9191C31.3447 12.8199 31.4775 12.7215 31.595 12.6294C31.748 12.5094 31.8889 12.3754 32.0266 12.2416C32.4058 11.8741 32.7385 11.4741 33.021 11.0453C33.7824 9.89014 34.214 8.86051 34.4639 7.6027C34.7671 6.0762 34.6324 4.70972 34.0634 3.54162C33.8094 3.02046 33.4264 2.55769 32.9381 2.28503C32.5761 2.08299 32.1384 2 31.7457 2C31.3933 2 31.0597 2.08818 30.7542 2.26196C29.8711 2.76416 29.404 3.33897 28.8956 4.16212C28.7818 4.34644 28.4942 4.86149 28.185 5.45326C28.0437 5.72362 27.7976 6.2272 27.5991 6.63557L27.5022 6.83578C27.1678 7.52643 26.7099 8.47231 26.1436 9.37999C25.9043 9.7636 25.6451 10.14 25.3862 10.4804C24.5789 8.3612 23.1567 5.81364 21.4751 4.76077C19.2411 3.36174 16.4504 3.51656 13.3867 5.18794C13.3754 5.16058 13.3621 5.13429 13.347 5.10938C13.1827 4.83887 12.8915 4.68986 12.527 4.68986C12.1046 4.68986 11.645 4.88594 11.2975 5.21423C11.0225 5.47435 10.8231 5.81088 10.7502 6.13718C10.686 6.42497 10.7482 6.66369 10.922 6.82172C9.69105 7.7835 8.67196 8.83055 7.89083 9.93584C6.69461 11.6288 6.60872 13.5106 6.68254 15.1283C6.76522 16.9388 7.30487 18.5889 7.8757 20.1023C7.99598 20.4215 8.13231 20.7669 8.3044 21.1891C8.40389 21.4328 8.60471 21.858 8.83198 21.9674C8.90885 22.0047 9.00834 22.0228 9.13596 22.0228C9.20107 22.0228 9.39944 21.9784 9.63068 21.8826C9.65758 21.8716 9.68478 21.8595 9.7126 21.8472L9.62105 22.2351L9.48916 22.7952L5 41.4206L15.1993 41.462L16.3119 33.32H22.3202C30.5538 33.32 37.1554 27.6991 37.1554 19.805C37.1554 16.036 34.932 13.7975 31.2064 12.9191ZM26.5111 26.1283C26.3257 27.1618 25.3243 27.9469 23.3586 27.9469H17.6099L18.3888 24.723H24.1374C25.584 24.7232 26.6595 25.0539 26.5111 26.1283ZM23.7121 15.2511L23.4773 15.3247C22.9068 15.5037 22.1975 15.7259 21.0558 16.1422L21.0406 16.1424L21.0207 16.1551C20.2607 16.4325 19.4338 16.7499 18.4939 17.1252C18.3646 17.1768 18.2379 17.2284 18.1144 17.2796C17.7641 17.425 17.4218 17.5747 17.0959 17.7251L17.0157 17.7623C16.907 17.8131 16.8054 17.8613 16.7111 17.907C16.5539 17.9834 16.4165 18.0525 16.2986 18.1142C16.2017 18.1627 14.2919 19.1472 13.1072 19.7445C13.7122 19.1922 16.608 17.3582 18.4933 16.0942C18.2401 15.1639 17.8977 14.132 17.4938 13.0892C17.2751 12.5241 17.0449 11.9717 16.806 11.4385C16.6741 11.1437 16.2091 9.94898 15.2451 8.7792C14.9286 8.39498 14.5114 8.0077 14.024 7.67162C13.7052 7.45185 13.308 7.20365 12.8571 7.06427C13.137 6.8772 13.5599 6.62227 13.6153 6.58896C15.2441 5.60563 16.777 5.10755 18.1777 5.10755C19.1217 5.10755 20.0024 5.34108 20.7951 5.80171C20.8961 5.86056 21.0033 5.92887 21.1231 6.01079L21.1705 6.04288C21.2671 6.11044 21.3662 6.18517 21.4659 6.26541L21.5205 6.31004C21.6213 6.39333 21.7127 6.47326 21.8004 6.55518L21.8408 6.59385C21.9397 6.68784 22.0282 6.77633 22.1087 6.86207L22.1205 6.8746C22.2027 6.96233 22.2878 7.05892 22.3887 7.17874L22.4222 7.21863C22.5032 7.31629 22.5817 7.4167 22.6586 7.51849L22.7031 7.57824C22.7801 7.68217 22.8556 7.78808 22.9291 7.89644L22.9594 7.94214C23.033 8.05172 23.1053 8.16298 23.1767 8.27837L23.1876 8.29549C23.2595 8.4121 23.3292 8.53054 23.3977 8.65036L23.4313 8.70936C23.4972 8.82566 23.5615 8.94365 23.6245 9.06286L23.6584 9.12781C23.7219 9.24901 23.7841 9.37097 23.845 9.49507L23.8655 9.53725C23.9265 9.66196 23.9863 9.78775 24.0449 9.91414L24.0555 9.93691C24.1152 10.0662 24.1735 10.1961 24.2309 10.3269L24.2569 10.3856C24.3124 10.5128 24.3668 10.6401 24.42 10.7666L24.4458 10.8287C24.5002 10.9591 24.554 11.0894 24.6115 11.2323L24.6566 11.3454C24.3775 11.6471 24.1457 11.8967 23.823 12.2189C23.5323 12.5091 22.1914 13.7093 20.927 14.7534C20.597 15.0259 20.3073 15.2812 20.3073 15.3874C20.3074 15.4585 20.7283 15.3162 21.3066 15.0201C21.5088 14.9248 21.7326 14.8302 21.9501 14.7001C22.9672 14.0921 24.0249 13.2482 24.3008 13.0153C24.7506 12.6356 25.1635 12.2662 25.5649 11.8227C25.6523 11.7261 25.7368 11.6309 25.8173 11.5372C25.9949 11.3314 26.1748 11.1078 26.3672 10.8542C26.6355 10.5007 26.8789 10.1484 27.1125 9.77491C27.263 9.5345 27.408 9.28829 27.5612 9.01181L27.5615 9.01197L27.567 9.00249C27.6746 8.81558 27.7805 8.62637 27.8864 8.43716L28.0229 8.19294C28.1279 8.00358 28.2323 7.81299 28.3365 7.62134L28.4861 7.34487C28.6704 7.00374 28.8546 6.66002 29.0403 6.31309L29.182 6.04885C29.7066 5.07026 30.5868 3.51625 31.8296 3.43235C32.2689 3.4027 32.7077 3.74887 32.8668 3.96023C33.1947 4.39581 33.4487 5.04794 33.5264 5.5202C33.6395 6.20764 33.619 6.83823 33.4585 7.62852C33.4191 7.82262 33.2956 8.34745 33.0022 9.02863C32.7556 9.60052 32.4478 10.1486 32.0876 10.6571C31.2967 11.774 29.5911 12.9945 27.7424 13.7667C26.9773 14.0869 25.0909 14.767 23.7121 15.2511Z" fill="#FF3EB5"/>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -1,17 +1,27 @@
{
"manifest_version": 3,
"name": "Gooti - Nostr Identity Manager & Signer",
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
"version": "0.0.4",
"homepage_url": "https://getgooti.com",
"version": "1.1.5",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [
"windows",
"storage"
],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"action": {
"default_popup": "index.html",
"default_icon": "gooti-with-bg.png"
"default_icon": {
"48": "icon-48.png",
"128": "icon-128.png"
}
},
"icons": {
"48": "icon-48.png",
"128": "icon-128.png"
},
"background": {
"service_worker": "background.js"
@@ -23,7 +33,7 @@
"<all_urls>"
],
"js": [
"gooti-content-script.js"
"plebian-signer-content-script.js"
],
"all_frames": true
}
@@ -31,14 +41,11 @@
"web_accessible_resources": [
{
"resources": [
"gooti-extension.js"
"plebian-signer-extension.js"
],
"matches": [
"https://*/*",
"http://localhost:*/*",
"http://0.0.0.0:*/*",
"http://127.0.0.1:*/*",
"http://*.localhost/*"
"http://*/*"
]
}
]

View File

@@ -1,16 +1,22 @@
<!DOCTYPE html>
<html data-bs-theme="dark">
<html>
<head>
<title>Gooti - Options</title>
<title>Plebeian Signer - Options</title>
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="scripts.js"></script>
<style>
/* Prevent white flash on load */
html { background-color: #0a0a0a; }
@media (prefers-color-scheme: light) {
html { background-color: #ffffff; }
}
body {
background: var(--background);
height: 100vh;
width: 100vw;
color: #ffffff;
color: var(--foreground);
font-size: 16px;
box-sizing: border-box;
}
@@ -35,7 +41,7 @@
height: 60px;
width: 60px;
border-radius: 100%;
border: 2px solid var(--primary);
border: 2px solid var(--secondary);
img {
height: 100%;
@@ -47,7 +53,7 @@
font-weight: 700;
font-size: 1.5rem;
letter-spacing: 4px;
color: #b9d6ff;
color: var(--secondary);
}
.main-header {
@@ -64,8 +70,8 @@
line-height: 1.4;
.accent {
color: #d63384;
border: 1px solid #d63384;
color: var(--secondary);
border: 1px solid var(--secondary);
border-radius: 4px;
padding: 2px 4px;
}
@@ -102,9 +108,9 @@
<div class="page">
<div class="container sam-flex-row gap" style="margin-top: 16px">
<div class="logo">
<img src="gooti.svg" alt="" />
<img src="logo.svg" alt="" />
</div>
<span class="brand-name">Gooti</span>
<span class="brand-name">Plebeian Signer</span>
<span>OPTIONS</span>
</div>

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#ffffff" class="bi bi-person-fill" viewBox="0 0 16 16">
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4C11.606 4 11.2159 4.0776 10.8519 4.22836C10.488 4.37913 10.1573 4.6001 9.87868 4.87868C9.6001 5.15726 9.37913 5.48797 9.22836 5.85195C9.0776 6.21593 9 6.60603 9 7C9 7.39397 9.0776 7.78407 9.22836 8.14805C9.37913 8.51203 9.6001 8.84274 9.87868 9.12132C10.1573 9.3999 10.488 9.62087 10.8519 9.77164C11.2159 9.9224 11.606 10 12 10C12.7956 10 13.5587 9.68393 14.1213 9.12132C14.6839 8.55871 15 7.79565 15 7C15 6.20435 14.6839 5.44129 14.1213 4.87868C13.5587 4.31607 12.7956 4 12 4ZM7 7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7C17 8.32608 16.4732 9.59785 15.5355 10.5355C14.5979 11.4732 13.3261 12 12 12C10.6739 12 9.40215 11.4732 8.46447 10.5355C7.52678 9.59785 7 8.32608 7 7ZM3.5 19C3.5 17.6739 4.02678 16.4021 4.96447 15.4645C5.90215 14.5268 7.17392 14 8.5 14H15.5C16.1566 14 16.8068 14.1293 17.4134 14.3806C18.02 14.6319 18.5712 15.0002 19.0355 15.4645C19.4998 15.9288 19.8681 16.48 20.1194 17.0866C20.3707 17.6932 20.5 18.3434 20.5 19V21H18.5V19C18.5 18.2044 18.1839 17.4413 17.6213 16.8787C17.0587 16.3161 16.2956 16 15.5 16H8.5C7.70435 16 6.94129 16.3161 6.37868 16.8787C5.81607 17.4413 5.5 18.2044 5.5 19V21H3.5V19Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 219 B

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 983 B

After

Width:  |  Height:  |  Size: 983 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,16 +1,22 @@
<!DOCTYPE html>
<html data-bs-theme="dark">
<html>
<head>
<title>Gooti</title>
<title>Plebeian Signer</title>
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="scripts.js"></script>
<style>
/* Prevent white flash on load */
html { background-color: #0a0a0a; }
@media (prefers-color-scheme: light) {
html { background-color: #ffffff; }
}
body {
background: var(--background);
height: 100vh;
width: 100vw;
color: #ffffff;
color: var(--foreground);
font-size: 16px;
}
@@ -21,16 +27,71 @@
.page {
height: 100%;
display: grid;
grid-template-rows: 1fr 60px;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr;
overflow-y: hidden;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--size);
background: var(--background);
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
}
.action-label {
width: 60px;
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground);
}
.action-buttons {
display: flex;
gap: 8px;
flex: 1;
}
.action-buttons button {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-reject {
background: var(--muted);
color: var(--foreground);
}
.btn-reject:hover {
background: var(--border);
}
.btn-accept {
background: var(--primary);
color: var(--primary-foreground);
}
.btn-accept:hover {
opacity: 0.9;
}
.card {
padding: var(--size);
background: var(--background-light);
border-radius: 8px;
color: #ffffff;
color: var(--foreground);
display: flex;
flex-direction: column;
}
@@ -48,6 +109,12 @@
font-size: 12px;
color: gray;
}
.description {
margin: 0;
text-align: center;
line-height: 1.5;
}
</style>
</head>
<body>
@@ -57,64 +124,31 @@
<span id="titleSpan" style="font-weight: 400 !important"></span>
</div>
<span
class="host-INSERT sam-align-self-center sam-text-muted"
style="font-weight: 500"
></span>
<!-- Card for getPublicKey -->
<div id="cardGetPublicKey" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your public key</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your public key</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for getRelays -->
<div id="cardGetRelays" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your relays</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your relays</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for signEvent -->
<div id="cardSignEvent" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">sign an event</b> (kind
<span id="kindSpan"></span>) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">sign an event</b> (kind <span id="kindSpan"></span>)
for the selected identity <b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for signEvent -->
@@ -124,20 +158,11 @@
<!-- Card for nip04.encrypt -->
<div id="cardNip04Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.encrypt -->
@@ -147,20 +172,11 @@
<!-- Card for nip44.encrypt -->
<div id="cardNip44Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.encrypt -->
@@ -170,20 +186,11 @@
<!-- Card for nip04.decrypt -->
<div id="cardNip04Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.decrypt -->
@@ -193,72 +200,90 @@
<!-- Card for nip44.decrypt -->
<div id="cardNip44Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.decrypt -->
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
<div id="card2Nip44Decrypt_text" class="text"></div>
</div>
<!-- Card for webln.enable -->
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">connect to your Lightning wallet</b>.
</p>
</div>
<!-- Card for webln.getInfo -->
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your wallet info</b>.
</p>
</div>
<!-- Card for webln.sendPayment -->
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a Lightning payment</b> of
<b id="paymentAmountSpan" class="color-primary"></b>.
</p>
</div>
<!-- Card2 for webln.sendPayment (shows invoice) -->
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<div id="card2WeblnSendPayment_json" class="json"></div>
</div>
<!-- Card for webln.makeInvoice -->
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">create a Lightning invoice</b>
<span id="invoiceAmountSpan"></span>.
</p>
</div>
<!-- Card for webln.keysend -->
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a keysend payment</b>.
</p>
</div>
</div>
<!------------->
<!-- ACTIONS -->
<!------------->
<div class="sam-footer-grid-2">
<div class="btn-group">
<button id="rejectButton" type="button" class="btn btn-secondary">
Reject
</button>
<button
type="button"
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button id="rejectJustOnceButton" class="dropdown-item">
just once
</button>
</li>
</ul>
<div class="actions">
<div class="action-row">
<span class="action-label">Reject</span>
<div class="action-buttons">
<button id="rejectOnceButton" type="button" class="btn-reject">Once</button>
<button id="rejectAlwaysButton" type="button" class="btn-reject">Always</button>
</div>
</div>
<div class="btn-group">
<button id="approveButton" type="button" class="btn btn-primary">
Approve
</button>
<button
type="button"
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<button id="approveJustOnceButton" class="dropdown-item" href="#">
just once
</button>
</li>
</ul>
<div class="action-row">
<span class="action-label">Accept</span>
<div class="action-buttons">
<button id="approveOnceButton" type="button" class="btn-accept">Once</button>
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
</div>
</div>
<div class="action-row" id="allQueuedRow">
<span class="action-label">All Queued</span>
<div class="action-buttons">
<button id="rejectAllButton" type="button" class="btn-reject">Reject All</button>
<button id="approveAllButton" type="button" class="btn-accept">Approve All</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html>
<head>
<title>Plebeian Signer - Unlock</title>
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="scripts.js"></script>
<style>
/* Prevent white flash on load */
html { background-color: #0a0a0a; }
@media (prefers-color-scheme: light) {
html { background-color: #ffffff; }
}
body {
background: var(--background);
height: 100vh;
width: 100vw;
color: var(--foreground);
font-size: 16px;
margin: 0;
display: flex;
flex-direction: column;
}
.color-primary {
color: var(--primary);
}
.page {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
box-sizing: border-box;
}
.header {
text-align: center;
font-size: 1.25rem;
font-weight: 500;
padding: var(--size) 0;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
}
.logo-frame {
border: 2px solid var(--secondary);
border-radius: 100%;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.logo-frame img {
display: block;
}
.input-group {
width: 100%;
max-width: 280px;
display: flex;
}
.input-group input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--border);
border-right: none;
border-radius: 6px 0 0 6px;
background: var(--background);
color: var(--foreground);
font-size: 14px;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
}
.input-group button {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 0 6px 6px 0;
background: var(--background-light);
color: var(--muted-foreground);
cursor: pointer;
}
.input-group button:hover {
background: var(--muted);
}
.unlock-btn {
width: 100%;
max-width: 280px;
padding: 10px 16px;
border: none;
border-radius: 6px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.unlock-btn:hover:not(:disabled) {
opacity: 0.9;
}
.unlock-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.alert {
position: fixed;
bottom: var(--size);
left: 50%;
transform: translateX(-50%);
padding: 10px 16px;
border-radius: 6px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.alert-danger {
background: var(--destructive);
color: var(--destructive-foreground);
}
.hidden {
display: none !important;
}
.deriving-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
z-index: 1000;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--muted);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.deriving-text {
color: var(--foreground);
font-size: 14px;
}
.host-info {
text-align: center;
font-size: 13px;
color: var(--muted-foreground);
margin-top: 8px;
}
.host-name {
color: var(--primary);
font-weight: 500;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<span class="brand">Plebeian Signer</span>
</div>
<div class="content">
<div class="logo-frame">
<img src="logo.svg" height="100" width="100" alt="" />
</div>
<div id="hostInfo" class="host-info hidden">
<span class="host-name" id="hostSpan"></span><br>
is requesting access
</div>
<div class="input-group sam-mt">
<input
id="passwordInput"
type="password"
placeholder="vault password"
autocomplete="current-password"
/>
<button id="togglePassword" type="button">
<i class="bi bi-eye"></i>
</button>
</div>
<button id="unlockBtn" type="button" class="unlock-btn" disabled>
<i class="bi bi-box-arrow-in-right"></i>
<span>Unlock</span>
</button>
</div>
</div>
<!-- Deriving overlay -->
<div id="derivingOverlay" class="deriving-overlay hidden">
<div class="spinner"></div>
<div class="deriving-text">Unlocking vault...</div>
</div>
<!-- Error alert -->
<div id="errorAlert" class="alert alert-danger hidden">
<i class="bi bi-exclamation-triangle"></i>
<span id="errorMessage">Invalid password</span>
</div>
<script src="unlock.js"></script>
</body>
</html>

View File

@@ -14,7 +14,7 @@ export class AppComponent implements OnInit {
readonly #logger = inject(LoggerService);
ngOnInit(): void {
this.#logger.initialize('Gooti Chrome Extension');
this.#logger.initialize('Plebeian Signer Chrome Extension');
this.#startup.startOver(getNewStorageServiceConfig());
}

View File

@@ -9,13 +9,20 @@ import { IdentitiesComponent } from './components/home/identities/identities.com
import { IdentityComponent } from './components/home/identity/identity.component';
import { InfoComponent } from './components/home/info/info.component';
import { SettingsComponent } from './components/home/settings/settings.component';
import { LogsComponent } from './components/home/logs/logs.component';
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
import { WalletComponent } from './components/home/wallet/wallet.component';
import { BackupsComponent } from './components/home/backups/backups.component';
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
import { KeysComponent as EditIdentityKeysComponent } from './components/edit-identity/keys/keys.component';
import { NcryptsecComponent as EditIdentityNcryptsecComponent } from './components/edit-identity/ncryptsec/ncryptsec.component';
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
import { VaultImportComponent } from './components/vault-import/vault-import.component';
import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
export const routes: Routes = [
{
@@ -64,12 +71,36 @@ export const routes: Routes = [
path: 'settings',
component: SettingsComponent,
},
{
path: 'logs',
component: LogsComponent,
},
{
path: 'bookmarks',
component: BookmarksComponent,
},
{
path: 'wallet',
component: WalletComponent,
},
{
path: 'backups',
component: BackupsComponent,
},
],
},
{
path: 'new-identity',
component: NewIdentityComponent,
},
{
path: 'whitelisted-apps',
component: WhitelistedAppsComponent,
},
{
path: 'profile-edit',
component: ProfileEditComponent,
},
{
path: 'edit-identity/:id',
component: EditIdentityComponent,
@@ -82,6 +113,10 @@ export const routes: Routes = [
path: 'keys',
component: EditIdentityKeysComponent,
},
{
path: 'ncryptsec',
component: EditIdentityNcryptsecComponent,
},
{
path: 'permissions',
component: EditIdentityPermissionsComponent,

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { GootiMetaData, GootiMetaHandler } from '@common';
import { ExtensionSettings, SignerMetaHandler } from '@common';
export class ChromeMetaHandler extends GootiMetaHandler {
export class ChromeMetaHandler extends SignerMetaHandler {
async loadFullData(): Promise<Partial<Record<string, any>>> {
const dataWithPossibleAlienProperties = await chrome.storage.local.get(
null
@@ -19,7 +19,7 @@ export class ChromeMetaHandler extends GootiMetaHandler {
return data;
}
async saveFullData(data: GootiMetaData): Promise<void> {
async saveFullData(data: ExtensionSettings): Promise<void> {
await chrome.storage.local.set(data);
}

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSessionData, BrowserSessionHandler } from '@common';
import { VaultSession, BrowserSessionHandler } from '@common';
export class ChromeSessionHandler extends BrowserSessionHandler {
async loadFullData(): Promise<Partial<Record<string, any>>> {
return chrome.storage.session.get(null);
}
async saveFullData(data: BrowserSessionData): Promise<void> {
async saveFullData(data: VaultSession): Promise<void> {
await chrome.storage.session.set(data);
}

View File

@@ -1,16 +1,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
EncryptedVault,
BrowserSyncHandler,
Identity_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
StoredCashuMint,
StoredIdentity,
StoredNwcConnection,
StoredPermission,
StoredRelay,
} from '@common';
/**
* Handles the browser "sync data" when the user does not want to sync anything.
* It uses the chrome.storage.local API to store the data. Since we also use this API
* to store local Gooti system data (like the user's decision to not sync), we
* to store local Signer system data (like the user's decision to not sync), we
* have to exclude these properties from the sync data.
*/
export class ChromeSyncNoHandler extends BrowserSyncHandler {
@@ -24,20 +26,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
return data;
}
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
await chrome.storage.local.set(data);
this.setFullData(data);
}
async saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[];
permissions: StoredPermission[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_Permissions(data);
}
async saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[];
identities: StoredIdentity[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_Identities(data);
@@ -51,12 +53,26 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
}
async saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[];
relays: StoredRelay[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: StoredNwcConnection[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: StoredCashuMint[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
const props = Object.keys(await this.loadUnmigratedData());
await chrome.storage.local.remove(props);

View File

@@ -1,10 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
Identity_ENCRYPTED,
Permission_ENCRYPTED,
EncryptedVault,
StoredCashuMint,
StoredIdentity,
StoredNwcConnection,
StoredPermission,
BrowserSyncHandler,
Relay_ENCRYPTED,
StoredRelay,
} from '@common';
/**
@@ -16,20 +18,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
return await chrome.storage.sync.get(null);
}
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
await chrome.storage.sync.set(data);
this.setFullData(data);
}
async saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[];
permissions: StoredPermission[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_Permissions(data);
}
async saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[];
identities: StoredIdentity[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_Identities(data);
@@ -43,12 +45,26 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
}
async saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[];
relays: StoredRelay[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: StoredNwcConnection[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: StoredCashuMint[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
await chrome.storage.sync.clear();
}

View File

@@ -9,7 +9,7 @@ export const getNewStorageServiceConfig = () => {
browserSessionHandler: new ChromeSessionHandler(),
browserSyncYesHandler: new ChromeSyncYesHandler(),
browserSyncNoHandler: new ChromeSyncNoHandler(),
gootiMetaHandler: new ChromeMetaHandler(),
signerMetaHandler: new ChromeMetaHandler(),
};
return storageConfig;

View File

@@ -136,6 +136,12 @@
</button>
</div>
</div>
<span class="sam-mt-2">Encrypted Key (NIP-49)</span>
<button class="btn btn-primary sam-mt-h" (click)="navigateToNcryptsec()">
Get ncryptsec
</button>
}
<lib-toast #toast [bottom]="16"></lib-toast>

View File

@@ -1,5 +1,5 @@
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import {
IconButtonComponent,
NavComponent,
@@ -29,6 +29,7 @@ export class KeysComponent extends NavComponent implements OnInit {
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
ngOnInit(): void {
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
@@ -51,6 +52,11 @@ export class KeysComponent extends NavComponent implements OnInit {
}
}
navigateToNcryptsec() {
if (!this.identity) return;
this.#router.navigateByUrl(`/edit-identity/${this.identity.id}/ncryptsec`);
}
async #initialize(identityId: string) {
const identity = this.#storage
.getBrowserSessionHandler()

View File

@@ -0,0 +1,60 @@
<div class="header-pane">
<lib-icon-button
icon="chevron-left"
(click)="navigateBack()"
></lib-icon-button>
<span>Get ncryptsec</span>
</div>
<!-- QR Code (shown after generation) -->
@if (ncryptsec) {
<div class="qr-container">
<button
type="button"
class="qr-button"
title="Copy to clipboard"
(click)="copyToClipboard(ncryptsec); toast.show('Copied to clipboard')"
>
<img [src]="ncryptsecQr" alt="ncryptsec QR code" class="qr-code" />
</button>
</div>
}
<!-- PASSWORD INPUT -->
<div class="password-section">
<label for="ncryptsecPasswordInput">Password</label>
<div class="input-group sam-mt-h">
<input
#passwordInput
id="ncryptsecPasswordInput"
type="password"
class="form-control"
placeholder="Enter encryption password"
[(ngModel)]="ncryptsecPassword"
[disabled]="isGenerating"
(keyup.enter)="generateNcryptsec()"
/>
</div>
</div>
<button
class="btn btn-primary generate-btn"
type="button"
(click)="generateNcryptsec()"
[disabled]="!ncryptsecPassword || isGenerating"
>
@if (isGenerating) {
<span class="spinner-border spinner-border-sm" role="status"></span>
Generating...
} @else {
Generate ncryptsec
}
</button>
<p class="description">
Enter a password to encrypt your private key. The resulting ncryptsec can be
used to securely backup or transfer your key.
</p>
<lib-toast #toast [bottom]="16"></lib-toast>

View File

@@ -0,0 +1,70 @@
:host {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
.header-pane {
display: flex;
flex-direction: row;
column-gap: var(--size-h);
align-items: center;
padding-bottom: var(--size);
background-color: var(--background);
position: sticky;
top: 0;
}
}
.description {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: var(--size);
}
.password-section {
margin-bottom: var(--size);
label {
font-weight: 500;
margin-bottom: var(--size-q);
}
}
.generate-btn {
width: 100%;
margin-bottom: var(--size);
}
.qr-container {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: var(--size);
}
.qr-button {
background: white;
padding: var(--size);
border-radius: 8px;
border: none;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&:active {
transform: scale(0.98);
}
}
.qr-code {
width: 250px;
height: 250px;
display: block;
}

View File

@@ -0,0 +1,100 @@
import {
AfterViewInit,
Component,
ElementRef,
inject,
OnInit,
ViewChild,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
IconButtonComponent,
NavComponent,
NostrHelper,
StorageService,
ToastComponent,
} from '@common';
import { FormsModule } from '@angular/forms';
import * as QRCode from 'qrcode';
@Component({
selector: 'app-ncryptsec',
imports: [IconButtonComponent, FormsModule, ToastComponent],
templateUrl: './ncryptsec.component.html',
styleUrl: './ncryptsec.component.scss',
})
export class NcryptsecComponent
extends NavComponent
implements OnInit, AfterViewInit
{
@ViewChild('passwordInput') passwordInput!: ElementRef<HTMLInputElement>;
privkeyHex = '';
ncryptsecPassword = '';
ncryptsec = '';
ncryptsecQr = '';
isGenerating = false;
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
ngOnInit(): void {
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
if (!identityId) {
return;
}
this.#initialize(identityId);
}
ngAfterViewInit(): void {
this.passwordInput.nativeElement.focus();
}
async generateNcryptsec() {
if (!this.privkeyHex || !this.ncryptsecPassword) {
return;
}
this.isGenerating = true;
this.ncryptsec = '';
this.ncryptsecQr = '';
try {
this.ncryptsec = await NostrHelper.privkeyToNcryptsec(
this.privkeyHex,
this.ncryptsecPassword
);
// Generate QR code
this.ncryptsecQr = await QRCode.toDataURL(this.ncryptsec, {
width: 250,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
} catch (error) {
console.error('Failed to generate ncryptsec:', error);
} finally {
this.isGenerating = false;
}
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
#initialize(identityId: string) {
const identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === identityId);
if (!identity) {
return;
}
this.privkeyHex = identity.privkey;
}
}

View File

@@ -7,7 +7,12 @@
<span class="text-muted" style="font-size: 12px">
Nothing configured so far.
</span>
} @for(hostPermissions of hostsPermissions; track hostPermissions) {
} @else {
<button class="btn btn-danger btn-sm remove-all-btn" (click)="onClickRemoveAllPermissions()">
Remove All Permissions
</button>
}
@for(hostPermissions of hostsPermissions; track hostPermissions) {
<div class="permissions-card">
<span style="margin-bottom: 4px; font-weight: 500">
{{ hostPermissions.host }}
@@ -22,7 +27,7 @@
>
<span class="text-muted">{{ permission.method }}</span>
@if(typeof permission.kind !== 'undefined') {
<span>(kind {{ permission.kind }})</span>
<span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
}
<div class="sam-flex-grow"></div>
<lib-icon-button

View File

@@ -17,6 +17,10 @@
top: 0;
}
.remove-all-btn {
margin-bottom: var(--size);
}
.permissions-card {
background: var(--background-light);
border-radius: 8px;

View File

@@ -5,6 +5,7 @@ import {
NavComponent,
Permission_DECRYPTED,
StorageService,
getKindName,
} from '@common';
import { ActivatedRoute } from '@angular/router';
@@ -41,6 +42,14 @@ export class PermissionsComponent extends NavComponent implements OnInit {
this.#buildHostsPermissions(this.identity?.id);
}
async onClickRemoveAllPermissions() {
const allPermissions = this.hostsPermissions.flatMap(hp => hp.permissions);
for (const permission of allPermissions) {
await this.#storage.deletePermission(permission.id);
}
this.#buildHostsPermissions(this.identity?.id);
}
#initialize(identityId: string) {
this.identity = this.#storage
.getBrowserSessionHandler()
@@ -78,4 +87,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
});
});
}
getKindTooltip(kind: number): string {
return getKindName(kind);
}
}

View File

@@ -6,23 +6,16 @@
<div class="sam-flex-row gap-h">
<lib-relay-rw
type="read"
[(model)]="relay.read"
(modelChange)="onRelayChanged(relay)"
[model]="relay.read"
[readonly]="true"
></lib-relay-rw>
<lib-relay-rw
type="write"
[(model)]="relay.write"
(modelChange)="onRelayChanged(relay)"
[model]="relay.write"
[readonly]="true"
></lib-relay-rw>
</div>
</div>
<lib-icon-button
icon="trash"
title="Remove relay"
(click)="onClickRemoveRelay(relay)"
style="margin-top: 4px"
></lib-icon-button>
</div>
</ng-template>
@@ -31,48 +24,37 @@
icon="chevron-left"
(click)="navigateBack()"
></lib-icon-button>
<span>Relays</span>
<span class="header-title">Relays</span>
</div>
<div class="sam-mb-2 sam-flex-row gap">
<div class="sam-flex-column sam-flex-grow">
<input
type="text"
(focus)="addRelayInputHasFocus = true"
(blur)="addRelayInputHasFocus = false"
[placeholder]="addRelayInputHasFocus ? 'server.com' : 'Add a relay'"
class="form-control"
[(ngModel)]="newRelay.url"
(ngModelChange)="evaluateCanAdd()"
/>
<div class="sam-flex-row gap-h" style="margin-top: 4px">
<lib-relay-rw
class="sam-flex-grow"
type="read"
[(model)]="newRelay.read"
(modelChange)="evaluateCanAdd()"
></lib-relay-rw>
<lib-relay-rw
class="sam-flex-grow"
type="write"
[(model)]="newRelay.write"
(modelChange)="evaluateCanAdd()"
></lib-relay-rw>
</div>
</div>
<button
type="button"
class="btn btn-primary"
style="height: 100%"
(click)="onClickAddRelay()"
[disabled]="!canAdd"
>
Add
</button>
<div class="info-banner">
<span class="emoji">💡</span>
<span>These relays are fetched from your NIP-65 relay list (kind 10002). To update your relay list, use a Nostr client that supports NIP-65.</span>
</div>
@for(relay of relays; track relay) {
@if(loading) {
<div class="loading-state">
<i class="bi bi-circle color-activity"></i>
<span>Fetching relay list...</span>
</div>
}
@if(!loading && errorMessage) {
<div class="error-state">
<i class="bi bi-exclamation-triangle sam-color-danger"></i>
<span>{{ errorMessage }}</span>
</div>
}
@if(!loading && !errorMessage && relays.length === 0) {
<div class="empty-state">
<i class="bi bi-broadcast"></i>
<span>No relay list found</span>
<span class="hint">Publish a NIP-65 relay list using a Nostr client to see your relays here.</span>
</div>
}
@for(relay of relays; track relay.url) {
<ng-container
*ngTemplateOutlet="relayTemplate; context: { relay: relay }"
></ng-container>

View File

@@ -17,14 +17,81 @@
top: 0;
}
.header-title {
font-family: var(--font-heading);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.05rem;
}
.info-banner {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: var(--size-h);
padding: var(--size-h) var(--size);
margin-bottom: var(--size);
background: var(--background-light);
border-radius: var(--radius-md);
border: 1px solid var(--border);
i {
color: var(--primary);
font-size: 14px;
margin-top: 2px;
}
span {
font-size: 12px;
color: var(--muted-foreground);
line-height: 1.4;
}
}
.loading-state,
.empty-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--size-h);
padding: var(--size-2);
color: var(--muted-foreground);
i {
font-size: 32px;
}
span {
font-size: 14px;
}
.hint {
font-size: 12px;
text-align: center;
max-width: 280px;
}
}
.color-activity {
color: var(--muted-foreground);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
.relay {
margin-bottom: 4px;
padding: 4px 8px 6px 8px;
border-radius: 8px;
background: var(--background-light);
&:hover {
background: var(--background-light-hover);
}
}
}

View File

@@ -1,28 +1,22 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import {
IconButtonComponent,
Identity_DECRYPTED,
NavComponent,
Relay_DECRYPTED,
Nip65Relay,
NostrHelper,
RelayListService,
RelayRwComponent,
StorageService,
VisualRelayPipe,
} from '@common';
interface NewRelay {
url: string;
read: boolean;
write: boolean;
};
@Component({
selector: 'app-relays',
imports: [
IconButtonComponent,
FormsModule,
RelayRwComponent,
NgTemplateOutlet,
VisualRelayPipe,
@@ -32,100 +26,52 @@ interface NewRelay {
})
export class RelaysComponent extends NavComponent implements OnInit {
identity?: Identity_DECRYPTED;
relays: Relay_DECRYPTED[] = [];
addRelayInputHasFocus = false;
newRelay: NewRelay = {
url: '',
read: true,
write: true,
};
canAdd = false;
relays: Nip65Relay[] = [];
loading = true;
errorMessage = '';
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
readonly #relayListService = inject(RelayListService);
ngOnInit(): void {
const selectedIdentityId =
this.#activatedRoute.parent?.snapshot.params['id'];
if (!selectedIdentityId) {
this.loading = false;
return;
}
this.#loadData(selectedIdentityId);
}
evaluateCanAdd() {
let canAdd = true;
if (!this.newRelay.url) {
canAdd = false;
} else if (!this.newRelay.read && !this.newRelay.write) {
canAdd = false;
}
this.canAdd = canAdd;
}
async onClickRemoveRelay(relay: Relay_DECRYPTED) {
if (!this.identity) {
return;
}
async #loadData(identityId: string) {
try {
await this.#storage.deleteRelay(relay.id);
this.#loadData(this.identity.id);
this.loading = true;
this.errorMessage = '';
this.identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === identityId);
if (!this.identity) {
this.loading = false;
this.errorMessage = 'Identity not found';
return;
}
// Get the pubkey for this identity
const pubkey = NostrHelper.pubkeyFromPrivkey(this.identity.privkey);
// Fetch NIP-65 relay list
const nip65Relays = await this.#relayListService.fetchRelayList(pubkey);
this.relays = nip65Relays;
this.loading = false;
} catch (error) {
console.log(error);
// TODO
console.error('Failed to load relay list:', error);
this.loading = false;
this.errorMessage = 'Failed to fetch relay list';
}
}
async onClickAddRelay() {
if (!this.identity) {
return;
}
try {
await this.#storage.addRelay({
identityId: this.identity.id,
url: 'wss://' + this.newRelay.url.toLowerCase(),
read: this.newRelay.read,
write: this.newRelay.write,
});
this.newRelay = {
url: '',
read: true,
write: true,
};
this.evaluateCanAdd();
this.#loadData(this.identity.id);
} catch (error) {
console.log(error);
// TODO
}
}
async onRelayChanged(relay: Relay_DECRYPTED) {
try {
await this.#storage.updateRelay(relay);
} catch (error) {
console.log(error);
// TODO
}
}
#loadData(identityId: string) {
this.identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === identityId);
const relays: Relay_DECRYPTED[] = [];
(this.#storage.getBrowserSessionHandler().browserSessionData?.relays ?? [])
.filter((x) => x.identityId === identityId)
.forEach((x) => {
relays.push(JSON.parse(JSON.stringify(x)));
});
this.relays = relays;
}
}

View File

@@ -0,0 +1,86 @@
<div class="sam-text-header">
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>
</button>
<span>Backups</span>
</div>
<div class="backup-settings">
<div class="setting-row">
<label for="maxBackups">Max Auto Backups:</label>
<input
id="maxBackups"
type="number"
[value]="maxBackups"
min="1"
max="20"
(change)="onMaxBackupsChange($event)"
/>
</div>
<p class="setting-note">
Automatic backups are created when significant changes are made.
Manual and pre-restore backups are not counted toward this limit.
</p>
</div>
<button class="btn btn-primary create-btn" (click)="createManualBackup()">
Create Backup Now
</button>
<div class="backups-list">
@if (backups.length === 0) {
<div class="empty-state">
<span>No backups yet</span>
</div>
}
@for (backup of backups; track backup.id) {
<div class="backup-item">
<div class="backup-info">
<span class="backup-date">{{ formatDate(backup.createdAt) }}</span>
<div class="backup-meta">
<span class="backup-reason" [class]="getReasonClass(backup.reason)">
{{ getReasonLabel(backup.reason) }}
</span>
<span class="backup-identities">{{ backup.identityCount }} identity(ies)</span>
</div>
</div>
<div class="backup-actions">
<button
class="btn btn-sm btn-secondary"
(click)="
confirm.show(
'Restore this backup? A backup of your current state will be created first.',
restoreBackup.bind(this, backup.id)
)
"
[disabled]="restoringBackupId !== null"
>
{{ restoringBackupId === backup.id ? 'Restoring...' : 'Restore' }}
</button>
<button
class="btn btn-sm btn-danger"
(click)="
confirm.show(
'Delete this backup? This cannot be undone.',
deleteBackup.bind(this, backup.id)
)
"
>
Delete
</button>
</div>
</div>
}
</div>
<lib-confirm #confirm></lib-confirm>

View File

@@ -0,0 +1,192 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
gap: 12px;
}
.sam-text-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: bold;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.lock-btn,
.back-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
&:hover {
background: var(--muted);
}
.emoji {
font-size: 16px;
}
}
.backup-settings {
background: var(--muted);
padding: 12px;
border-radius: 8px;
}
.setting-row {
display: flex;
align-items: center;
gap: 12px;
label {
font-weight: 500;
}
input[type="number"] {
width: 60px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--foreground);
}
}
.setting-note {
margin-top: 8px;
font-size: 12px;
color: var(--muted-foreground);
}
.create-btn {
align-self: flex-start;
}
.backups-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 100px;
color: var(--muted-foreground);
}
.backup-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
gap: 12px;
}
.backup-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.backup-date {
font-weight: 500;
font-size: 13px;
}
.backup-meta {
display: flex;
gap: 8px;
font-size: 11px;
}
.backup-reason {
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
&.reason-auto {
background: var(--muted);
color: var(--muted-foreground);
}
&.reason-manual {
background: rgba(34, 197, 94, 0.2);
color: rgb(34, 197, 94);
}
&.reason-prerestore {
background: rgba(234, 179, 8, 0.2);
color: rgb(234, 179, 8);
}
}
.backup-identities {
color: var(--muted-foreground);
}
.backup-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
&:hover:not(:disabled) {
opacity: 0.9;
}
}
.btn-secondary {
background: var(--secondary);
color: var(--secondary-foreground);
&:hover:not(:disabled) {
background: var(--muted);
}
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: rgb(239, 68, 68);
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.3);
}
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}

View File

@@ -0,0 +1,125 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
ConfirmComponent,
LoggerService,
NavComponent,
SignerMetaData_VaultSnapshot,
StartupService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
@Component({
selector: 'app-backups',
templateUrl: './backups.component.html',
styleUrl: './backups.component.scss',
imports: [ConfirmComponent],
})
export class BackupsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
backups: SignerMetaData_VaultSnapshot[] = [];
maxBackups = 5;
restoringBackupId: string | null = null;
ngOnInit(): void {
this.loadBackups();
this.maxBackups = this.storage.getSignerMetaHandler().getMaxBackups();
}
loadBackups(): void {
this.backups = this.storage.getSignerMetaHandler().getBackups();
}
async onMaxBackupsChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value, 10);
if (!isNaN(value) && value >= 1 && value <= 20) {
this.maxBackups = value;
await this.storage.getSignerMetaHandler().setMaxBackups(value);
}
}
async createManualBackup(): Promise<void> {
const browserSyncData = this.storage.getBrowserSyncHandler().browserSyncData;
if (browserSyncData) {
await this.storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
this.loadBackups();
}
}
async restoreBackup(backupId: string): Promise<void> {
this.restoringBackupId = backupId;
try {
// First, create a pre-restore backup of current state
const currentData = this.storage.getBrowserSyncHandler().browserSyncData;
if (currentData) {
await this.storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
}
// Get the backup data
const backupData = this.storage.getSignerMetaHandler().getBackupData(backupId);
if (!backupData) {
throw new Error('Backup not found');
}
// Import the backup
await this.storage.deleteVault(true);
await this.storage.importVault(backupData);
this.#logger.logVaultImport('Backup Restore');
this.storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to restore backup:', error);
this.restoringBackupId = null;
}
}
async deleteBackup(backupId: string): Promise<void> {
await this.storage.getSignerMetaHandler().deleteBackup(backupId);
this.loadBackups();
}
formatDate(isoDate: string): string {
const date = new Date(isoDate);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
getReasonLabel(reason?: string): string {
switch (reason) {
case 'auto':
return 'Auto';
case 'manual':
return 'Manual';
case 'pre-restore':
return 'Pre-Restore';
default:
return 'Unknown';
}
}
getReasonClass(reason?: string): string {
switch (reason) {
case 'auto':
return 'reason-auto';
case 'manual':
return 'reason-manual';
case 'pre-restore':
return 'reason-prerestore';
default:
return '';
}
}
goBack(): void {
this.#router.navigateByUrl('/home/settings');
}
async onClickLock(): Promise<void> {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -0,0 +1,46 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Bookmarks</span>
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
<span class="emoji"></span>
</button>
</div>
<div class="bookmarks-container">
@if (isLoading) {
<div class="loading-state">Loading...</div>
} @else if (bookmarks.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
No bookmarks yet. Click "Bookmark This Page" to add the current page.
</span>
</div>
} @else {
@for (bookmark of bookmarks; track bookmark.id) {
<div class="bookmark-item" (click)="openBookmark(bookmark)">
<div class="bookmark-info">
<span class="bookmark-title">{{ bookmark.title }}</span>
<span class="bookmark-url">{{ getDomain(bookmark.url) }}</span>
</div>
<button
class="remove-btn"
title="Remove bookmark"
(click)="onRemoveBookmark(bookmark); $event.stopPropagation()"
>
<span class="emoji"></span>
</button>
</div>
}
}
</div>

View File

@@ -0,0 +1,112 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-top: var(--size);
padding-bottom: var(--size);
overflow: hidden;
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.sam-text-header {
margin-bottom: var(--size);
flex-shrink: 0;
.add-btn {
position: absolute;
right: 0;
background: transparent;
border: none;
padding: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-light);
}
.emoji {
font-size: 20px;
}
}
}
}
.bookmarks-container {
flex: 1;
overflow-y: auto;
}
.empty-state,
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted-foreground);
}
.bookmark-item {
display: flex;
align-items: center;
gap: var(--size-h);
padding: var(--size-h) var(--size);
margin-bottom: var(--size-hh);
background: var(--background-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: var(--background-light-hover);
}
}
.bookmark-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.bookmark-title {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-url {
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-btn {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
color: var(--muted-foreground);
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--destructive);
color: var(--destructive-foreground);
}
}

View File

@@ -0,0 +1,98 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Bookmark, LoggerService, NavComponent, SignerMetaData } from '@common';
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
@Component({
selector: 'app-bookmarks',
templateUrl: './bookmarks.component.html',
styleUrl: './bookmarks.component.scss',
imports: [],
})
export class BookmarksComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #metaHandler = new ChromeMetaHandler();
readonly #router = inject(Router);
bookmarks: Bookmark[] = [];
isLoading = true;
async ngOnInit() {
await this.loadBookmarks();
}
async loadBookmarks() {
this.isLoading = true;
try {
const metaData = await this.#metaHandler.loadFullData() as SignerMetaData;
this.#metaHandler.setFullData(metaData);
this.bookmarks = this.#metaHandler.getBookmarks();
} catch (error) {
console.error('Failed to load bookmarks:', error);
} finally {
this.isLoading = false;
}
}
async onBookmarkThisPage() {
try {
// Get the current tab URL and title
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.url || !tab?.title) {
console.error('Could not get current tab info');
return;
}
// Check if already bookmarked
if (this.bookmarks.some(b => b.url === tab.url)) {
console.log('Page already bookmarked');
return;
}
const newBookmark: Bookmark = {
id: crypto.randomUUID(),
url: tab.url,
title: tab.title,
createdAt: Date.now(),
};
this.bookmarks = [newBookmark, ...this.bookmarks];
await this.saveBookmarks();
this.#logger.logBookmarkAdded(newBookmark.url, newBookmark.title);
} catch (error) {
console.error('Failed to bookmark page:', error);
}
}
async onRemoveBookmark(bookmark: Bookmark) {
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmark.id);
await this.saveBookmarks();
this.#logger.logBookmarkRemoved(bookmark.url, bookmark.title);
}
async saveBookmarks() {
try {
await this.#metaHandler.setBookmarks(this.bookmarks);
} catch (error) {
console.error('Failed to save bookmarks:', error);
}
}
openBookmark(bookmark: Bookmark) {
chrome.tabs.create({ url: bookmark.url });
}
getDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -3,34 +3,23 @@
</div>
<div class="tabs">
<a
class="tab"
routerLink="/home/identity"
routerLinkActive="active"
title="Your selected identity"
>
<i class="bi bi-person-circle"></i>
<a class="tab" routerLink="/home/identity" routerLinkActive="active" title="You">
<span class="emoji">👤</span>
</a>
<a
class="tab"
routerLink="/home/identities"
routerLinkActive="active"
title="Identities"
>
<i class="bi bi-people-fill"></i>
<a class="tab" routerLink="/home/identities" routerLinkActive="active" title="Identities">
<span class="emoji">👥</span>
</a>
<a
class="tab"
routerLink="/home/settings"
routerLinkActive="active"
title="Settings"
>
<i class="bi bi-gear"></i>
<a class="tab" routerLink="/home/wallet" routerLinkActive="active" title="Wallet">
<span class="emoji">💰</span>
</a>
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
<i class="bi bi-info-circle"></i>
<a class="tab" routerLink="/home/bookmarks" routerLinkActive="active" title="Bookmarks">
<span class="emoji">🔖</span>
</a>
<a class="tab" routerLink="/home/settings" routerLinkActive="active" title="Settings">
<span class="emoji">⚙️</span>
</a>
</div>

View File

@@ -4,17 +4,17 @@
flex-direction: column;
.tab-content {
height: calc(100% - 60px);
height: calc(100% - 48px);
}
.tabs {
height: 60px;
min-height: 60px;
height: 48px;
min-height: 48px;
background: var(--background-light);
display: flex;
flex-direction: row;
a {
a, button {
all: unset;
}
@@ -23,20 +23,25 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: gray;
border-top: 3px solid transparent;
color: var(--muted-foreground);
border-top: 2px solid transparent;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
cursor: pointer;
.emoji {
font-size: 20px;
}
&:hover {
background: var(--background-light-hover);
color: var(--foreground);
}
&.active {
color: #ffffff;
border-top: 3px solid #0d6efd;
color: var(--foreground);
border-top: 2px solid var(--primary);
}
}
}

View File

@@ -1,78 +1,79 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="custom-header" style="position: sticky; top: 0">
<span class="text">Identities </span>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span class="text">Identities</span>
<button class="button btn btn-primary btn-sm" (click)="onClickNewIdentity()">
<div class="sam-flex-row gap-h">
<i class="bi bi-plus-lg"></i>
<span>New</span>
</div>
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">
<span class="emoji"></span>
</button>
</div>
<div class="reckless-mode-row">
<label class="reckless-label" (click)="onToggleRecklessMode()">
<input
type="checkbox"
[checked]="isRecklessMode"
(click)="$event.stopPropagation()"
(change)="onToggleRecklessMode()"
/>
<span
class="reckless-text"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
title="Auto-approve all actions. If whitelist has entries, only those apps are auto-approved."
>Reckless mode</span>
</label>
<button
class="gear-btn"
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<span class="emoji">⚙️</span>
</button>
</div>
@let sessionData = storage.getBrowserSessionHandler().browserSessionData;
<!-- - -->
@let identities = sessionData?.identities ?? []; @if(identities.length === 0) {
<div
style="
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
"
>
@let identities = sessionData?.identities ?? [];
@if(identities.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
Create your first identity by clicking on the button in the upper right
corner.
</span>
</div>
}
} @for(identity of identities; track identity) {
<div
class="identity"
style="overflow: hidden"
(click)="onClickEditIdentity(identity)"
>
@for(identity of identities; track identity.id) {
@let isSelected = identity.id === sessionData?.selectedIdentityId;
<span
class="no-select"
style="overflow-x: hidden; text-overflow: ellipsis; white-space: nowrap"
[class.not-active]="!isSelected"
<div
class="identity"
[class.selected]="isSelected"
(click)="onClickSelectIdentity(identity.id)"
>
{{ identity.nick }}
</span>
<div class="sam-flex-grow"></div>
@if(isSelected) {
<lib-icon-button
icon="star-fill"
title="Edit identity"
style="pointer-events: none; color: var(--bs-pink)"
></lib-icon-button>
}
<div class="buttons sam-flex-row gap-h">
@if(!isSelected) {
<img
class="avatar"
[src]="getAvatarUrl(identity)"
alt=""
(error)="$any($event.target).src = 'person-fill.svg'"
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button
icon="star-fill"
title="Select identity"
(click)="
onClickSwitchIdentity(identity.id, $event);
toast.show('Identity changed')
"
icon="⚙️"
title="Identity settings"
(click)="onClickEditIdentity(identity.id, $event)"
></lib-icon-button>
}
</div>
<lib-icon-button
icon="arrow-right"
title="Edit identity"
style="pointer-events: none"
></lib-icon-button>
</div>
}
<lib-toast #toast></lib-toast>

View File

@@ -3,66 +3,163 @@
display: flex;
flex-direction: column;
overflow-y: auto;
padding-left: var(--size);
padding-right: var(--size);
> *:not(.custom-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.custom-header {
padding-top: var(--size);
padding-bottom: var(--size);
height: 48px;
min-height: 48px;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto;
align-items: center;
background: var(--background);
position: relative;
.button {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
justify-self: end;
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.header-btn,
.add-btn {
background: transparent;
border: none;
padding: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-light);
}
.emoji {
font-size: 20px;
}
}
.add-btn {
position: absolute;
right: 0;
}
.text {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-size: 20px;
font-weight: 500;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 700;
letter-spacing: 0.1rem;
justify-self: center;
height: 32px;
}
}
.identity {
height: 48px;
min-height: 48px;
.reckless-mode-row {
display: flex;
flex-direction: row;
align-items: center;
padding-left: 16px;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: 12px;
background: var(--background-light);
border-radius: 8px;
.reckless-label {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
.reckless-text {
font-size: 14px;
color: var(--foreground);
}
}
.gear-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--foreground);
background: var(--background-light-hover);
}
i {
font-size: 16px;
}
}
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.identity {
height: 56px;
min-height: 56px;
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding-left: 12px;
padding-right: 8px;
background: var(--background-light);
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
.not-active {
//color: #525b6a;
opacity: 0.4;
}
transition: background-color 0.15s ease;
&:hover {
background: var(--background-light-hover);
.buttons {
visibility: visible;
}
}
.buttons {
visibility: hidden;
&.selected {
background: rgba(255, 62, 181, 0.15);
border: 1px solid var(--primary);
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--muted);
}
.name {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
}
}

View File

@@ -1,8 +1,13 @@
import { Component, inject } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
IconButtonComponent,
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
StorageService,
ToastComponent,
} from '@common';
@@ -13,21 +18,68 @@ import {
styleUrl: './identities.component.scss',
imports: [IconButtonComponent, ToastComponent],
})
export class IdentitiesComponent {
readonly storage = inject(StorageService);
export class IdentitiesComponent extends NavComponent implements OnInit {
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
// Cache of pubkey -> profile for quick lookup
#profileCache = new Map<string, ProfileMetadata | null>();
get isRecklessMode(): boolean {
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
}
async ngOnInit() {
await this.#profileMetadata.initialize();
this.#loadProfiles();
}
#loadProfiles() {
const identities = this.storage.getBrowserSessionHandler().browserSessionData?.identities ?? [];
for (const identity of identities) {
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
const profile = this.#profileMetadata.getCachedProfile(pubkey);
this.#profileCache.set(identity.id, profile);
}
}
getAvatarUrl(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id);
return profile?.picture || 'person-fill.svg';
}
getDisplayName(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id) ?? null;
return this.#profileMetadata.getDisplayName(profile) || identity.nick;
}
onClickNewIdentity() {
this.#router.navigateByUrl('/new-identity');
}
onClickEditIdentity(identity: Identity_DECRYPTED) {
this.#router.navigateByUrl(`/edit-identity/${identity.id}/home`);
onClickEditIdentity(identityId: string, event: MouseEvent) {
event.stopPropagation();
this.#router.navigateByUrl(`/edit-identity/${identityId}/home`);
}
async onClickSwitchIdentity(identityId: string, event: MouseEvent) {
event.stopPropagation();
async onClickSelectIdentity(identityId: string) {
await this.storage.switchIdentity(identityId);
}
async onToggleRecklessMode() {
const newValue = !this.isRecklessMode;
await this.storage.getSignerMetaHandler().setRecklessMode(newValue);
}
onClickWhitelistedApps() {
this.#router.navigateByUrl('/whitelisted-apps');
}
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,56 +1,91 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>You</span>
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
<span class="emoji">📝</span>
</button>
</div>
<div class="vertically-centered">
<div class="sam-flex-column center">
<div class="sam-flex-column gap center">
<div class="picture-frame" [class.padding]="!loadedData.profile?.image">
<div class="identity-container">
<!-- Banner background -->
<div
class="banner-background"
[style.background-image]="bannerUrl ? 'url(' + bannerUrl + ')' : 'none'"
>
<div class="banner-overlay"></div>
<div class="profile-content">
<!-- Avatar -->
<div class="avatar-frame" [class.has-image]="avatarUrl">
<img
[src]="
!loadedData.profile?.image
? 'person-fill.svg'
: loadedData.profile?.image
"
[src]="avatarUrl || 'person-fill.svg'"
alt=""
class="avatar-image"
/>
</div>
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
<span class="name" (click)="onClickShowDetails()">
{{ selectedIdentity?.nick }}
</span>
<!-- Display name (primary, large) -->
<div class="name-badge-container" (click)="onClickShowDetails()">
<span class="display-name">
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
</span>
@if(username) {
<span class="username">
{{ username }}
</span>
}
</div>
@if(loadedData.profile) {
<div class="sam-flex-row gap-h">
@if(loadedData.validating) {
<!-- NIP-05 verification -->
@if(profile?.nip05) {
<div class="nip05-row">
@if(validating) {
<i class="bi bi-circle color-activity"></i>
} @else { @if(loadedData.nip05isValidated) {
} @else { @if(nip05isValidated) {
<i class="bi bi-patch-check sam-color-primary"></i>
} @else {
<i class="bi bi-exclamation-octagon-fill sam-color-danger"></i>
} }
<span class="sam-color-primary">{{
loadedData.profile.nip05 | visualNip05
<span class="nip05-badge">{{
profile?.nip05 | visualNip05
}}</span>
</div>
} @else {
<span>&nbsp;</span>
}
<lib-pubkey
[value]="selectedIdentityNpub ?? 'na'"
[first]="14"
[last]="8"
(click)="
copyToClipboard(selectedIdentityNpub);
toast.show('Copied to clipboard')
"
></lib-pubkey>
<!-- npub display -->
<div class="npub-wrapper">
<lib-pubkey
[value]="selectedIdentityNpub ?? 'na'"
[first]="14"
[last]="8"
(click)="
copyToClipboard(selectedIdentityNpub);
toast.show('Copied to clipboard')
"
></lib-pubkey>
</div>
</div>
</div>
</div>
<!-- About section -->
@if (aboutText) {
<div class="about-section">
<div class="about-header">About</div>
<div class="about-content">{{ aboutText }}</div>
</div>
}
<lib-toast #toast></lib-toast>

View File

@@ -3,39 +3,215 @@
display: flex;
flex-direction: column;
.vertically-centered {
height: 100%;
.sam-text-header {
.edit-btn {
position: absolute;
right: 0;
background: transparent;
border: none;
padding: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-light);
}
.emoji {
font-size: 20px;
}
}
}
.identity-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.banner-background {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
background-size: cover;
background-position: center;
background-color: var(--background-light);
// Create square aspect ratio centered on vertical
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: inherit;
background-size: cover;
background-position: center center;
}
}
.name {
font-size: 20px;
font-weight: 500;
cursor: pointer;
max-width: 343px;
overflow-x: hidden;
text-overflow: ellipsis;
.banner-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.5) 50%,
rgba(0, 0, 0, 0.3) 100%
);
z-index: 1;
}
.picture-frame {
.profile-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
}
.avatar-frame {
height: 120px;
width: 120px;
border: 2px solid white;
border: 3px solid rgba(255, 255, 255, 0.9);
border-radius: 100%;
&.padding {
padding: 12px;
background: var(--background);
padding: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
&.has-image {
padding: 0;
}
img {
.avatar-image {
border-radius: 100%;
width: 100%;
height: 100%;
object-fit: cover;
}
}
// Common badge styling - rounded corners, black background
%text-badge {
background-color: rgba(0, 0, 0, 0.75);
padding: 6px 14px;
border-radius: 6px;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
backdrop-filter: blur(4px);
}
.name-badge-container {
@extend %text-badge;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
white-space: normal;
text-align: center;
&:hover {
background-color: rgba(0, 0, 0, 0.85);
}
.display-name {
font-family: var(--font-heading);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.05rem;
color: #ffffff;
}
.username {
font-size: 13px;
font-weight: 400;
color: rgba(255, 255, 255, 0.85);
}
}
.nip05-row {
@extend %text-badge;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
i {
font-size: 16px;
}
}
.nip05-badge {
font-size: 13px;
color: var(--primary);
}
.npub-wrapper {
@extend %text-badge;
padding: 8px 14px;
lib-pubkey {
cursor: pointer;
}
}
.color-activity {
color: var(--bs-border-color);
color: var(--muted-foreground);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
.about-section {
margin: var(--size);
margin-top: 0;
flex-shrink: 0;
max-height: 150px;
display: flex;
flex-direction: column;
.about-header {
font-size: 0.85rem;
font-weight: 600;
color: var(--muted-foreground);
margin-bottom: var(--size-h);
}
.about-content {
flex: 1;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.5;
color: var(--foreground);
background: var(--background-light);
border-radius: var(--radius-sm);
padding: var(--size);
white-space: pre-wrap;
word-break: break-word;
}
}
}

View File

@@ -2,20 +2,16 @@ import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
PubkeyComponent,
StorageService,
ToastComponent,
VisualNip05Pipe,
validateNip05,
} from '@common';
import NDK, { NDKUserProfile } from '@nostr-dev-kit/ndk';
interface LoadedData {
profile: NDKUserProfile | undefined;
nip05: string | undefined;
nip05isValidated: boolean | undefined;
validating: boolean;
}
@Component({
selector: 'app-identity',
@@ -23,23 +19,42 @@ interface LoadedData {
templateUrl: './identity.component.html',
styleUrl: './identity.component.scss',
})
export class IdentityComponent implements OnInit {
export class IdentityComponent extends NavComponent implements OnInit {
selectedIdentity: Identity_DECRYPTED | undefined;
selectedIdentityNpub: string | undefined;
loadedData: LoadedData = {
profile: undefined,
nip05: undefined,
nip05isValidated: undefined,
validating: false,
};
profile: ProfileMetadata | null = null;
nip05isValidated: boolean | undefined;
validating = false;
loading = true;
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
this.#loadData();
}
get displayName(): string | undefined {
return this.#profileMetadata.getDisplayName(this.profile);
}
get username(): string | undefined {
return this.#profileMetadata.getUsername(this.profile);
}
get avatarUrl(): string | undefined {
return this.profile?.picture;
}
get bannerUrl(): string | undefined {
return this.profile?.banner;
}
get aboutText(): string | undefined {
return this.profile?.about;
}
copyToClipboard(pubkey: string | undefined) {
if (!pubkey) {
return;
@@ -57,19 +72,33 @@ export class IdentityComponent implements OnInit {
);
}
onClickEditProfile() {
if (!this.selectedIdentity) {
return;
}
this.#router.navigateByUrl('/profile-edit');
}
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
async #loadData() {
try {
const selectedIdentityId =
this.#storage.getBrowserSessionHandler().browserSessionData
this.storage.getBrowserSessionHandler().browserSessionData
?.selectedIdentityId ?? null;
const identity = this.#storage
const identity = this.storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find(
(x) => x.id === selectedIdentityId
);
if (!identity) {
this.loading = false;
return;
}
@@ -77,41 +106,59 @@ export class IdentityComponent implements OnInit {
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
this.selectedIdentityNpub = NostrHelper.pubkey2npub(pubkey);
// Determine the user's relays to check for his profile.
const relays =
this.#storage
.getBrowserSessionHandler()
.browserSessionData?.relays.filter(
(x) => x.identityId === identity.id
) ?? [];
if (relays.length === 0) {
return;
// Initialize the profile metadata service (loads cache from storage)
await this.#profileMetadata.initialize();
// Check if we have cached profile data
const cachedProfile = this.#profileMetadata.getCachedProfile(pubkey);
if (cachedProfile) {
this.profile = cachedProfile;
this.loading = false;
// Validate NIP-05 if present (in background)
if (cachedProfile.nip05) {
this.#validateNip05(pubkey, cachedProfile.nip05);
}
return; // Use cached data, don't fetch again
}
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
// Fetch the user's profile.
const ndk = new NDK({
explicitRelayUrls: relevantRelays,
});
await ndk.connect();
const user = ndk.getUser({
pubkey: NostrHelper.pubkeyFromPrivkey(identity.privkey),
//relayUrls: relevantRelays,
});
this.loadedData.profile = (await user.fetchProfile()) ?? undefined;
if (this.loadedData.profile?.nip05) {
this.loadedData.validating = true;
this.loadedData.nip05isValidated =
(await user.validateNip05(this.loadedData.profile.nip05)) ??
undefined;
this.loadedData.validating = false;
// No cached data, fetch from relays
this.loading = true;
const fetchedProfile = await this.#profileMetadata.fetchProfile(pubkey);
if (fetchedProfile) {
this.profile = fetchedProfile;
// Validate NIP-05 if present
if (fetchedProfile.nip05) {
this.#validateNip05(pubkey, fetchedProfile.nip05);
}
}
this.loading = false;
} catch (error) {
console.error(error);
// TODO
this.loading = false;
}
}
async #validateNip05(pubkey: string, nip05: string) {
try {
this.validating = true;
// Direct NIP-05 validation - fetches .well-known/nostr.json directly
const result = await validateNip05(nip05, pubkey);
this.nip05isValidated = result.valid;
if (result.valid) {
this.#logger.logNip05ValidationSuccess(nip05, pubkey);
} else {
this.#logger.logNip05ValidationError(nip05, result.error ?? 'Unknown error');
}
this.validating = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logNip05ValidationError(nip05, errorMsg);
this.nip05isValidated = false;
this.validating = false;
}
}
}

View File

@@ -1,37 +1,26 @@
<div class="sam-text-header">
<span> Gooti </span>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Plebeian Signer </span>
</div>
<span>Version {{ version }}</span>
<span>&nbsp;</span>
<span> Website </span>
<a href="https://getgooti.com" target="_blank">www.getgooti.com</a>
<span>&nbsp;</span>
<span> Source code</span>
<a
href="https://github.com/sam-hayes-org/gooti-extension"
href="https://github.com/PlebeianApp/plebeian-signer"
target="_blank"
>
github.com/sam-hayes-org/gooti-extension
github.com/PlebeianApp/plebeian-signer
</a>
<div class="sam-flex-grow"></div>
<div class="sam-card sam-mb" style="align-items: center">
<span>
Made with <i class="bi bi-heart-fill" style="color: red"></i> by
<a href="https://sam-hayes.org" target="_blank">Sam Hayes</a>
</span>
<lib-pubkey
class="sam-mt-h"
value="npub1tgyjshvelwj73t3jy0n3xllgt03elkapfl3k3n0x2wkunegkgrwssfp0u4"
(click)="toast.show('Copied to clipboard')"
></lib-pubkey>
</div>
<lib-toast #toast [bottom]="188"></lib-toast>

View File

@@ -4,6 +4,13 @@
flex-direction: column;
align-items: center;
overflow-y: auto;
padding-left: var(--size);
padding-right: var(--size);
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.sam-text-header {
width: 100%;
}
}

View File

@@ -1,13 +1,22 @@
import { Component } from '@angular/core';
import { PubkeyComponent, ToastComponent } from '@common';
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, NavComponent } from '@common';
import packageJson from '../../../../../../../package.json';
@Component({
selector: 'app-info',
imports: [PubkeyComponent, ToastComponent],
templateUrl: './info.component.html',
styleUrl: './info.component.scss',
})
export class InfoComponent {
export class InfoComponent extends NavComponent {
readonly #logger = inject(LoggerService);
readonly #router = inject(Router);
version = packageJson.custom.chrome.version;
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -0,0 +1,30 @@
<div class="sam-text-header">
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Logs</span>
<div class="logs-actions">
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>
<button class="btn btn-sm btn-secondary" title="Clear logs" (click)="onClear()">Clear</button>
</div>
</div>
<div class="logs-container">
@if (logs.length === 0) {
<div class="logs-empty">No activity logged yet</div>
}
@for (log of logs; track log.timestamp) {
<div class="log-entry" [class]="getLevelClass(log.level)">
<span class="log-icon emoji">{{ log.icon }}</span>
<span class="log-time">{{ log.timestamp | date:'HH:mm:ss' }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
}
</div>

View File

@@ -0,0 +1,88 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-top: var(--size);
padding-bottom: var(--size);
overflow: hidden;
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.sam-text-header {
margin-bottom: var(--size);
flex-shrink: 0;
.logs-actions {
position: absolute;
right: 0;
display: flex;
gap: 8px;
}
}
}
.logs-container {
flex: 1;
overflow-y: auto;
background: var(--background-light);
border-radius: 8px;
padding: var(--size-h);
}
.logs-empty {
color: var(--muted-foreground);
text-align: center;
padding: var(--size);
}
.log-entry {
font-family: var(--font-sans);
font-size: 11px;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 2px;
display: flex;
gap: 8px;
align-items: center;
&.log-error {
background: rgba(220, 53, 69, 0.15);
color: #ff6b6b;
}
&.log-warn {
background: rgba(255, 193, 7, 0.15);
color: #ffc107;
}
&.log-debug {
background: rgba(108, 117, 125, 0.15);
color: #adb5bd;
}
&.log-info {
background: rgba(13, 110, 253, 0.1);
color: var(--foreground);
}
}
.log-icon {
font-size: 14px;
flex-shrink: 0;
width: 18px;
text-align: center;
}
.log-time {
color: var(--muted-foreground);
flex-shrink: 0;
font-size: 10px;
}
.log-message {
flex: 1;
word-break: break-word;
}

View File

@@ -0,0 +1,51 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, LogEntry, NavComponent } from '@common';
import { DatePipe } from '@angular/common';
@Component({
selector: 'app-logs',
templateUrl: './logs.component.html',
styleUrl: './logs.component.scss',
imports: [DatePipe],
})
export class LogsComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #router = inject(Router);
get logs(): LogEntry[] {
return this.#logger.logs;
}
ngOnInit() {
// Refresh logs from storage to get background script logs
this.#logger.refreshLogs();
}
async onRefresh() {
await this.#logger.refreshLogs();
}
async onClear() {
await this.#logger.clear();
}
getLevelClass(level: LogEntry['level']): string {
switch (level) {
case 'error':
return 'log-error';
case 'warn':
return 'log-warn';
case 'debug':
return 'log-debug';
default:
return 'log-info';
}
}
async onClickLock() {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,19 +1,47 @@
<div class="sam-text-header">
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Settings </span>
</div>
<span>SYNC: {{ syncFlow }}</span>
<div class="vault-buttons">
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
</div>
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
<div class="dev-mode-row">
<label class="toggle-label">
<input type="checkbox" [checked]="devMode" (change)="onToggleDevMode($event)" />
<span>Dev Mode</span>
</label>
</div>
<div class="sam-flex-grow"></div>
<div class="sync-info">
<span class="sync-label">SYNC: {{ syncFlow }}</span>
<p class="sync-note">
To change sync mode, export your vault, reset the extension,
and re-import with the desired sync setting.
</p>
</div>
<button
class="btn btn-danger"
(click)="

View File

@@ -4,11 +4,57 @@
flex-direction: column;
row-gap: var(--size);
overflow-y: auto;
padding-left: var(--size);
padding-right: var(--size);
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.file-input {
position: absolute;
visibility: hidden;
}
}
.vault-buttons {
display: flex;
gap: var(--size);
button {
flex: 1;
}
}
.dev-mode-row {
display: flex;
align-items: center;
gap: var(--size);
.toggle-label {
display: flex;
align-items: center;
gap: var(--size-h);
cursor: pointer;
font-size: 0.9rem;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
}
.sync-info {
.sync-label {
display: block;
font-weight: 500;
}
.sync-note {
margin: var(--size-h) 0 0 0;
font-size: 0.85rem;
color: var(--muted-foreground);
line-height: 1.4;
}
}

Some files were not shown because too many files have changed in this diff Show More