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 { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import { useAuthor } from '@/hooks/useAuthor'; function Post({ event }: { event: NostrEvent }) { const author = useAuthor(event.pubkey); const metadata: NostrMetadata | undefined = author.data?.metadata; const displayName = metadata?.name || event.pubkey.slice(0, 8); 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 /> </div> ); }
The
LoginArea component displays a "Log in" button when the user is logged out, and changes to an account switcher once the user is logged in. It handles all the login-related UI and interactions internally, including displaying login dialogs and switching between accounts. It should not be wrapped in any conditional logic.
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 } );
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.
// 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"
@/ prefix for cleaner importsnpm run buildnpm run build:devWhenever you modify code, you should test your changes after you're finished by running:
npm run ci
This command will typecheck the code and attempt to build it.
Your task is not considered finished until this test passes without errors.