This guide provides essential information for AI coding agents working on the noStrudel codebase.
noStrudel is a React/TypeScript web application for exploring the nostr protocol. It uses Vite as the build tool, Chakra UI for components, and a custom state management layer built around the applesauce pattern for Nostr data.
src/
├── components/ # Reusable UI components (organized by feature)
├── views/ # Page-level components (route handlers)
├── hooks/ # Custom React hooks (70+ hooks)
├── helpers/ # Pure utility functions
├── providers/ # React context providers (global/local/route)
├── services/ # Singleton services & business logic
├── models/ # Data models (applesauce pattern)
├── classes/ # Class implementations
├── types/ # TypeScript type definitions
├── theme/ # Chakra UI theme customization
└── sw/ # Service worker code
- Always use kebab-case for files and directories
- Components:
user-avatar.tsx,compact-note-content.tsx - Hooks:
use-async-action.ts,use-event-reactions.ts - Helpers:
relay.ts,app-settings.ts - Use
index.tsxfor main module exports
- Use relative imports (preferred in codebase)
- Path alias
~/is configured but rarely used - Example:
import UserAvatar from "../user/user-avatar" - Group imports: external libraries → internal modules → components
// Preferred: Default export with function declaration
export default function HomePage() {
// component logic
}
// Alternative: Named export with React.memo
export const CompactNoteContent = React.memo(
({ event, maxLength, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
// component logic
},
);- Use functional components exclusively (no class components)
- Use default exports for components
- Destructure props in function signature
- Spread remaining props:
...props(common with Chakra UI) - Use
React.memo()for performance-critical components - Use
forwardRefwhen refs need to be forwarded
- Prefix with
use-in filename - Export as default
- Return objects for multiple values:
{ loading, run } - Keep focused on single responsibility
IMPORTANT: When writing async actions or callbacks in components, use the useAsyncAction hook instead of try/catch. The hook handles errors cleanly by showing toast notifications.
// ✅ CORRECT: Use useAsyncAction
import useAsyncAction from "~/hooks/use-async-action";
const { loading, run } = useAsyncAction(async () => {
await someAsyncOperation();
}, [dependencies]);
<Button onClick={run} isLoading={loading}>Submit</Button>
// ❌ INCORRECT: Don't use raw try/catch in components
const handleClick = async () => {
try {
await someAsyncOperation();
} catch (e) {
// error handling
}
};export default function useAsyncAction<Args extends Array<any>, T = any>(
fn: (...args: Args) => Promise<T>,
deps: DependencyList = [],
): { loading: boolean; run: (...args: Args) => Promise<T | undefined> };import { ErrorBoundary } from "react-error-boundary";
<ErrorBoundary fallback={<ErrorFallback />}>
<CriticalComponent />
</ErrorBoundary>- Use
ErrorBoundarywrapper for critical sections - Use Chakra UI
useToastfor user-facing errors - Type-check errors:
if (e instanceof Error)
- React Context - Global/shared state (EventStore, Accounts, etc.)
- RxJS Observables - Reactive data streams (
BehaviorSubject) - Singleton Services - App-wide concerns (pool, accounts, eventStore)
- React Hooks - Local component state
// Use EventModel queries for Nostr data
const reactions = useEventModel(ReactionsQuery, [event, relays]);
// Use timeline loaders for feeds
const timeline = useTimelineLoader(timelineName, relays, filters);import { Button, Box, Flex } from "@chakra-ui/react";
// Extend Chakra props
type CustomProps = Omit<ButtonProps, "children"> & {
customProp?: string;
};import { NostrEvent } from "nostr-tools";
// Work with events through helpers and services
import { getDisplayName } from "../../helpers/nostr/profile";
import eventStore from "../../services/event-store";Views are page-level components that handle routing and display content. Follow this structured approach when adding new views to the app.
Create a new directory under src/views/ with the following structure:
src/views/your-view/
├── index.tsx # Main view (list/feed page)
├── routes.tsx # Route definitions
├── [detail-page].tsx # Detail view (optional)
├── new.tsx # Create form (optional)
└── components/ # View-specific components
├── component-one.tsx
└── component-two.tsx
IMPORTANT: Always create helper functions for working with Nostr events in src/helpers/nostr/. This keeps business logic separate from UI components.
File: src/helpers/nostr/your-feature.ts
import { NostrEvent } from "nostr-tools";
// Define event kinds
export const YOUR_FEATURE_KIND = 2003;
export const YOUR_FEATURE_COMMENT_KIND = 2004;
// Helper functions to extract data from events
export function getFeatureTitle(event: NostrEvent) {
const title = event.tags.find((t) => t[0] === "title")?.[1];
if (!title) throw new Error("Missing title");
return title;
}
export function getFeatureData(event: NostrEvent) {
const data = event.tags.find((t) => t[0] === "x")?.[1];
if (!data) throw new Error("Missing data");
return data;
}
// Validation helper
export function validateFeature(event: NostrEvent) {
try {
getFeatureTitle(event);
getFeatureData(event);
return true;
} catch (e) {
return false;
}
}
// Add any constants or types needed
export type Category = {
name: string;
tag: string;
};File: src/views/your-view/index.tsx
import { useCallback, useMemo } from "react";
import { Button, Flex, Spacer } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { NostrEvent } from "nostr-tools";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import VerticalPageLayout from "../../components/vertical-page-layout";
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import { YOUR_FEATURE_KIND, validateFeature } from "../../helpers/nostr/your-feature";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { useReadRelays } from "../../hooks/use-client-relays";
function YourViewPage() {
const { filter, listId } = usePeopleListContext();
const relays = useReadRelays();
const muteFilter = useClientSideMuteFilter();
const eventFilter = useCallback(
(e: NostrEvent) => {
if (muteFilter(e)) return false;
if (!validateFeature(e)) return false;
return true;
},
[muteFilter],
);
const query = useMemo(() => {
if (!filter) return undefined;
return { ...filter, kinds: [YOUR_FEATURE_KIND] };
}, [filter]);
const { loader, timeline: items } = useTimelineLoader(
`${listId || "global"}-your-view`,
relays,
query,
{ eventFilter },
);
const callback = useTimelineCurserIntersectionCallback(loader);
return (
<VerticalPageLayout>
<Flex gap="2">
<PeopleListSelection />
<Spacer />
<Button as={RouterLink} to="/your-view/new">
Create New
</Button>
</Flex>
<IntersectionObserverProvider callback={callback}>
{/* Render your items here */}
{items?.map((item) => (
<YourItemComponent key={item.id} item={item} />
))}
</IntersectionObserverProvider>
</VerticalPageLayout>
);
}
// Export with provider wrapper
export default function YourView() {
return (
<PeopleListProvider>
<YourViewPage />
</PeopleListProvider>
);
}File: src/views/your-view/detail.tsx
import { Spinner } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { ErrorBoundary } from "../../components/error-boundary";
import VerticalPageLayout from "../../components/vertical-page-layout";
import useParamsEventPointer from "../../hooks/use-params-event-pointer";
import useSingleEvent from "../../hooks/use-single-event";
import { getFeatureTitle } from "../../helpers/nostr/your-feature";
function DetailPage({ item }: { item: NostrEvent }) {
return (
<VerticalPageLayout>
<h1>{getFeatureTitle(item)}</h1>
{/* Render item details */}
</VerticalPageLayout>
);
}
export default function DetailView() {
const pointer = useParamsEventPointer("id");
const item = useSingleEvent(pointer);
if (!item) return <Spinner />;
return (
<ErrorBoundary>
<DetailPage item={item} />
</ErrorBoundary>
);
}File: src/views/your-view/routes.tsx
import { RouteObject } from "react-router-dom";
import YourView from ".";
import NewItemView from "./new";
import DetailView from "./detail";
export default [
{ index: true, Component: YourView },
{ path: "new", Component: NewItemView },
{ path: ":id", Component: DetailView },
] satisfies RouteObject[];File: src/app.tsx
Add the import near other route imports (around line 45):
import yourViewRoutes from "./views/your-view/routes";Add the route to the router configuration (around line 122):
const router = createHashRouter([
{
element: <RootPage />,
children: [
// ... existing routes
{ path: "your-view", children: yourViewRoutes },
],
},
]);Create reusable components in src/views/your-view/components/:
File: src/views/your-view/components/item-row.tsx
import { memo } from "react";
import { Link, Td, Tr } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { Link as RouterLink } from "react-router-dom";
import UserLink from "../../../components/user/user-link";
import Timestamp from "../../../components/timestamp";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
import useShareableEventAddress from "../../../hooks/use-shareable-event-address";
import { getFeatureTitle } from "../../../helpers/nostr/your-feature";
function ItemRow({ item }: { item: NostrEvent }) {
const ref = useEventIntersectionRef<HTMLTableRowElement>(item);
const address = useShareableEventAddress(item);
return (
<Tr ref={ref}>
<Td>
<Link as={RouterLink} to={`/your-view/${address}`}>
{getFeatureTitle(item)}
</Link>
</Td>
<Td>
<Timestamp timestamp={item.created_at} />
</Td>
<Td>
<UserLink pubkey={item.pubkey} />
</Td>
</Tr>
);
}
export default memo(ItemRow);- Helper Functions First: Always create helpers in
src/helpers/nostr/before building UI - Provider Wrapper: Wrap main view with providers (PeopleListProvider, etc.)
- Timeline Loader: Use
useTimelineLoaderfor feeds with infinite scroll - Event Validation: Filter events with
eventFiltercallback - Intersection Observer: Use for lazy loading and performance
- Relative Imports: Always use relative imports (
../../components/) - Memo Components: Use
memo()for list items to prevent re-renders - Error Boundaries: Wrap critical sections with
<ErrorBoundary>
The torrents view (src/views/torrents/) demonstrates this pattern:
- Helpers:
src/helpers/nostr/torrents.ts- Event kind, validation, data extraction - Main View:
src/views/torrents/index.tsx- List with filtering and infinite scroll - Detail View:
src/views/torrents/torrent.tsx- Individual torrent details - Routes:
src/views/torrents/routes.tsx- Route configuration - Components:
src/views/torrents/components/- Reusable UI components