mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-27 13:09:22 +00:00
439 lines
14 KiB
Markdown
439 lines
14 KiB
Markdown
# 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.
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```tsx
|
|
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
|
|
|
|
```ts
|
|
/** 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.
|
|
|
|
```tsx
|
|
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.
|
|
|
|
```tsx
|
|
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 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.
|
|
|
|
```ts
|
|
// ❌ Wrong: naddr is not decoded
|
|
const events = await nostr.query(
|
|
[{ ids: [naddr] }],
|
|
{ signal }
|
|
);
|
|
```
|
|
|
|
Corrected example:
|
|
|
|
```ts
|
|
// 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 }
|
|
);
|
|
```
|
|
|
|
#### Use in URL Paths
|
|
|
|
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:
|
|
|
|
- Regular events: Use `/nevent1...` paths
|
|
- Replaceable/addressable events: Use `/naddr1...` paths
|
|
|
|
Always 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.
|
|
|
|
```ts
|
|
// Secure routing with naddr
|
|
const decoded = nip19.decode(params.nip19);
|
|
if (decoded.type === 'naddr' && decoded.data.kind === 30024) {
|
|
// Render ArticlePage component
|
|
}
|
|
```
|
|
|
|
### Nostr Edit Profile
|
|
|
|
To include an Edit Profile form, place the `EditProfileForm` component in the project:
|
|
|
|
```tsx
|
|
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.
|
|
|
|
```tsx
|
|
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.
|
|
|
|
```ts
|
|
// 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 Rich Text Content
|
|
|
|
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:
|
|
|
|
```tsx
|
|
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
|
|
|
|
## Writing Tests
|
|
|
|
This project uses Vitest for testing React components. The `TestApp` component provides all necessary providers for components that use Nostr functionality, React Router, and TanStack Query.
|
|
|
|
Test files should be placed next to the module they test, using a `.test.tsx` or `.test.ts` extension:
|
|
- `src/components/MyComponent.tsx` → `src/components/MyComponent.test.tsx`
|
|
- `src/hooks/useCustomHook.ts` → `src/hooks/useCustomHook.test.ts`
|
|
|
|
### Test Setup
|
|
|
|
Wrap components with the `TestApp` component to provide required context providers:
|
|
|
|
```tsx
|
|
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();
|
|
});
|
|
});
|
|
```
|
|
|
|
### Mocking Hooks
|
|
|
|
Mock custom hooks using Vitest's `vi.mock()`:
|
|
|
|
```tsx
|
|
import { vi } from 'vitest';
|
|
|
|
vi.mock('@/hooks/useCurrentUser', () => ({
|
|
useCurrentUser: () => ({
|
|
user: null, // or provide mock user data
|
|
}),
|
|
}));
|
|
```
|
|
|
|
## Testing Your Changes
|
|
|
|
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.** |