mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-06 18:51:16 +02:00
initial mkstack code commit (lumina v2 init)
This commit is contained in:
332
docs/AI_CHAT.md
Normal file
332
docs/AI_CHAT.md
Normal 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
54
docs/NOSTR_COMMENTS.md
Normal 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"
|
||||
/>
|
||||
```
|
||||
473
docs/NOSTR_DIRECT_MESSAGES.md
Normal file
473
docs/NOSTR_DIRECT_MESSAGES.md
Normal 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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
72
docs/NOSTR_INFINITE_SCROLL.md
Normal file
72
docs/NOSTR_INFINITE_SCROLL.md
Normal 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>
|
||||
);
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user