12 KiB
Project Overview
This project is a Nostr client application built with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify.
Technology Stack
- React 18.x: Stable version of React with hooks, concurrent rendering, and improved performance
- TailwindCSS 3.x: Utility-first CSS framework for styling
- Vite: Fast build tool and development server
- shadcn/ui: Unstyled, accessible UI components built with Radix UI and Tailwind
- Nostrify: Nostr protocol framework for Deno and web
- React Router: For client-side routing
- TanStack Query: For data fetching, caching, and state management
- TypeScript: For type-safe JavaScript development
Project Structure
/src/components/
: UI components including NostrProvider for Nostr integration/src/hooks/
: Custom hooks includinguseNostr
anduseNostrQuery
/src/pages/
: Page components used by React Router/src/lib/
: Utility functions and shared logic/public/
: Static assets
UI Components
The 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:
- Accordion: Vertically collapsing content panels
- Alert: Displays important messages to users
- AlertDialog: Modal dialog for critical actions requiring confirmation
- AspectRatio: Maintains consistent width-to-height ratio
- Avatar: User profile pictures with fallback support
- Badge: Small status descriptors for UI elements
- Breadcrumb: Navigation aid showing current location in hierarchy
- Button: Customizable button with multiple variants and sizes
- Calendar: Date picker component
- Card: Container with header, content, and footer sections
- Carousel: Slideshow for cycling through elements
- Chart: Data visualization component
- Checkbox: Selectable input element
- Collapsible: Toggle for showing/hiding content
- Command: Command palette for keyboard-first interfaces
- ContextMenu: Right-click menu component
- Dialog: Modal window overlay
- Drawer: Side-sliding panel
- DropdownMenu: Menu that appears from a trigger element
- Form: Form validation and submission handling
- HoverCard: Card that appears when hovering over an element
- InputOTP: One-time password input field
- Input: Text input field
- Label: Accessible form labels
- Menubar: Horizontal menu with dropdowns
- NavigationMenu: Accessible navigation component
- Pagination: Controls for navigating between pages
- Popover: Floating content triggered by a button
- Progress: Progress indicator
- RadioGroup: Group of radio inputs
- Resizable: Resizable panels and interfaces
- ScrollArea: Scrollable container with custom scrollbars
- Select: Dropdown selection component
- Separator: Visual divider between content
- Sheet: Side-anchored dialog component
- Sidebar: Navigation sidebar component
- Skeleton: Loading placeholder
- Slider: Input for selecting a value from a range
- Sonner: Toast notification manager
- Switch: Toggle switch control
- Table: Data table with headers and rows
- Tabs: Tabbed interface component
- Textarea: Multi-line text input
- Toast: Toast notification component
- ToggleGroup: Group of toggle buttons
- Toggle: Two-state button
- Tooltip: Informational text that appears on hover
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.
Nostr Protocol Integration
This project comes with custom hooks for querying and publishing events on the Nostr network.
The useNostr
Hook
The 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();
// ...
}
Query Nostr Data with useNostr
and Tanstack Query
When 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.
The useAuthor
Hook
To 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;
}
The useNostrPublish
Hook
To 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.
Nostr Login
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 addresses
Nostr 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.
Use in Filters
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 }
);
Nostr Edit Profile
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.
Uploading Files on Nostr
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.
Nostr Encryption and Decryption
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"
Rendering Kind 1 Text
If you need to render kind 1 text, use 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>
);
}
Development Practices
- Uses React Query for data fetching and caching
- Follows shadcn/ui component patterns
- Implements Path Aliases with
@/
prefix for cleaner imports - Uses Vite for fast development and production builds
- Component-based architecture with React hooks
- Default connection to multiple Nostr relays for network redundancy
Build & Deployment
- Build for production:
npm run build
- Development build:
npm run build:dev
Testing Your Changes
Whenever you modify code, you should test your changes after you're finished by running:
npm run test
This command will typecheck the code and attempt to build it.
Your task is not considered finished until this test passes without errors.