initial mkstack code commit (lumina v2 init)

This commit is contained in:
2025-11-21 23:28:46 +01:00
parent 9194f84cdd
commit ba386f8bc7
415 changed files with 20333 additions and 25734 deletions

332
docs/AI_CHAT.md Normal file
View File

@@ -0,0 +1,332 @@
### AI Integration with Shakespeare API
Use the `useShakespeare` hook for AI chat completions with Nostr authentication. The API dynamically provides available models, so you should query them at runtime rather than hardcoding model names.
```tsx
import { useShakespeare, type ChatMessage, type Model } from '@/hooks/useShakespeare';
const {
sendChatMessage,
sendStreamingMessage,
getAvailableModels,
isLoading,
error,
isAuthenticated
} = useShakespeare();
```
#### Model Selector Component
```tsx
function ModelSelector({ onModelSelect }: { onModelSelect: (modelId: string) => void }) {
const { getAvailableModels, isLoading } = useShakespeare();
const [models, setModels] = useState<Model[]>([]);
const [selectedModel, setSelectedModel] = useState<string>('');
useEffect(() => {
const fetchModels = async () => {
try {
const response = await getAvailableModels();
// Sort models by total cost (cheapest first)
const sortedModels = response.data.sort((a, b) => {
const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion);
const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion);
return costA - costB;
});
setModels(sortedModels);
// Select the cheapest model by default
if (sortedModels.length > 0) {
const cheapestModel = sortedModels[0];
setSelectedModel(cheapestModel.id);
onModelSelect(cheapestModel.id);
}
} catch (err) {
console.error('Failed to fetch models:', err);
}
};
fetchModels();
}, [getAvailableModels, onModelSelect]);
const handleModelChange = (modelId: string) => {
setSelectedModel(modelId);
onModelSelect(modelId);
};
return (
<div>
<label htmlFor="model-select">Choose Model:</label>
<select
id="model-select"
value={selectedModel}
onChange={(e) => handleModelChange(e.target.value)}
disabled={isLoading}
>
<option value="">Select a model...</option>
{models.map((model, index) => {
const totalCost = parseFloat(model.pricing.prompt) + parseFloat(model.pricing.completion);
const isCheapest = index === 0;
return (
<option key={model.id} value={model.id}>
{model.name} - {isCheapest ? "Cheapest" : `$${totalCost.toFixed(6)}/token`}
</option>
);
})}
</select>
</div>
);
}
```
#### Basic Chat Example
```tsx
function AIChat() {
const { sendChatMessage, isLoading, error, isAuthenticated } = useShakespeare();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [selectedModel, setSelectedModel] = useState<string>('');
const handleSend = async () => {
if (!input.trim() || !selectedModel) return;
const newMessages = [...messages, { role: 'user', content: input }];
setMessages(newMessages);
setInput('');
try {
const response = await sendChatMessage(newMessages, selectedModel);
setMessages(prev => [...prev, {
role: 'assistant',
content: response.choices[0].message.content as string
}]);
} catch (err) {
console.error('Chat error:', err);
}
};
if (!isAuthenticated) return <div>Please log in to use AI</div>;
return (
<div className="max-w-2xl mx-auto p-4">
{error && <div className="text-red-500 mb-4">{error}</div>}
{/* Model Selection */}
<div className="mb-4">
<ModelSelector onModelSelect={setSelectedModel} />
</div>
<div className="space-y-2 mb-4">
{messages.map((msg, i) => (
<div key={i} className={`p-2 rounded ${msg.role === 'user' ? 'bg-blue-100' : 'bg-gray-100'}`}>
<strong>{msg.role}:</strong> {msg.content}
</div>
))}
</div>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
className="flex-1 p-2 border rounded"
disabled={isLoading || !selectedModel}
placeholder={!selectedModel ? "Select a model first..." : "Type your message..."}
/>
<button
onClick={handleSend}
disabled={isLoading || !selectedModel}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
Send
</button>
</div>
</div>
);
}
```
#### Streaming Chat Example
```tsx
function StreamingChat() {
const { sendStreamingMessage } = useShakespeare();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [currentResponse, setCurrentResponse] = useState('');
const [selectedModel, setSelectedModel] = useState<string>('');
const handleStreaming = async (content: string) => {
if (!selectedModel) return;
setCurrentResponse('');
const newMessages = [...messages, { role: 'user', content }];
setMessages(newMessages);
try {
await sendStreamingMessage(newMessages, selectedModel, (chunk) => {
setCurrentResponse(prev => prev + chunk);
});
// Add the complete response to messages
if (currentResponse.trim()) {
setMessages(prev => [...prev, {
role: 'assistant',
content: currentResponse
}]);
}
} catch (err) {
console.error('Streaming error:', err);
} finally {
setCurrentResponse('');
}
};
return (
<div>
{/* Model selection UI */}
<div className="mb-4">
<ModelSelector onModelSelect={setSelectedModel} />
</div>
{/* Chat interface */}
{/* ... rest of your chat UI */}
</div>
);
}
```
#### Model Information
Models are dynamically fetched from the Shakespeare API and include:
- **Model ID**: Unique identifier for the model
- **Name**: Human-readable model name
- **Description**: Model capabilities and use cases
- **Context Window**: Maximum token limit for conversations
- **Pricing**: Cost per token for prompt and completion
- **Free Models**: Models with `pricing.prompt === "0"` and `pricing.completion === "0"`
#### Key Points
- **Dynamic Model Discovery**: Always fetch available models using `getAvailableModels()`
- **Authentication Required**: User must be logged in with Nostr account
- **Free vs Premium**: Check pricing to determine if model requires credits
- **Error Handling**: Handle `isLoading` and `error` states appropriately
- **Model Selection**: Provide UI for users to choose between available models
## Implementation Patterns and Best Practices
### Dialog Component Patterns
When using Dialog components, always ensure accessibility compliance by including required elements:
```tsx
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
// ✅ Correct - Always include DialogHeader with DialogTitle
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>
Optional description for screen readers
</DialogDescription>
</DialogHeader>
{/* Dialog content */}
</DialogContent>
</Dialog>
```
**Important**: Even if you want to hide the title visually, use the `VisuallyHidden` component to maintain accessibility:
```tsx
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
<DialogHeader>
<VisuallyHidden>
<DialogTitle>Hidden Title for Screen Readers</DialogTitle>
</VisuallyHidden>
</DialogHeader>
```
### Streaming Response Handling
When implementing streaming chat interfaces, always accumulate streamed content in a local variable before clearing the streaming state to prevent content loss:
```tsx
const handleStreamingResponse = async () => {
let streamedContent = ''; // ✅ Use local variable to accumulate content
try {
await sendStreamingMessage(messages, model, (chunk) => {
streamedContent += chunk; // ✅ Accumulate in local variable
setCurrentStreamingMessage(streamedContent); // Update UI
});
// ✅ Save accumulated content to persistent state
if (streamedContent.trim()) {
const assistantMessage: MessageDisplay = {
id: Date.now().toString(),
role: 'assistant',
content: streamedContent, // ✅ Use accumulated content
timestamp: new Date()
};
setMessages(prev => [...prev, assistantMessage]);
}
} finally {
setCurrentStreamingMessage(''); // ✅ Clear streaming state after saving
}
};
```
### Error Boundary Patterns
Always wrap AI components with error boundaries and provide user-friendly error messages for common failure scenarios:
```tsx
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { Alert, AlertDescription } from '@/components/ui/alert';
function AIChatWithErrorBoundary() {
return (
<ErrorBoundary
fallback={
<div className="p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Something went wrong with the AI chat. Please refresh the page and try again.
</AlertDescription>
</Alert>
</div>
}
>
<AIChat />
</ErrorBoundary>
);
}
// In your AI component, handle specific error types gracefully:
function useAIWithErrorHandling() {
const { sendChatMessage, error, clearError } = useShakespeare();
const sendMessage = async (messages: ChatMessage[], modelId: string) => {
try {
await sendChatMessage(messages, modelId);
} catch (err) {
// Handle specific error types with user-friendly messages
if (err.message.includes('401')) {
throw new Error('Authentication failed. Please log in again.');
} else if (err.message.includes('402')) {
throw new Error('Insufficient credits. Please add credits to use premium features.');
} else if (err.message.includes('network')) {
throw new Error('Network error. Please check your internet connection.');
}
throw err; // Re-throw for error boundary
}
};
return { sendMessage, error, clearError };
}
```

54
docs/NOSTR_COMMENTS.md Normal file
View File

@@ -0,0 +1,54 @@
# Adding Nostr Comments Sections
The project includes a complete commenting system using NIP-22 (kind 1111) comments that can be added to any Nostr event or URL. The `CommentsSection` component provides a full-featured commenting interface with threaded replies, user authentication, and real-time updates.
## Basic Usage
```tsx
import { CommentsSection } from "@/components/comments/CommentsSection";
function ArticlePage({ article }: { article: NostrEvent }) {
return (
<div className="space-y-6">
{/* Your article content */}
<div>{/* article content */}</div>
{/* Comments section */}
<CommentsSection root={article} />
</div>
);
}
```
## Props and Customization
The `CommentsSection` component accepts the following props:
- **`root`** (required): The root event or URL to comment on. Can be a `NostrEvent` or `URL` object.
- **`title`**: Custom title for the comments section (default: "Comments")
- **`emptyStateMessage`**: Message shown when no comments exist (default: "No comments yet")
- **`emptyStateSubtitle`**: Subtitle for empty state (default: "Be the first to share your thoughts!")
- **`className`**: Additional CSS classes for styling
- **`limit`**: Maximum number of comments to load (default: 500)
```tsx
<CommentsSection
root={event}
title="Discussion"
emptyStateMessage="Start the conversation"
emptyStateSubtitle="Share your thoughts about this post"
className="mt-8"
limit={100}
/>
```
## Commenting on URLs
The comments system also supports commenting on external URLs, making it useful for web pages, articles, or any online content:
```tsx
<CommentsSection
root={new URL("https://example.com/article")}
title="Comments on this article"
/>
```

View File

@@ -0,0 +1,473 @@
### Direct Messaging on Nostr
This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage.
**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application.
## Setup Instructions
### 1. Add DMProvider to Your App
First, add the `DMProvider` to your app's provider tree in `src/App.tsx`:
```tsx
// Add these imports at the top of src/App.tsx
import { DMProvider, type DMConfig } from '@/components/DMProvider';
import { PROTOCOL_MODE } from '@/lib/dmConstants';
// Add this configuration before your App component
const dmConfig: DMConfig = {
// Enable or disable DMs entirely
enabled: true, // Set to true to enable messaging functionality
// Choose one protocol mode:
// PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only
// PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only
// PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17)
protocolMode: PROTOCOL_MODE.NIP17_ONLY, // Recommended for new apps
};
// Then wrap your app components with DMProvider:
export function App() {
return (
<UnheadProvider head={head}>
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<NostrSync />
<DMProvider config={dmConfig}>
<TooltipProvider>
<Toaster />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
</DMProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</AppProvider>
</UnheadProvider>
);
}
```
### 2. Configure DM Settings
The `DMConfig` object supports the following options:
- `enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed.
- `protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support:
- `PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only
- `PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended)
- `PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility)
**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain.
## Quick Start
### 1. Send Messages
```tsx
import { useDMContext } from '@/hooks/useDMContext';
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
function ComposeMessage({ recipientPubkey }: { recipientPubkey: string }) {
const { sendMessage } = useDMContext();
const [content, setContent] = useState('');
const handleSend = async () => {
await sendMessage({
recipientPubkey,
content,
protocol: MESSAGE_PROTOCOL.NIP17, // Uses NIP-44 encryption + gift wrapping
});
setContent('');
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type a message..."
/>
<button type="submit">Send</button>
</form>
);
}
```
### 2. Display Conversations
```tsx
import { useDMContext } from '@/hooks/useDMContext';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
function ConversationList({ onSelectConversation }: { onSelectConversation: (pubkey: string) => void }) {
const { conversations, isLoading } = useDMContext();
if (isLoading) {
return <div>Loading conversations...</div>;
}
return (
<div className="space-y-2">
{conversations.map((conversation) => (
<ConversationItem
key={conversation.pubkey}
conversation={conversation}
onClick={() => onSelectConversation(conversation.pubkey)}
/>
))}
</div>
);
}
function ConversationItem({ conversation, onClick }: {
conversation: ConversationSummary;
onClick: () => void;
}) {
const author = useAuthor(conversation.pubkey);
const displayName = author.data?.metadata?.name || genUserName(conversation.pubkey);
const avatarUrl = author.data?.metadata?.picture;
return (
<button onClick={onClick} className="w-full p-3 hover:bg-accent rounded-lg">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={avatarUrl} />
<AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 text-left">
<div className="font-medium">{displayName}</div>
<div className="text-sm text-muted-foreground truncate">
{conversation.lastMessage?.decryptedContent || 'No messages yet'}
</div>
</div>
</div>
</button>
);
}
```
### 3. Display Messages in a Conversation
```tsx
import { useConversationMessages } from '@/hooks/useConversationMessages';
import { useCurrentUser } from '@/hooks/useCurrentUser';
function MessageThread({ conversationPubkey }: { conversationPubkey: string }) {
const { user } = useCurrentUser();
const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(conversationPubkey);
return (
<div className="flex flex-col space-y-2">
{hasMoreMessages && (
<button onClick={loadEarlierMessages} className="text-sm text-muted-foreground">
Load earlier messages
</button>
)}
{messages.map((message) => {
const isFromMe = message.pubkey === user?.pubkey;
return (
<div
key={message.id}
className={cn(
"flex",
isFromMe ? "justify-end" : "justify-start"
)}
>
<div className={cn(
"max-w-[70%] rounded-lg px-4 py-2",
isFromMe ? "bg-primary text-primary-foreground" : "bg-muted"
)}>
{message.error ? (
<span className="text-red-500">🔒 {message.error}</span>
) : (
<p className="whitespace-pre-wrap break-words">
{message.decryptedContent}
</p>
)}
{message.isSending && (
<span className="text-xs opacity-50">Sending...</span>
)}
</div>
</div>
);
})}
</div>
);
}
```
## Using the Complete Messaging Interface
For a fully-featured messaging UI out of the box, use the `DMMessagingInterface` component:
```tsx
import { DMMessagingInterface } from "@/components/dm/DMMessagingInterface";
function MessagesPage() {
return (
<div className="container mx-auto p-4 h-screen">
<DMMessagingInterface />
</div>
);
}
```
The `DMMessagingInterface` component provides a complete messaging UI with:
- Conversation list with Active/Requests tabs
- Message thread view with pagination
- Compose area with file upload support
- Real-time message updates
- Mobile-responsive layout (shows one panel at a time on mobile)
It requires no props and works automatically when wrapped in `DMProvider`.
**For custom layouts**, see the "Building Custom Messaging UIs" section below for individual components (`DMConversationList`, `DMChatArea`, `DMStatusInfo`).
## Sending Files with Messages
```tsx
import { useDMContext } from '@/hooks/useDMContext';
import { useUploadFile } from '@/hooks/useUploadFile';
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
import type { FileAttachment } from '@/contexts/DMContext';
function ComposeWithFiles({ recipientPubkey }: { recipientPubkey: string }) {
const { sendMessage } = useDMContext();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const [content, setContent] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleSend = async () => {
let attachments: FileAttachment[] | undefined;
// Upload file if one is selected
if (selectedFile) {
const tags = await uploadFile(selectedFile);
attachments = [{
url: tags[0][1], // URL from first tag
mimeType: selectedFile.type,
size: selectedFile.size,
name: selectedFile.name,
tags: tags
}];
}
await sendMessage({
recipientPubkey,
content,
protocol: MESSAGE_PROTOCOL.NIP17,
attachments,
});
setContent('');
setSelectedFile(null);
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type a message..."
/>
<input
type="file"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
/>
{selectedFile && <div>Selected: {selectedFile.name}</div>}
<button type="submit" disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Send'}
</button>
</form>
);
}
```
## Protocol Comparison
### NIP-04 (Legacy)
- **Encryption**: NIP-04 (simpler, older)
- **Metadata**: Sender and recipient visible to relays
- **Event Kind**: Kind 4
- **Use When**: Compatibility with older clients
### NIP-17 (Modern & Private)
- **Encryption**: NIP-44 (stronger)
- **Metadata**: Hidden via gift wrapping (NIP-59)
- **Event Kinds**: Kind 14 (text), Kind 15 (files)
- **Wrapped In**: Kind 1059 (Gift Wrap) with ephemeral keys
- **Use When**: Maximum privacy (recommended)
**Key Privacy Features of NIP-17:**
- Sender identity hidden (uses random ephemeral keys)
- Timestamps randomized (±2 days) to hide send time
- Dual gift wraps (recipient + sender) for message history
## Advanced Features
### Conversation Categorization
The system automatically categorizes conversations:
```tsx
const { conversations } = useDMContext();
// Filter by category
const knownConversations = conversations.filter(c => c.isKnown);
const requestConversations = conversations.filter(c => c.isRequest);
// isKnown = true if user has sent at least one message
// isRequest = true if only received messages, never replied
```
### Loading States
```tsx
const { isLoading, loadingPhase, scanProgress } = useDMContext();
// Check overall loading state
if (isLoading) {
console.log('Current phase:', loadingPhase);
// LOADING_PHASES.CACHE - Loading from local cache
// LOADING_PHASES.RELAYS - Querying relays
// LOADING_PHASES.SUBSCRIPTIONS - Setting up real-time updates
// LOADING_PHASES.READY - Fully loaded
}
// Display scan progress for large message histories
if (scanProgress.nip17) {
console.log(`NIP-17: ${scanProgress.nip17.current} messages - ${scanProgress.nip17.status}`);
}
```
### Clear Cache and Refresh
```tsx
import { useDMContext } from '@/hooks/useDMContext';
function SettingsButton() {
const { clearCacheAndRefetch } = useDMContext();
const handleClearCache = async () => {
await clearCacheAndRefetch();
// Clears IndexedDB cache and reloads all messages from relays
};
return (
<button onClick={handleClearCache}>
Clear Message Cache
</button>
);
}
```
## Architecture Notes
### Data Flow
1. **Cache First**: Messages load instantly from encrypted IndexedDB cache
2. **Background Sync**: New messages fetched from relays in parallel
3. **Real-time Updates**: WebSocket subscriptions for live messages
4. **Optimistic UI**: Sent messages appear immediately, confirmed on relay response
### Storage
- **IndexedDB**: All messages stored locally with NIP-44 encryption
- **Per-User Storage**: Separate encrypted store for each logged-in user
- **Automatic Sync**: Debounced writes (15s) + immediate on new messages
### Performance
- **Parallel Queries**: NIP-04 and NIP-17 messages fetched simultaneously
- **Batched Loading**: Messages loaded in batches (1000/batch, 20k limit)
- **Pagination**: Conversation messages paginated (25/page)
- **Deduplication**: Automatic filtering of duplicate messages by ID
### Security
- **NIP-44 Encryption**: Modern authenticated encryption for all NIP-17 messages
- **Local Encryption**: IndexedDB storage encrypted with user's NIP-44 key
- **Ephemeral Keys**: Random keys for NIP-17 gift wraps (sender anonymity)
- **No Plaintext**: Decrypted content never persisted unencrypted
- **Domain Isolation**: IndexedDB databases are namespaced by hostname to prevent data conflicts
## Building Custom Messaging UIs
For advanced use cases, you can use the individual DM components to build custom layouts:
### Available Components
**`DMConversationList`** - Conversation sidebar with tabs
```tsx
import { DMConversationList } from '@/components/dm/DMConversationList';
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={(pubkey) => setSelectedPubkey(pubkey)}
onStatusClick={() => setShowStatus(true)} // optional
className="h-full"
/>
```
**`DMChatArea`** - Message thread and compose area
```tsx
import { DMChatArea } from '@/components/dm/DMChatArea';
<DMChatArea
pubkey={selectedPubkey}
onBack={() => setSelectedPubkey(null)} // optional, for mobile back button
className="h-full"
/>
```
**`DMStatusInfo`** - Debug/status panel
```tsx
import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
<DMStatusInfo clearCacheAndRefetch={clearCacheAndRefetch} />
```
### Custom Layout Example
```tsx
import { useState } from 'react';
import { DMConversationList } from '@/components/dm/DMConversationList';
import { DMChatArea } from '@/components/dm/DMChatArea';
function CustomMessagingLayout() {
const [selectedPubkey, setSelectedPubkey] = useState<string | null>(null);
return (
<div className="flex h-screen">
{/* Custom sidebar */}
<aside className="w-64 border-r">
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={setSelectedPubkey}
/>
</aside>
{/* Custom main area */}
<main className="flex-1">
{selectedPubkey ? (
<DMChatArea pubkey={selectedPubkey} />
) : (
<div className="flex items-center justify-center h-full">
<p>Select a conversation to start messaging</p>
</div>
)}
</main>
</div>
);
}
```

View File

@@ -0,0 +1,72 @@
### Infinite Scroll for Nostr Feeds
For feed-like interfaces, implement infinite scroll using TanStack Query's `useInfiniteQuery` with Nostr's timestamp-based pagination:
```typescript
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
export function useGlobalFeed() {
const { nostr } = useNostr();
return useInfiniteQuery({
queryKey: ['global-feed'],
queryFn: async ({ pageParam, signal }) => {
const filter = { kinds: [1], limit: 20 };
if (pageParam) filter.until = pageParam;
const events = await nostr.query([filter], {
signal: AbortSignal.any([signal, AbortSignal.timeout(1500)])
});
return events;
},
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) return undefined;
return lastPage[lastPage.length - 1].created_at - 1; // Subtract 1 since 'until' is inclusive
},
initialPageParam: undefined,
});
}
```
Example usage with intersection observer for automatic loading:
```tsx
import { useInView } from 'react-intersection-observer';
import { useMemo } from 'react';
function GlobalFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useGlobalFeed();
const { ref, inView } = useInView();
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Remove duplicate events by ID
const posts = useMemo(() => {
const seen = new Set();
return data?.pages.flat().filter(event => {
if (!event.id || seen.has(event.id)) return false;
seen.add(event.id);
return true;
}) || [];
}, [data?.pages]);
return (
<div className="space-y-4">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{hasNextPage && (
<div ref={ref} className="py-4">
{isFetchingNextPage && <Skeleton className="h-20 w-full" />}
</div>
)}
</div>
);
}
```