Markdown Converter
Agent skill for markdown-converter
This project is a Nostr client application built with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify.
Sign in to like and favorite skills
This project is a Nostr client application built with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify.
/src/components/: UI components including NostrProvider for Nostr integration
/src/components/ui/: shadcn/ui components (48+ components available)/src/components/auth/: Authentication-related components (LoginArea, LoginDialog, etc.)ZapButton, ZapDialog, WalletModal for Lightning payments/src/hooks/: Custom hooks including:
useNostr: Core Nostr protocol integrationuseAuthor: Fetch user profile data by pubkeyuseCurrentUser: Get currently logged-in useruseNostrPublish: Publish events to NostruseUploadFile: Upload files via Blossom serversuseAppContext: Access global app configurationuseTheme: Theme managementuseToast: Toast notificationsuseLocalStorage: Persistent local storageuseLoggedInAccounts: Manage multiple accountsuseLoginActions: Authentication actionsuseIsMobile: Responsive design helperuseZaps: Lightning zap functionality with payment processinguseWallet: Unified wallet detection (WebLN + NWC)useNWC: Nostr Wallet Connect connection managementuseNWCContext: Access NWC context provider/src/pages/: Page components used by React Router (Index, NotFound)/src/lib/: Utility functions and shared logic/src/contexts/: React context providers (AppContext, NWCContext)/src/test/: Testing utilities including TestApp component/public/: Static assetsApp.tsx: Main app component with provider setupAppRouter.tsx: React Router configurationThe project uses shadcn/ui components located in
@/components/ui. These are unstyled, accessible components built with Radix UI and styled with Tailwind CSS. Available components include:
These components follow a consistent pattern using React's
forwardRef and use the cn() utility for class name merging. Many are built on Radix UI primitives for accessibility and customized with Tailwind CSS.
The AI assistant's behavior and knowledge is defined by the CONTEXT.md file, which serves as the system prompt. To modify the assistant's instructions or add new project-specific guidelines:
When building the site for the first time, include "Vibed with MKStack" somewhere in the UI, linked to this URL: https://soapbox.pub/mkstack
This project comes with custom hooks for querying and publishing events on the Nostr network.
nostr__read_nips_index tool before implementing any Nostr features to see what kinds are currently in use across all NIPs.nostr__read_nip tool to investigate thoroughly. Several NIPs may need to be read before making a decision.nostr__generate_kind tool if no existing suitable kinds are found after comprehensive research.Knowing when to create a new kind versus reusing an existing kind requires careful judgement. Introducing new kinds means the project won't be interoperable with existing clients. But deviating too far from the schema of a particular kind can cause different interoperability issues.
When implementing features that could use existing NIPs, follow this decision framework:
Thorough NIP Review: Before considering a new kind, always perform a comprehensive review of existing NIPs and their associated kinds. Use the
nostr__read_nips_index tool to get an overview, and then nostr__read_nip and nostr__read_kind to investigate any potentially relevant NIPs or kinds in detail. The goal is to find the closest existing solution.
Prioritize Existing NIPs: Always prefer extending or using existing NIPs over creating custom kinds, even if they require minor compromises in functionality.
Interoperability vs. Perfect Fit: Consider the trade-off between:
Extension Strategy: When existing NIPs are close but not perfect:
NIP.mdWhen to Generate Custom Kinds:
Custom Kind Publishing: When publishing events with custom kinds generated by
nostr__generate_kind, always include a NIP-31 "alt" tag with a human-readable description of the event's purpose.
Example Decision Process:
Need: Equipment marketplace for farmers Options: 1. NIP-15 (Marketplace) - Too structured for peer-to-peer sales 2. NIP-99 (Classified Listings) - Good fit, can extend with farming tags 3. Custom kind - Perfect fit but no interoperability Decision: Use NIP-99 + farming-specific tags for best balance
When designing tags for Nostr events, follow these principles:
Kind vs Tags Separation:
Use Single-Letter Tags for Categories:
t tags for categorization, not custom multi-letter tagst tags allow items to belong to multiple categoriesRelay-Level Filtering:
#t: ["category"]Tag Examples:
// ❌ Wrong: Multi-letter tag, not queryable at relay level ["product_type", "electronics"] // ✅ Correct: Single-letter tag, relay-indexed and queryable ["t", "electronics"] ["t", "smartphone"] ["t", "android"]
Querying Best Practices:
// ❌ Inefficient: Get all events, filter in JavaScript const events = await nostr.query([{ kinds: [30402] }]); const filtered = events.filter(e => hasTag(e, 'product_type', 'electronics')); // ✅ Efficient: Filter at relay level const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]);
t Tag Filtering for Community-Specific ContentFor applications focused on a specific community or niche, you can use
t tags to filter events for the target audience.
When to Use:
t: "farming", "Poland" → t: "poland"Implementation:
// Publishing with community tag createEvent({ kind: 1, content: data.content, tags: [['t', 'farming']] }); // Querying community content const events = await nostr.query([{ kinds: [1], '#t': ['farming'], limit: 20 }], { signal });
An event's kind number determines the event's behavior and storage characteristics:
Kinds below 1000 are considered "legacy" kinds, and may have different storage characteristics based on their kind definition. For example, kind 1 is regular, while kind 3 is replaceable.
When designing new event kinds, the
content field should be used for semantically important data that doesn't need to be queried by relays. Structured JSON data generally shouldn't go in the content field (kind 0 being an early exception).
content: ""✅ Good - queryable data in tags:
{ "kind": 30402, "content": "", "tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]] }
❌ Bad - structured data in content:
{ "kind": 30402, "content": "{\"title\":\"Camera\",\"price\":250,\"category\":\"photo\"}", "tags": [["d", "product-123"]] }
The file
NIP.md is used by this project to define a custom Nostr protocol document. If the file doesn't exist, it means this project doesn't have any custom kinds associated with it.
Whenever new kinds are generated, the
NIP.md file in the project must be created or updated to document the custom event schema. Whenever the schema of one of these custom events changes, NIP.md must also be updated accordingly.
useNostr HookThe
useNostr hook returns an object containing a nostr property, with .query() and .event() methods for querying and publishing Nostr events respectively.
import { useNostr } from '@nostrify/react'; function useCustomHook() { const { nostr } = useNostr(); // ... }
useNostr and Tanstack QueryWhen querying Nostr, the best practice is to create custom hooks that combine
useNostr and useQuery to get the required data.
import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/query'; function usePosts() { const { nostr } = useNostr(); return useQuery({ queryKey: ['posts'], queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]); const events = await nostr.query([{ kinds: [1], limit: 20 }], { signal }); return events; // these events could be transformed into another format }, }); }
Critical: Always minimize the number of separate queries to avoid rate limiting and improve performance. Combine related queries whenever possible.
✅ Efficient - Single query with multiple kinds:
// Query multiple event types in one request const events = await nostr.query([ { kinds: [1, 6, 16], // All repost kinds in one query '#e': [eventId], limit: 150, } ], { signal }); // Separate by type in JavaScript const notes = events.filter((e) => e.kind === 1); const reposts = events.filter((e) => e.kind === 6); const genericReposts = events.filter((e) => e.kind === 16);
❌ Inefficient - Multiple separate queries:
// This creates unnecessary load and can trigger rate limiting const [notes, reposts, genericReposts] = await Promise.all([ nostr.query([{ kinds: [1], '#e': [eventId] }], { signal }), nostr.query([{ kinds: [6], '#e': [eventId] }], { signal }), nostr.query([{ kinds: [16], '#e': [eventId] }], { signal }), ]);
Query Optimization Guidelines:
kinds: [1, 6, 16] instead of separate queriesThe data may be transformed into a more appropriate format if needed, and multiple calls to
nostr.query() may be made in a single queryFn.
When querying events, if the event kind being returned has required tags or required JSON fields in the content, the events should be filtered through a validator function. This is not generally needed for kinds such as 1, where all tags are optional and the content is freeform text, but is especially useful for custom kinds as well as kinds with strict requirements.
// Example validator function for NIP-52 calendar events function validateCalendarEvent(event: NostrEvent): boolean { // Check if it's a calendar event kind if (![31922, 31923].includes(event.kind)) return false; // Check for required tags according to NIP-52 const d = event.tags.find(([name]) => name === 'd')?.[1]; const title = event.tags.find(([name]) => name === 'title')?.[1]; const start = event.tags.find(([name]) => name === 'start')?.[1]; // All calendar events require 'd', 'title', and 'start' tags if (!d || !title || !start) return false; // Additional validation for date-based events (kind 31922) if (event.kind === 31922) { // start tag should be in YYYY-MM-DD format for date-based events const dateRegex = /^\d{4}-\d{2}-\d{2}$/; if (!dateRegex.test(start)) return false; } // Additional validation for time-based events (kind 31923) if (event.kind === 31923) { // start tag should be a unix timestamp for time-based events const timestamp = parseInt(start); if (isNaN(timestamp) || timestamp <= 0) return false; } return true; } function useCalendarEvents() { const { nostr } = useNostr(); return useQuery({ queryKey: ['calendar-events'], queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]); const events = await nostr.query([{ kinds: [31922, 31923], limit: 20 }], { signal }); // Filter events through validator to ensure they meet NIP-52 requirements return events.filter(validateCalendarEvent); }, }); }
useAuthor HookTo display profile data for a user by their Nostr pubkey (such as an event author), use the
useAuthor hook.
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; function Post({ event }: { event: NostrEvent }) { const author = useAuthor(event.pubkey); const metadata: NostrMetadata | undefined = author.data?.metadata; const displayName = metadata?.name ?? genUserName(event.pubkey); const profileImage = metadata?.picture; // ...render elements with this data }
NostrMetadata type/** Kind 0 metadata. */ interface NostrMetadata { /** A short description of the user. */ about?: string; /** A URL to a wide (~1024x768) picture to be optionally displayed in the background of a profile screen. */ banner?: string; /** A boolean to clarify that the content is entirely or partially the result of automation, such as with chatbots or newsfeeds. */ bot?: boolean; /** An alternative, bigger name with richer characters than `name`. `name` should always be set regardless of the presence of `display_name` in the metadata. */ display_name?: string; /** A bech32 lightning address according to NIP-57 and LNURL specifications. */ lud06?: string; /** An email-like lightning address according to NIP-57 and LNURL specifications. */ lud16?: string; /** A short name to be displayed for the user. */ name?: string; /** An email-like Nostr address according to NIP-05. */ nip05?: string; /** A URL to the user's avatar. */ picture?: string; /** A web URL related in any way to the event author. */ website?: string; }
useNostrPublish HookTo publish events, use the
useNostrPublish hook in this project. This hook automatically adds a "client" tag to published events.
import { useState } from 'react'; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useNostrPublish } from '@/hooks/useNostrPublish'; export function MyComponent() { const [ data, setData] = useState<Record<string, string>>({}); const { user } = useCurrentUser(); const { mutate: createEvent } = useNostrPublish(); const handleSubmit = () => { createEvent({ kind: 1, content: data.content }); }; if (!user) { return <span>You must be logged in to use this form.</span>; } return ( <form onSubmit={handleSubmit} disabled={!user}> {/* ...some input fields */} </form> ); }
The
useCurrentUser hook should be used to ensure that the user is logged in before they are able to publish Nostr events.
To enable login with Nostr, simply use the
LoginArea component already included in this project.
import { LoginArea } from "@/components/auth/LoginArea"; function MyComponent() { return ( <div> {/* other components ... */} <LoginArea className="max-w-60" /> </div> ); }
The
LoginArea component handles all the login-related UI and interactions, including displaying login dialogs, sign up functionality, and switching between accounts. It should not be wrapped in any conditional logic.
LoginArea displays both "Log in" and "Sign Up" buttons when the user is logged out, and changes to an account switcher once the user is logged in. It is an inline-flex element by default. To make it expand to the width of its container, you can pass a className like flex (to make it a block element) or w-full. If it is left as inline-flex, it's recommended to set a max width.
Important: Social applications should include a profile menu button in the main interface (typically in headers/navigation) to provide access to account settings, profile editing, and logout functionality. Don't only show
LoginArea in logged-out states.
npub, naddr, and other Nostr addressesNostr defines a set of bech32-encoded identifiers in NIP-19. Their prefixes and purposes:
npub1: public keys - Just the 32-byte public key, no additional metadatansec1: private keys - Secret keys (should never be displayed publicly)note1: event IDs - Just the 32-byte event ID (hex), no additional metadatanevent1: event pointers - Event ID plus optional relay hints and author pubkeynprofile1: profile pointers - Public key plus optional relay hints and petnamenaddr1: addressable event coordinates - For parameterized replaceable events (kind 30000-39999)nrelay1: relay references - Relay URLs (deprecated)
vs note1
:nevent1
note1: Contains only the event ID (32 bytes) - specifically for kind:1 events (Short Text Notes) as defined in NIP-10nevent1: Contains event ID plus optional relay hints and author pubkey - for any event kindnote1 for simple references to text notes and threadsnevent1 when you need to include relay hints or author context for any event type
vs npub1
:nprofile1
npub1: Contains only the public key (32 bytes)nprofile1: Contains public key plus optional relay hints and petnamenpub1 for simple user referencesnprofile1 when you need to include relay hints or display name contextCritical: NIP-19 identifiers should be handled at the root level of URLs (e.g.,
/note1..., /npub1..., /naddr1...), NOT nested under paths like /note/note1... or /profile/npub1....
This project includes a boilerplate
NIP19Page component that provides the foundation for handling all NIP-19 identifier types at the root level. The component is configured in the routing system and ready for AI agents to populate with specific functionality.
How it works:
/:nip19 in AppRouter.tsx catches all NIP-19 identifiersNIP19Page component automatically decodes the identifier using nip19.decode()npub1/nprofile1: Profile section with placeholder for profile viewnote1: Note section with placeholder for kind:1 text note viewnevent1: Event section with placeholder for any event type viewnaddr1: Addressable event section with placeholder for articles, marketplace items, etc.Example URLs that work automatically:
/npub1abc123... - User profile (needs implementation)/note1def456... - Kind:1 text note (needs implementation)/nevent1ghi789... - Any event with relay hints (needs implementation)/naddr1jkl012... - Addressable event (needs implementation)Features included:
Error handling:
nsec1) → 404 NotFoundTo implement NIP-19 routing in your Nostr application:
AppRouter.tsx
identifiers are specifically for kind:1 events (Short Text Notes) as defined in NIP-10: "Text Notes and Threads". These are the basic social media posts in Nostr.note1
identifiers can reference any event kind and include additional metadata like relay hints and author pubkey. Use nevent1
nevent1 when:
The base Nostr protocol uses hex string identifiers when filtering by event IDs and pubkeys. Nostr filters only accept hex strings.
// ❌ Wrong: naddr is not decoded const events = await nostr.query( [{ ids: [naddr] }], { signal } );
Corrected example:
// Import nip19 from nostr-tools import { nip19 } from 'nostr-tools'; // Decode a NIP-19 identifier const decoded = nip19.decode(value); // Optional: guard certain types (depending on the use-case) if (decoded.type !== 'naddr') { throw new Error('Unsupported Nostr identifier'); } // Get the addr object const naddr = decoded.data; // ✅ Correct: naddr is expanded into the correct filter const events = await nostr.query( [{ kinds: [naddr.kind], authors: [naddr.pubkey], '#d': [naddr.identifier], }], { signal } );
note1 for kind:1 text notes specificallynevent1 when including relay hints or for non-kind:1 eventsnaddr1 for addressable events (always includes author pubkey for security)npub1/nprofile1: Display user profilesnote1: Display kind:1 text notes specificallynevent1: Display any event with optional relay contextnaddr1: Display addressable events (articles, marketplace items, etc.)naddr1 for addressable events instead of just the d tag value, as naddr1 contains the author pubkey needed to create secure filtersTo include an Edit Profile form, place the
EditProfileForm component in the project:
import { EditProfileForm } from "@/components/EditProfileForm"; function EditProfilePage() { return ( <div> {/* you may want to wrap this in a layout or include other components depending on the project ... */} <EditProfileForm /> </div> ); }
The
EditProfileForm component displays just the form. It requires no props, and will "just work" automatically.
Use the
useUploadFile hook to upload files. This hook uses Blossom servers for file storage and returns NIP-94 compatible tags.
import { useUploadFile } from "@/hooks/useUploadFile"; function MyComponent() { const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const handleUpload = async (file: File) => { try { // Provides an array of NIP-94 compatible tags // The first tag in the array contains the URL const [[_, url]] = await uploadFile(file); // ...use the url } catch (error) { // ...handle errors } }; // ...rest of component }
To attach files to kind 1 events, each file's URL should be appended to the event's
content, and an imeta tag should be added for each file. For kind 0 events, the URL by itself can be used in relevant fields of the JSON content.
The logged-in user has a
signer object (matching the NIP-07 signer interface) that can be used for encryption and decryption. The signer's nip44 methods handle all cryptographic operations internally, including key derivation and conversation key management, so you never need direct access to private keys. Always use the signer interface for encryption rather than requesting private keys from users, as this maintains security and follows best practices.
// Get the current user const { user } = useCurrentUser(); // Optional guard to check that nip44 is available if (!user.signer.nip44) { throw new Error("Please upgrade your signer extension to a version that supports NIP-44 encryption"); } // Encrypt message to self const encrypted = await user.signer.nip44.encrypt(user.pubkey, "hello world"); // Decrypt message to self const decrypted = await user.signer.nip44.decrypt(user.pubkey, encrypted) // "hello world"
Nostr text notes (kind 1, 11, and 1111) have a plaintext
content field that may contain URLs, hashtags, and Nostr URIs. These events should render their content using the NoteContent component:
import { NoteContent } from "@/components/NoteContent"; export function Post(/* ...props */) { // ... return ( <CardContent className="pb-2"> <div className="whitespace-pre-wrap break-words"> <NoteContent event={post} className="text-sm" /> </div> </CardContent> ); }
The project includes a complete commenting system using NIP-22 (kind 1111) comments that can be added to any Nostr event or URL. The
CommentsSection component provides a full-featured commenting interface with threaded replies, user authentication, and real-time updates.
import { CommentsSection } from "@/components/comments/CommentsSection"; function ArticlePage({ article }: { article: NostrEvent }) { return ( <div className="space-y-6"> {/* Your article content */} <div>{/* article content */}</div> {/* Comments section */} <CommentsSection root={article} /> </div> ); }
The
CommentsSection component accepts the following props:
root (required): The root event or URL to comment on. Can be a NostrEvent or URL object.title: Custom title for the comments section (default: "Comments")emptyStateMessage: Message shown when no comments exist (default: "No comments yet")emptyStateSubtitle: Subtitle for empty state (default: "Be the first to share your thoughts!")className: Additional CSS classes for stylinglimit: Maximum number of comments to load (default: 500)<CommentsSection root={event} title="Discussion" emptyStateMessage="Start the conversation" emptyStateSubtitle="Share your thoughts about this post" className="mt-8" limit={100} />
The comments system also supports commenting on external URLs, making it useful for web pages, articles, or any online content:
<CommentsSection root={new URL("https://example.com/article")} title="Comments on this article" />
The project includes an
AppProvider that manages global application state including theme and relay configuration. The default configuration includes:
const defaultConfig: AppConfig = { theme: "light", relayUrl: "wss://relay.nostr.band", };
Preset relays are available including Ditto, Nostr.Band, Damus, and Primal. The app uses local storage to persist user preferences.
The project uses React Router with a centralized routing configuration in
AppRouter.tsx. To add new routes:
/src/pages/AppRouter.tsx* route:<Route path="/your-path" element={<YourComponent />} />
The router includes automatic scroll-to-top functionality and a 404 NotFound page for unmatched routes.
@/ prefix for cleaner importsany type: Always use proper TypeScript types for type safetyUse skeleton loading for structured content (feeds, profiles, forms). Use spinners only for buttons or short operations.
// Skeleton example matching component structure <Card> <CardHeader> <div className="flex items-center space-x-3"> <Skeleton className="h-10 w-10 rounded-full" /> <div className="space-y-1"> <Skeleton className="h-4 w-24" /> <Skeleton className="h-3 w-16" /> </div> </div> </CardHeader> <CardContent> <div className="space-y-2"> <Skeleton className="h-4 w-full" /> <Skeleton className="h-4 w-4/5" /> </div> </CardContent> </Card>
When no content is found (empty search results, no data available, etc.), display a minimalist empty state with the
RelaySelector component. This allows users to easily switch relays to discover content from different sources.
import { RelaySelector } from '@/components/RelaySelector'; import { Card, CardContent } from '@/components/ui/card'; // Empty state example <div className="col-span-full"> <Card className="border-dashed"> <CardContent className="py-12 px-8 text-center"> <div className="max-w-sm mx-auto space-y-6"> <p className="text-muted-foreground"> No results found. Try another relay? </p> <RelaySelector className="w-full" /> </div> </CardContent> </Card> </div>
Tailor the site's look and feel based on the user's specific request. This includes:
To add custom fonts, follow these steps:
Install a font package using the
js-dev__npm_add_package tool:
Any Google Font can be installed using the @fontsource packages. Examples:
js-dev__npm_add_package({ name: "@fontsource-variable/inter" })js-dev__npm_add_package({ name: "@fontsource/roboto" })js-dev__npm_add_package({ name: "@fontsource-variable/outfit" })js-dev__npm_add_package({ name: "@fontsource/poppins" })js-dev__npm_add_package({ name: "@fontsource/open-sans" })Format:
@fontsource/[font-name] or @fontsource-variable/[font-name] (for variable fonts)
Import the font in
src/main.tsx:
import '@fontsource-variable/<font-name>';
Update Tailwind configuration in
tailwind.config.ts:
export default { theme: { extend: { fontFamily: { sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'], }, }, }, }
The project includes a complete light/dark theme system using CSS custom properties. The theme can be controlled via:
useTheme hook for programmatic theme switchingsrc/index.css.dark classWhen users specify color schemes:
src/index.css (both :root and .dark selectors)cn() utility for conditional class mergingThere is an important distinction between writing new tests and running existing tests:
Do not write tests unless the user explicitly requests them in plain language. Writing unnecessary tests wastes significant time and money. Only create tests when:
Never write tests because:
ALWAYS run the test script after making any code changes. This is mandatory regardless of whether you wrote new tests or not.
js-dev__run_script tool with the "test" parameterThe project uses Vitest with jsdom environment and includes comprehensive test setup:
TestApp component provides all necessary context providers for testingThe project includes a
TestApp component that provides all necessary context providers for testing. Wrap components with this component to provide required context providers:
import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { TestApp } from '@/test/TestApp'; import { MyComponent } from './MyComponent'; describe('MyComponent', () => { it('renders correctly', () => { render( <TestApp> <MyComponent /> </TestApp> ); expect(screen.getByText('Expected text')).toBeInTheDocument(); }); });
CRITICAL: Whenever you are finished modifying code, you must run the test script using the js-dev__run_script tool.
Your task is not considered finished until this test passes without errors.
This requirement applies regardless of whether you wrote new tests or not. The test script validates the entire codebase, including TypeScript compilation, ESLint rules, and existing test suite.