mkstack/CONTEXT.md
2025-05-27 23:10:17 +02:00

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 including useNostr and useNostrQuery
  • /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 keys
  • nsec: private keys
  • note: note ids
  • nprofile: a nostr profile
  • nevent: a nostr event
  • naddr: a nostr replaceable event coordinate
  • nrelay: 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.