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/hooks/: Custom hooks including useNostr and useNostrQuery/src/pages/: Page components used by React Router/src/lib/: Utility functions and shared logic/public/: Static assetsThe 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.
This project comes with custom hooks for querying and publishing events on the Nostr network.
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 }, }); }
The data may be transformed into a more appropriate format if needed, and multiple calls to
nostr.query() may be made in a single queryFn.
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.
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 and switching between accounts. It should not be wrapped in any conditional logic.
LoginArea displays a "Log in" button 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.
npub, naddr, and other Nostr addressesNostr defines a set identifiers in NIP-19. Their prefixes:
npub: public keysnsec: private keysnote: note idsnprofile: a nostr profilenevent: a nostr eventnaddr: a nostr replaceable event coordinatenrelay: a nostr relay (deprecated)NIP-19 identifiers include a prefix, the number "1", then a base32-encoded data string.
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 } );
For URL routing, use NIP-19 identifiers as path parameters (e.g.,
/:nip19) to create secure, universal links to Nostr events. Decode the identifier and render the appropriate component based on the type:
/nevent1... paths/naddr1... pathsAlways use
naddr identifiers for addressable events instead of just the d tag value, as naddr contains the author pubkey needed to create secure filters. This prevents security issues where malicious actors could publish events with the same d tag to override content.
// Secure routing with naddr const decoded = nip19.decode(params.nip19); if (decoded.type === 'naddr' && decoded.data.kind === 30024) { // Render ArticlePage component }
To 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.
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> ); }
@/ prefix for cleaner importsTailor 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 npm_add_package tool:
Any Google Font can be installed using the @fontsource packages. Examples:
npm_add_package({ name: "@fontsource-variable/inter" })npm_add_package({ name: "@fontsource/roboto" })npm_add_package({ name: "@fontsource-variable/outfit" })npm_add_package({ name: "@fontsource/poppins" })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/inter';
Update Tailwind configuration in
tailwind.config.ts:
export default { theme: { extend: { fontFamily: { sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'], }, }, }, }
When users specify color schemes:
src/index.csscn() utility for conditional class mergingImportant for AI Assistants: Only create tests when the user is experiencing a specific problem or explicitly requests tests. Do not proactively write tests for new features or components unless the user is having issues that require testing to diagnose or resolve.
Wrap components with the
TestApp 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(); }); });
Whenever you modify code, you must run the test script using the run_script tool.
Your task is not considered finished until this test passes without errors.